diff --git a/.gitignore b/.gitignore index e09a007..fb068c8 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ data.json # Exclude macOS Finder (System Explorer) View States .DS_Store + +.env \ No newline at end of file diff --git a/README.md b/README.md index 56c53ca..c7567a7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# SQLite DB Plugin for Obsidian +# SQLite DB Plugin -The **SQLite DB Plugin for Obsidian** allows you to interact with SQLite databases directly within your Obsidian vault. You can execute SQL queries, generate charts from your data, inspect table structures, and even export table rows as notes. +The **SQLite DB Plugin** allows you to interact with SQLite databases directly within your Obsidian vault. You can execute SQL queries, generate charts from your data, inspect table structures, and even export table rows as notes. --- @@ -19,7 +19,7 @@ The **SQLite DB Plugin for Obsidian** allows you to interact with SQLite databas --- -## Installation +## Configuration 1. **Open Obsidian Settings** Navigate to **Community Plugins** and disable **Safe Mode**. @@ -29,19 +29,34 @@ The **SQLite DB Plugin for Obsidian** allows you to interact with SQLite databas In your Community Plugins list, enable the plugin. 4. **Download the .wasm file** From the repository and put in the folder of the plugin (.obsidian/plugins/sqlite-db) ---- -## Configuration +Once the plugin is installed, open Settings → SQLite DB Plugin to configure it. +You can choose to work with either a local database file or a remote API, and optionally integrate with your daily notes or Cloudflare Access for secure API access. -Before using the plugin, set the path to your SQLite database in the plugin settings: -- **Database Path:** Absolute path to your SQLite database file. - _Example:_ `/home/user/path/to/database.db` +🔀 Database Mode +Choose whether the plugin interacts with a local SQLite file or a remote API server. + +📁 Local Mode Settings +Used when Database mode is set to Local. Here you can set your full absolute path to your .db SQLite file + +🌐 Remote Mode Settings +Used when Database mode is set to Remote. +Ideal for syncing across devices or accessing a shared dataset via HTTP. + +If you protect your API using Cloudflare Access, provide your Client ID and Client Secret here for automatic token-based login. + +🗓️ Journal Settings +These options help the plugin locate and manage your journal entries if you have some. This + the commands registered in the plugin allows you to dump the entries in your db into the folder or the other way round, upsert the journal you wrote in obsidian into your own DB. + +This as other parts of the plugin requires you to setup a specific database schema for the tables --- ## Usage -### SQL Code Blocks +### Codeblocks + +#### SQL Code Blocks Create a code block labeled with `sql` to run a SQL query. For example: @@ -66,21 +81,6 @@ This query will: - Order the result by the `due` column in ascending order. - Limit the number of rows to 10. -### Chart Code Blocks - -Create a code block labeled with `sql-chart` for visualizations. For example: - -```sql-chart -table: Time -chartType: pie -categoryColumn: tag -valueColumn: duration -``` - -![Pie Chart](./assets/pie-chart.png) - -## Query Parameters - Below is a list of available parameters you can use in your SQL blocks: | Parameter | Description | Example | @@ -94,11 +94,22 @@ Below is a list of available parameters you can use in your SQL blocks: | `endDate` | Ending date for filtering. | `endDate: 2024-12-31` | | `orderBy` | Column to order the results by. | `orderBy: due` | | `orderDirection`| Direction of sort (`asc` or `desc`). | `orderDirection: asc` | -| `limit` | Maximum number of rows to return. | `limit: 10` | +| `limit` | Maximum number of rows to return. | `limit: 10` ---- +#### Chart Code Blocks -## Chart Parameters +Create a code block labeled with `sql-chart` for visualizations. For example: + +```sql-chart +table: Time +chartType: pie +categoryColumn: tag +valueColumn: duration +``` + +![Pie Chart](./assets/pie-chart.png) + +**Parameters** | Parameter | Description | Example | | --------------- | ------------------------------------------------------------------- | --------------------------------- | @@ -108,7 +119,7 @@ Below is a list of available parameters you can use in your SQL blocks: Each chart takes an optional chartOptions object that can be used to customize the different chart types: -### Line Chart +**Chart types** ```sql-chart @@ -135,7 +146,6 @@ chartOptions: { ``` -### Bar Chart ```sql-chart table: QuantifiableHabits @@ -158,8 +168,6 @@ chartOptions: { } ``` -### Pie Chart - ```sql-chart table: Money @@ -177,3 +185,78 @@ chartOptions: { isDoughnut: true } ``` + +### Interactive Components + +The plugin supports custom interactive HTML components you can embed in your notes to view and update data live. These components are initialized at runtime using your database settings. +I wrote these to create interactive Periodic Notes, and update data in them accordingly + +#### Boolean Switch + +Use the component to create toggleable switches tied to boolean habit data. This lets you interact with your data directly from a note, such as marking a habit done or undone for a given day. + +``` + + data-emoji="🧘" + data-date="2025-05-09" + data-table="BooleanHabits" + data-habit-id-col="habitKey" + data-value-col="value" + data-date-col="date"> + +``` + +![Boolean Switch](./assets/boolean.png) + +#### Habit Counter + +Use the component to track numerical habits, such as how many coffees you had or how many cigarettes you smoked today. + +``` + + data-emoji="🚬" + data-date="2025-05-09" + data-table="QuantifiableHabits" + data-habit-id-col="habitKey" + data-value-col="value" + data-date-col="date"> + +``` + +![Habit Counter](./assets/habits.png) + +#### Text Input + +Use the component to display and optionally update text fields in your database. + +``` + + data-date="@date" + data-value-col="dayRating" + data-date-col="date" + placeholder="How do u feel today?" + data-label="Day Rating 📈" +/> +``` + +It supports both inline editing and a "button-triggered" modal input, making it versatile for journal prompts, mood logs, notes, or custom fields. + +``` + + data-modal-type="time-picker" + placeholder="Select Time" + data-label="Sleep Time 💤" +/> +``` + +![Text Input](./assets/text-input.png) + diff --git a/assets/boolean.png b/assets/boolean.png new file mode 100644 index 0000000..140c9c9 Binary files /dev/null and b/assets/boolean.png differ diff --git a/assets/habits.png b/assets/habits.png new file mode 100644 index 0000000..f89a938 Binary files /dev/null and b/assets/habits.png differ diff --git a/assets/text-input.png b/assets/text-input.png new file mode 100644 index 0000000..d28c3cd Binary files /dev/null and b/assets/text-input.png differ diff --git a/main.ts b/main.ts index 8df0ad2..e8511ff 100644 --- a/main.ts +++ b/main.ts @@ -1,39 +1,92 @@ import { - App, Plugin, - PluginSettingTab, - Setting, - Notice, FileSystemAdapter, Editor, MarkdownView, - MarkdownPostProcessorContext + MarkdownPostProcessorContext, + Notice, } from "obsidian"; -import { DBService } from "./src/dbService"; -import { inspectTableStructure, convertEntriesInNotes } from "./src/commands"; -import { processSqlBlock, processSqlChartBlock } from "./src/codeblocks"; -import { pickTableName } from "./src/helpers"; +import { DBService } from "./src/DBService"; +import { SQLiteDBSettingTab } from "./src/settingTab"; +import { inspectTableStructure, convertEntriesInNotes, syncDBToJournals, syncJournalsToDB } from "./src/commands"; +import { processSqlBlock, processSqlChartBlock, DateNavigatorRenderer } from "./src/codeblocks"; +import { pickTableName, replacePlaceholders } from "./src/helpers"; +import { injectDatePickerStyles, injectDateNavigatorStyles, injectTimePickerStyles, removeDateNavigatorStyles } from "src/styles"; +import { + registerHabitCounter, + registerBooleanSwitch, + registerTextInput, + registerTimestampUpdaterButton, + registerSqlChartRenderer, + registerSqlRenderer, + registerMoodNoteButtonProcessor, + registerAddTextSupport +} from "./src/webcomponents"; import { SQLiteDBSettings, DEFAULT_SETTINGS } from "./src/types"; +import { pluginState } from "src/pluginState"; export default class SQLiteDBPlugin extends Plugin { settings: SQLiteDBSettings; private dbService: DBService; - + async onload() { - console.log("Loading SQLiteDBPlugin..."); + // init await this.loadSettings(); - this.dbService = new DBService(this.app); - await this.openDatabase(); + pluginState.initialize(this.app); + + injectDatePickerStyles(); + injectDateNavigatorStyles(); + injectTimePickerStyles(); + + //? Components + this.registerMarkdownPostProcessor((el, ctx) => { + registerHabitCounter(el, this.dbService); + registerBooleanSwitch(el, this.dbService); + registerTextInput(el, this.app, this.dbService); + }); + + registerSqlChartRenderer(this, this.dbService); + registerSqlRenderer(this, this.dbService); + registerTimestampUpdaterButton(this); + registerMoodNoteButtonProcessor(this, this.dbService); + registerAddTextSupport(this, this.dbService); + + + //? Codeblocks + this.registerMarkdownCodeBlockProcessor( + "sql", + async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { + const processedSource = replacePlaceholders(source); + await processSqlBlock(this.dbService, processedSource, el); + } + ); + + this.registerMarkdownCodeBlockProcessor( + "sql-chart", + async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { + const processedSource = replacePlaceholders(source); + await processSqlChartBlock(this.dbService, processedSource, el); + } + ); + + this.registerMarkdownCodeBlockProcessor( + "date-header", + (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { + const processedSource = replacePlaceholders(source); + ctx.addChild(new DateNavigatorRenderer(el, this.app, processedSource)); + + } + ); + + //? Commands this.addCommand({ 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); }, }); @@ -41,9 +94,7 @@ export default class SQLiteDBPlugin extends Plugin { this.addCommand({ id: "dump-table-to-notes", name: "Dump table to notes", - callback: async () => { - await this.openDatabase(); // ensure DB is loaded - + callback: async () => { // 1) pick a table const chosenTable = await pickTableName(this.dbService, this.app); if (!chosenTable) { @@ -55,39 +106,55 @@ export default class SQLiteDBPlugin extends Plugin { }, }); - this.registerMarkdownCodeBlockProcessor( - "sql", // <-- the name of your code block (```sql) - async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { - await processSqlBlock(this.dbService, source, el); + this.addCommand({ + id: 'sync-journal-notes-to-db', + name: 'Sync Journal Notes -> Database', + callback: async () => { + if (!this.settings.journalFolderPath || !this.settings.journalTableName) { + new Notice("Please configure Journal folder path and table name in settings."); + return; + } + new Notice("Starting sync: Notes -> DB..."); + await syncJournalsToDB(this.dbService, this.settings.journalFolderPath, this.settings.journalTableName, this.app); } - ); + }); - this.registerMarkdownCodeBlockProcessor( - "sql-chart", - async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { - await processSqlChartBlock(this.dbService, source, el); + this.addCommand({ + id: 'sync-journal-db-to-notes', + name: 'Sync Database -> Journal Notes', + callback: async () => { + if (!this.settings.journalFolderPath || !this.settings.journalTableName) { + new Notice("Please configure Journal folder path and table name in settings."); + return; + } + new Notice("Starting sync: DB -> Notes..."); + await syncDBToJournals(this.dbService, this.settings.journalTableName, this.settings.journalFolderPath, this.app); } - ); + }); this.addSettingTab(new SQLiteDBSettingTab(this.app, this)); } onunload() { - console.log("Unloading SQLiteDBPlugin..."); + removeDateNavigatorStyles(); } private async openDatabase(forceReload = true) { const adapter = this.app.vault.adapter; let basePath: string; - + if (adapter instanceof FileSystemAdapter) { basePath = adapter.getBasePath(); } else { basePath = (adapter as any).getFullPath(""); } - - await this.dbService.ensureDBLoaded(this.settings, basePath, forceReload); - } + + if (this.settings.mode === "local") { + await this.dbService.ensureDBLoaded(this.settings, basePath, forceReload); + } else { + await this.dbService.ensureDBLoaded(this.settings, basePath, false); + } + } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); @@ -98,29 +165,3 @@ export default class SQLiteDBPlugin extends Plugin { } } -class SQLiteDBSettingTab extends PluginSettingTab { - plugin: SQLiteDBPlugin; - - constructor(app: App, plugin: SQLiteDBPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const { containerEl } = this; - containerEl.empty(); - - new Setting(containerEl) - .setName("Database file path") - .setDesc("Absolute path to the .db file on disk.") - .addText((text) => - text - .setPlaceholder("/home/user/path/to/your.db") - .setValue(this.plugin.settings.dbFilePath) - .onChange(async (value) => { - this.plugin.settings.dbFilePath = value; - await this.plugin.saveSettings(); - }) - ); - } -} diff --git a/src/DBService.ts b/src/DBService.ts new file mode 100644 index 0000000..7ec1e34 --- /dev/null +++ b/src/DBService.ts @@ -0,0 +1,216 @@ +import { Notice, App } from "obsidian"; +import initSqlJs, { Database, SqlJsStatic, Statement } from "sql.js"; +import { readFileSync } from "fs"; +import { writeFile } from "fs/promises"; +import { SQLiteDBSettings } from "./types"; + +export class DBService { + public mode: "local" | "remote" = "local"; + private db: Database | null = null; + private SQL: SqlJsStatic | null = null; // We still need to store the initialized library instance + private app: App; + private settings: SQLiteDBSettings | null = null; // Store settings for saving path + private basePath: string = ""; + private apiBaseUrl: string = ""; + + constructor(app: App) { + this.app = app; + } + + /** + * 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): Promise { // Added return type promise + this.settings = settings; + this.basePath = basePath; + this.mode = settings.mode ?? "local"; + + if (this.mode === "remote") { + if (!settings.apiBaseUrl) { + new Notice("Remote mode selected, but no API base URL configured."); + return false; + } + this.apiBaseUrl = settings.apiBaseUrl; + return true; + } + + if (!settings.dbFilePath) { + new Notice("No local DB path set in plugin settings."); + return false; + } + + // Only reload if forced or not already loaded + if (!this.db || forceReload) { + try { + this.SQL = await initSqlJs({ + locateFile: (file) => `${basePath}/${this.app.vault.configDir}/plugins/sqlite-db/${file}`, + }); + + const fileBuffer = readFileSync(settings.dbFilePath); + this.db = new this.SQL.Database(fileBuffer); + + // 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."); + } + return true; // Indicate success + } catch (err) { + this.db = null; // Ensure db is null on error + this.SQL = null; + console.error("DBService: Error initializing/reading DB:", err); + new Notice("Error reading DB: " + (err as Error).message); + return false; // Indicate failure + } + } else { + return true; // Already loaded + } + } + + /** + * Returns the current Database instance, or null if not loaded. + */ + getDB(): Database | null { + return this.db; + } + + /** + * Executes a query that is expected to return rows (e.g., SELECT). + * Uses prepared statements for security. + * @param sql SQL query string with placeholders (?) + * @param params Array of parameters to bind to placeholders + * @returns Promise resolving to an array of result objects + */ + async getQuery>(sql: string, params: any[] = []): Promise { + if (this.mode === "remote") { + const res = await fetch(`${this.apiBaseUrl}/query`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(this.settings?.cfAccessClientId && { + "CF-Access-Client-Id": this.settings.cfAccessClientId, + }), + ...(this.settings?.cfAccessClientSecret && { + "CF-Access-Client-Secret": this.settings.cfAccessClientSecret, + }), + }, + body: JSON.stringify({ sql, params }), + }); + if (!res.ok) throw new Error(await res.text()); + return await res.json(); + } + + if (!this.db) throw new Error("Database not loaded (local mode)."); + + let stmt: Statement | null = null; + try { + stmt = this.db.prepare(sql); + stmt.bind(params); + const results: T[] = []; + while (stmt.step()) { + results.push(stmt.getAsObject() as T); + } + return results; + } finally { + if (stmt) stmt.free(); + } + } + + /** + * Executes a query that does not return rows (e.g., INSERT, UPDATE, DELETE). + * Uses prepared statements for security. + * Saves the database to disk after successful execution. + * @param sql SQL query string with placeholders (?) + * @param params Array of parameters to bind to placeholders + * @returns Promise resolving when execution and save are complete + */ + async runQuery(sql: string, params: any[] = []): Promise { + if (this.mode === "remote") { + const res = await fetch(`${this.apiBaseUrl}/execute`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(this.settings?.cfAccessClientId && { + "CF-Access-Client-Id": this.settings.cfAccessClientId, + }), + ...(this.settings?.cfAccessClientSecret && { + "CF-Access-Client-Secret": this.settings.cfAccessClientSecret, + }), + }, + body: JSON.stringify({ sql, params }), + }); + if (!res.ok) throw new Error(await res.text()); + return; + } + + if (!this.db) throw new Error("Database not loaded (local mode)."); + + let stmt: Statement | null = null; + try { + stmt = this.db.prepare(sql); + stmt.bind(params); + stmt.run(); + await this._saveDBInternal(); + } finally { + if (stmt) stmt.free(); + } + } + + /** + * Internal method to save the current in-memory database back to the file. + */ + private async _saveDBInternal(): Promise { + if (!this.db) { + console.error("DBService._saveDBInternal: Cannot save, database not loaded."); + return; // Don't throw, just log and return if DB isn't loaded + } + if (!this.settings || !this.settings.dbFilePath) { + console.error("DBService._saveDBInternal: Cannot save, settings or DB path missing."); + new Notice("Cannot save DB: Path missing."); + return; + } + + try { + const data = this.db.export(); + await writeFile(this.settings.dbFilePath, data); + } catch (error) { + console.error(`DBService._saveDBInternal: Error saving database to ${this.settings.dbFilePath}:`, error); + new Notice("Error saving database changes!"); + throw error; + } + } + + public generateUuid(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + /** + * Explicitly closes the database connection. Important for unloading. + */ + closeDB() { + if (this.db) { + try { + console.log("DBService: Closing database connection."); + this.db.close(); + } catch(closeError) { + console.error("DBService: Error closing database:", closeError); + } finally { + this.db = null; + this.SQL = null; + this.settings = null; + this.basePath = ""; + } + } else { + this.SQL = null; + } + } +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/DateNavigator.ts b/src/codeblocks/dateHeader/DateNavigator.ts new file mode 100644 index 0000000..467e2d8 --- /dev/null +++ b/src/codeblocks/dateHeader/DateNavigator.ts @@ -0,0 +1,85 @@ +import { App } from "obsidian"; +import { PluginState } from "src/pluginState"; +import { DateNavigatorOptions, DateNavigatorDOMElements, NavigationPeriod } from "./dateNavigator.types"; +import { createPrevHandler, createNextHandler, createOpenModalHandler } from "./eventHandlers/dateNavigationHandlers"; +import { buildDateNavigatorDOM, updateDateNavigatorDisplay } from "./dom/buildDateNavigatorDOM"; +import { DATE_CHANGED_EVENT_NAME } from "../../pluginState"; +import { createPeriodChangeHandler } from "./eventHandlers/periodChangeHandler"; + +/** + * Creates and manages the interactive Date Navigator header. + * Uses pluginState to get/set the current date. + * Updates its display when the date changes. + */ +export class DateNavigator { + private app: App; + private pluginState: PluginState; + private containerEl: HTMLElement; + private configPeriod: NavigationPeriod; + private elements: DateNavigatorDOMElements | null = null; + private isListening: boolean = false; + + constructor(options: DateNavigatorOptions) { + this.app = options.app; + this.pluginState = options.pluginState; + this.containerEl = options.containerEl; + this.configPeriod = options.period ?? 'day'; + + if (!this.app || !this.pluginState || !this.containerEl) { + throw new Error("DateNavigator missing required options (app, pluginState, containerEl)."); + } + + //? Set the global state's period to match this instance *on initialization*. + //? This means the last rendered navigator sets the global period. + if (this.pluginState.currentPeriod !== this.configPeriod) { + //? Note: This immediately changes global state and triggers event/recalc + this.pluginState.currentPeriod = this.configPeriod; + } + + //? Now build the UI using the synchronized (or initially matching) state + this._buildUI(); + this._setupStateListener(); + } + + /** Builds the initial UI */ + private _buildUI(): void { + const currentGlobalPeriod = this.pluginState.currentPeriod; + const currentSelectedDate = this.pluginState.selectedDate; + + //~ Create bound handlers first + const handlers = { + handlePrev: createPrevHandler(this.pluginState, this.app), + handleNext: createNextHandler(this.pluginState, this.app), + handleOpenModal: createOpenModalHandler(this.pluginState, this.app), + handlePeriodChange: createPeriodChangeHandler(this.pluginState), + }; + //~ Build DOM and store element references + this.elements = buildDateNavigatorDOM( + this.containerEl, + currentSelectedDate, + currentGlobalPeriod, + handlers + ); + } + + /** Updates the displayed date. Bound method for callbacks. */ + private _updateDisplay = (): void => { + const currentIsoDate = this.pluginState.selectedDate; + const currentPeriod = this.pluginState.currentPeriod; + + updateDateNavigatorDisplay(this.elements, currentIsoDate, currentPeriod); + }; + + /** Listen for the custom date change event from pluginState */ + private _setupStateListener(): void { + if (this.isListening) return; + document.addEventListener(DATE_CHANGED_EVENT_NAME, this._updateDisplay); + this.isListening = true; + } + + /** Cleans up the component (e.g., remove listeners, clear container) */ + public destroy(): void { + this.containerEl.empty(); + this.elements = null; + } +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/dateNavigator.types.ts b/src/codeblocks/dateHeader/dateNavigator.types.ts new file mode 100644 index 0000000..f7961ae --- /dev/null +++ b/src/codeblocks/dateHeader/dateNavigator.types.ts @@ -0,0 +1,25 @@ +// src/components/dateNavigator/dateNavigator.types.ts +import { App } from "obsidian"; +import { pluginState } from "src/pluginState"; + +//? Defines the period types the navigator can handle (currently only 'day') +export type NavigationPeriod = 'day' | 'week' | 'month' | 'quarter' | 'year'; + +//? Configuration options for the DateNavigator +export interface DateNavigatorOptions { + app: App; + pluginState: typeof pluginState; + containerEl: HTMLElement; // Where to render the navigator + period?: NavigationPeriod; // Defaults to 'day' + dateFormat?: string; // todo: Allow custom date formats later +} + +//? References to the DOM elements created by the navigator builder +export interface DateNavigatorDOMElements { + wrapper: HTMLElement; + prevButton: HTMLButtonElement; + nextButton: HTMLButtonElement; + dateDisplay: HTMLElement; // The H1 or span showing the date + openModalButton: HTMLButtonElement; + periodSelect?: HTMLSelectElement; +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/dateNavigatorRenderer.ts b/src/codeblocks/dateHeader/dateNavigatorRenderer.ts new file mode 100644 index 0000000..f7e3f7e --- /dev/null +++ b/src/codeblocks/dateHeader/dateNavigatorRenderer.ts @@ -0,0 +1,59 @@ +import { MarkdownRenderChild, App } from 'obsidian'; +import { DateNavigator } from './DateNavigator'; +import { pluginState } from '../../pluginState'; +import { NavigationPeriod } from './dateNavigator.types'; + +//? Helper function to parse the period from the code block source +function parsePeriodFromSource(source: string): NavigationPeriod | null { + const sourceTrimmed = source?.trim().toLowerCase(); + if (!sourceTrimmed) return null; + + const validPeriods: NavigationPeriod[] = ['day', 'week', 'month', 'quarter', 'year']; + if (validPeriods.includes(sourceTrimmed as NavigationPeriod)) { + return sourceTrimmed as NavigationPeriod; + } + console.warn(`[DateNavigatorRenderer] Invalid period specified in code block source: "${sourceTrimmed}". Defaulting.`); + return null; +} + +/** + * An Obsidian Render Child responsible for rendering and managing the lifecycle + * of a DateNavigator instance within a Markdown code block. + */ +export class DateNavigatorRenderer extends MarkdownRenderChild { + private navigator: DateNavigator | null = null; + private initialPeriod: NavigationPeriod; + + constructor( + containerEl: HTMLElement, + private appInstance: App, + private source: string + ) { + super(containerEl); + this.initialPeriod = parsePeriodFromSource(source) ?? 'day'; + } + + /** Called by Obsidian when the component should be loaded/rendered. */ + onload() { + this.containerEl.empty(); + this.containerEl.addClass('date-navigator-container-wrapper'); + + try { + this.navigator = new DateNavigator({ + app: this.appInstance, + pluginState: pluginState, + containerEl: this.containerEl, + period: this.initialPeriod + }); + } catch (error) { + console.error("[DateNavigatorRenderer] Failed to create DateNavigator:", error); + this.containerEl.setText('Error loading date navigator component.'); + } + } + + /** Called by Obsidian when the component should be unloaded/cleaned up. */ + onunload() { + this.navigator?.destroy(); + this.navigator = null; + } +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/dom/buildDateNavigatorDOM.ts b/src/codeblocks/dateHeader/dom/buildDateNavigatorDOM.ts new file mode 100644 index 0000000..4d35ab0 --- /dev/null +++ b/src/codeblocks/dateHeader/dom/buildDateNavigatorDOM.ts @@ -0,0 +1,87 @@ +import type { DateNavigatorDOMElements, NavigationPeriod } from "../dateNavigator.types"; +import { formatPeriodForDisplay } from "src/helpers/datePeriodUtils"; + +//? Structure for the handlers needed by the builder +export interface DateNavigatorHandlers { + handlePrev: () => void; + handleNext: () => void; + handleOpenModal: () => void; + handlePeriodChange: (event: Event) => void; +} + +/** Builds the DOM for the date navigator header. */ +export function buildDateNavigatorDOM( + containerEl: HTMLElement, + initialIsoDate: string, + initialPeriod: NavigationPeriod, + handlers: DateNavigatorHandlers +): DateNavigatorDOMElements { + + containerEl.empty(); + const wrapper = containerEl.createDiv({ cls: "date-navigator-wrapper" }); + const navRow = wrapper.createDiv({ cls: "date-nav-row" }); + + // --- Navigation Buttons --- + const prevLabel = `Previous ${initialPeriod}`; + const nextLabel = `Next ${initialPeriod}`; + + const prevButton = navRow.createEl("button", { text: "←", cls: "clickable-icon date-nav-button date-nav-prev" }); + prevButton.setAttribute("aria-label", prevLabel); + prevButton.addEventListener("click", handlers.handlePrev); + + // --- Date Display (H1) --- + const dateDisplay = navRow.createEl("h1", { cls: "date-navigator-display" }); + dateDisplay.textContent = formatPeriodForDisplay(initialIsoDate, initialPeriod); + + const nextButton = navRow.createEl("button", { text: "→", cls: "clickable-icon date-nav-button date-nav-next" }); + nextButton.setAttribute("aria-label", nextLabel); + nextButton.addEventListener("click", handlers.handleNext); + + const controlsRow = wrapper.createDiv({cls: "date-controls-row"}); // Inner div for controls + + // --- Open Modal Button --- + //? Placed below the H1 for structure + const openModalButton = controlsRow.createEl("button", { text: "Select Date", cls: "date-nav-open-modal" }); + openModalButton.addEventListener("click", handlers.handleOpenModal); + + //todo meh i dont like it + // const periodSelect = controlsRow.createEl("select", { cls: "dropdown date-nav-period-select" }); + // periodSelect.setAttribute("aria-label", "Select Period"); + // const periods: NavigationPeriod[] = ['day', 'week', 'month', 'quarter', 'year']; + // periods.forEach(p => { + // const option = periodSelect.createEl("option"); + // option.value = p; + // option.textContent = p.charAt(0).toUpperCase() + p.slice(1); // Capitalize + // if (p === initialPeriod) { + // option.selected = true; // Set initial selection + // } + // }); + // periodSelect.addEventListener("change", handlers.handlePeriodChange); + + return { wrapper, prevButton, nextButton, dateDisplay, openModalButton }; +} + +/** Updates the displayed date text in the navigator. */ +export function updateDateNavigatorDisplay( + elements: DateNavigatorDOMElements | null, + newIsoDate: string, + newPeriod: NavigationPeriod +): void { + const displayString = formatPeriodForDisplay(newIsoDate, newPeriod); + if (!elements) { return; } + + // Update Title + if (elements.dateDisplay) { + elements.dateDisplay.textContent = displayString; + } + // Update Button Labels + const prevLabel = `Previous ${newPeriod.charAt(0).toUpperCase() + newPeriod.slice(1)}`; + const nextLabel = `Next ${newPeriod.charAt(0).toUpperCase() + newPeriod.slice(1)}`; + if (elements.prevButton) elements.prevButton.setAttribute("aria-label", prevLabel); + if (elements.nextButton) elements.nextButton.setAttribute("aria-label", nextLabel); + + // Update Period Selector (if its value differs from the new global period) + if (elements.periodSelect && elements.periodSelect.value !== newPeriod) { + elements.periodSelect.value = newPeriod; + } +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/eventHandlers/dateNavigationHandlers.ts b/src/codeblocks/dateHeader/eventHandlers/dateNavigationHandlers.ts new file mode 100644 index 0000000..eef00bb --- /dev/null +++ b/src/codeblocks/dateHeader/eventHandlers/dateNavigationHandlers.ts @@ -0,0 +1,58 @@ +import { App } from "obsidian"; +import { DatePickerModal } from "src/components/DatePickerModal/DatePickerModal"; +import { PluginState } from "src/pluginState"; +import { calculateAdjacentPeriodDate } from "src/helpers/datePeriodUtils"; + +/** Updates the global plugin state with the new date and triggers necessary updates */ +function updateGlobalDate(newIsoDate: string, pluginState: PluginState): void { + //? pluginState setter handles recalculation and event dispatch + pluginState.selectedDate = newIsoDate; +} + +/** Creates the handler for the "previous" button click */ +export function createPrevHandler( + pluginState: PluginState, + app: App +): () => void { + return () => { + const currentPeriod = pluginState.currentPeriod; + const newIsoDate = calculateAdjacentPeriodDate(pluginState.selectedDate, currentPeriod, 'prev'); + if (newIsoDate) { + updateGlobalDate(newIsoDate, pluginState); // Updates state -> triggers display update via listener + } else { + console.error("[DateNavHandlers] Failed to calculate previous date."); + } + }; +} + +/** Creates the handler for the "next" button click */ +export function createNextHandler( + pluginState: PluginState, + app: App +): () => void { + return () => { + const currentPeriod = pluginState.currentPeriod; + const newIsoDate = calculateAdjacentPeriodDate(pluginState.selectedDate, currentPeriod, 'next'); + if (newIsoDate) { + updateGlobalDate(newIsoDate, pluginState); + } else { + console.error("[DateNavHandlers] Failed to calculate next date."); + } + }; +} + +/** Creates the handler for the "open modal" button click */ +export function createOpenModalHandler( + pluginState: PluginState, + app: App, +): () => void { + return () => { + new DatePickerModal(app, pluginState.selectedDate, (newIsoDate) => { + //? Modal selecting a specific DAY always updates the global selectedDate + //? State setter will handle recalculation and events + updateGlobalDate(newIsoDate, pluginState); + // updateDisplayCallback(newIsoDate); // Display updates via listener + }).open(); + }; +} + diff --git a/src/codeblocks/dateHeader/eventHandlers/periodChangeHandler.ts b/src/codeblocks/dateHeader/eventHandlers/periodChangeHandler.ts new file mode 100644 index 0000000..d9596c5 --- /dev/null +++ b/src/codeblocks/dateHeader/eventHandlers/periodChangeHandler.ts @@ -0,0 +1,23 @@ +import { PluginState } from "src/pluginState"; +import { NavigationPeriod } from "src/codeblocks/dateHeader/dateNavigator.types"; + +/** Creates the handler for the period selector's 'change' event. */ +export function createPeriodChangeHandler( + pluginState: PluginState +): (event: Event) => void { + return (event: Event) => { + if (event.target instanceof HTMLSelectElement) { + const newPeriod = event.target.value as NavigationPeriod; + + //? Validate if it's a known period type before setting + const validPeriods: NavigationPeriod[] = ['day', 'week', 'month', 'quarter', 'year']; + if (validPeriods.includes(newPeriod)) { + //^ Update the GLOBAL plugin state's period + //? This will trigger recalculation and the 'plugin-date-changed' event + pluginState.currentPeriod = newPeriod; + } else { + console.warn(`[PeriodChangeHandler] Invalid period selected: ${newPeriod}`); + } + } + }; +} \ No newline at end of file diff --git a/src/codeblocks/index.ts b/src/codeblocks/index.ts index 38c67b6..023a8fe 100644 --- a/src/codeblocks/index.ts +++ b/src/codeblocks/index.ts @@ -1,4 +1,5 @@ import { processSqlBlock } from "./processSqlBlock"; import { processSqlChartBlock } from "./processSqlChartBlock"; +import { DateNavigatorRenderer } from "./dateHeader/dateNavigatorRenderer"; -export { processSqlBlock, processSqlChartBlock }; \ No newline at end of file +export { processSqlBlock, processSqlChartBlock, DateNavigatorRenderer }; \ No newline at end of file diff --git a/src/codeblocks/processSqlBlock/helpers/buildSqlQuery.ts b/src/codeblocks/processSqlBlock/helpers/buildSqlQuery.ts index 228b74c..a1cd1b8 100644 --- a/src/codeblocks/processSqlBlock/helpers/buildSqlQuery.ts +++ b/src/codeblocks/processSqlBlock/helpers/buildSqlQuery.ts @@ -1,8 +1,51 @@ import { SqlParams } from "../types"; +/** + * Builds the date part of a WHERE clause using >= startDate and < nextDay logic. + * @param dateColumn The name of the date/timestamp column. + * @param startDate The start date string ('YYYY-MM-DD'). + * @param endDate The end date string ('YYYY-MM-DD'). + * @returns An object { clause: string; params: string[] } or null if no date filtering needed or on error. + */ +export function buildDateWhereClause( + dateColumn: string | undefined, + startDate: string | undefined, + endDate: string | undefined +): { clause: string; params: string[] } | null { + + // Only proceed if all three are provided + if (!dateColumn || !startDate || !endDate) { + return null; + } + + try { + // --- Calculate the day AFTER the end date --- + const endDateObj = new Date(endDate + 'T00:00:00Z'); // Parse as UTC midnight + if (isNaN(endDateObj.getTime())) { + throw new Error(`Invalid end date format: ${endDate}`); + } + endDateObj.setUTCDate(endDateObj.getUTCDate() + 1); // Add 1 day UTC + const nextDayString = endDateObj.toISOString().split('T')[0]; // Format 'YYYY-MM-DD' + + // --- Build the SQL WHERE Clause --- + // Quote the column name for safety + const clause = `"${dateColumn}" >= ? AND "${dateColumn}" < ?`; + const params = [startDate, nextDayString]; // Use string dates + + // console.log(`buildDateWhereClause: Generated clause="${clause}", params=[${params.join(', ')}]`); // Optional debug log + + return { clause, params }; + + } catch (dateError) { + console.error("Error processing date range for SQL query:", dateError); + // Returning null effectively skips date filtering on error. + return null; + } +} + export function buildSqlQuery(params: SqlParams): { query: string; queryParams: any[] } { - const { - table, + const { + table, columns, dateColumn, startDate, @@ -11,17 +54,17 @@ export function buildSqlQuery(params: SqlParams): { query: string; queryParams: filterValue, orderBy, orderDirection, - limit + limit } = params; - - const selectCols = columns - ? columns.split(",").map(c => c.trim()).join(", ") + + const selectCols = columns + ? columns.split(",").map(c => `"${c.trim()}"`).join(", ") // Quote columns here : "*"; const queryParams: any[] = []; const conditions: string[] = []; - // Handle multiple filters + // Handle multiple filters (already quotes columns) if (filterColumn && filterValue) { const filterColumns = Array.isArray(filterColumn) ? filterColumn : [filterColumn]; const filterValues = Array.isArray(filterValue) ? filterValue : [filterValue]; @@ -31,44 +74,45 @@ export function buildSqlQuery(params: SqlParams): { query: string; queryParams: } filterColumns.forEach((col, index) => { + // Ensure column name is quoted conditions.push(`"${col}" = ?`); queryParams.push(filterValues[index]); }); } - // Add date range conditions if provided - if (dateColumn) { - if (startDate) { - conditions.push(`"${dateColumn}" >= ?`); - queryParams.push(startDate); - } - if (endDate) { - conditions.push(`"${dateColumn}" <= ?`); - queryParams.push(endDate); - } + // Add the new logic: + const dateFilter = buildDateWhereClause(dateColumn, startDate, endDate); + if (dateFilter) { + conditions.push(dateFilter.clause); // Add the generated clause (e.g., '"col" >= ? AND "col" < ?') + queryParams.push(...dateFilter.params); // Add the parameters (e.g., ['2023-10-27', '2023-10-28']) } - + // Build the query + // Quote table name for safety let query = `SELECT ${selectCols} FROM "${table}"`; - + // Add WHERE clause if there are conditions if (conditions.length > 0) { query += ` WHERE ${conditions.join(" AND ")}`; } - // Add ORDER BY if specified + // Add ORDER BY if specified (already quotes column) if (orderBy) { - query += ` ORDER BY "${orderBy}" ${orderDirection || 'ASC'}`; + // Ensure direction is safe (ASC or DESC) + const direction = (orderDirection?.toUpperCase() === 'DESC') ? 'DESC' : 'ASC'; + query += ` ORDER BY "${orderBy}" ${direction}`; } // Add LIMIT if specified - if (limit) { + if (limit !== undefined && limit !== null) { // Check explicitly for presence query += ` LIMIT ?`; queryParams.push(limit); } + // Optional: Remove trailing semicolon if your execution method doesn't require it + // return { query, queryParams }; return { - query: query + ";", + query: query, // Removed trailing semicolon for broader compatibility queryParams }; } \ No newline at end of file diff --git a/src/codeblocks/processSqlBlock/helpers/parseSqlParams.ts b/src/codeblocks/processSqlBlock/helpers/parseSqlParams.ts index b915fdb..27cec69 100644 --- a/src/codeblocks/processSqlBlock/helpers/parseSqlParams.ts +++ b/src/codeblocks/processSqlBlock/helpers/parseSqlParams.ts @@ -45,14 +45,41 @@ export function parseSqlParams(source: string): SqlParams | null { case 'orderBy': params.orderBy = val; break; + case 'displayFormat': + const format = val.toLowerCase(); + if (format === 'list' || format === 'table') { + params.displayFormat = format; + } else { + console.warn(`Invalid displayFormat "${val}", defaulting to 'list'.`); + params.displayFormat = 'list'; // Default if invalid value + } + break; } } } - // Validate required parameters + // --- Validation for required 'table' --- if (!params.table) { + // Indicate parsing failure specifically because 'table' is missing + // The calling function will handle displaying the error message. return null; } + // --- Set Default displayFormat if not provided --- + if (!params.displayFormat) { + params.displayFormat = 'list'; + } + + // Check consistency if multiple filters were provided + if (Array.isArray(params.filterColumn) || Array.isArray(params.filterValue)) { + if (!Array.isArray(params.filterColumn) || !Array.isArray(params.filterValue) || params.filterColumn.length !== params.filterValue.length) { + console.error("Mismatch between number of filterColumn and filterValue entries. Filtering might be incorrect."); + // Decide how to handle: throw error, ignore filters, return null? + // Returning null might be safest if config is invalid + return null; // Indicate parsing failure due to inconsistent filters + } + } + + return params as SqlParams; } \ No newline at end of file diff --git a/src/codeblocks/processSqlBlock/helpers/renderResultsAsTable.ts b/src/codeblocks/processSqlBlock/helpers/renderResultsAsTable.ts new file mode 100644 index 0000000..d726cac --- /dev/null +++ b/src/codeblocks/processSqlBlock/helpers/renderResultsAsTable.ts @@ -0,0 +1,44 @@ +// helpers.ts (add this new function) + +export function renderResultsAsTable(results: { columns: string[], values: any[][] }, el: HTMLElement): void { + el.empty(); // Clear the container first + + const table = el.createEl("table", { cls: "sql-results-table" }); // Add a class for styling + + // --- Create Table Header --- + const thead = table.createEl("thead", { cls: "sql-results-thead" }); + const headerRow = thead.createEl("tr", { cls: "sql-results-tr sql-results-header-row" }); + results.columns.forEach(colName => { + headerRow.createEl("th", { text: colName, cls: "sql-results-th" }); + }); + + // --- Create Table Body --- + const tbody = table.createEl("tbody", { cls: "sql-results-tbody" }); + results.values.forEach(valueRow => { + const tr = tbody.createEl("tr", { cls: "sql-results-tr" }); + valueRow.forEach(cellValue => { + // Render null/undefined as empty string or specific text + const displayValue = (cellValue === null || cellValue === undefined) ? "" : String(cellValue); + tr.createEl("td", { text: displayValue, cls: "sql-results-td" }); + }); + }); +} + +// Consider renaming the existing renderResults for clarity if needed +// export function renderResultsAsList(...) { ... } +// Or keep renderResults as the function for 'list' format. +// Let's assume the existing renderResults IS the list renderer for now. +export function renderResults(results: { columns: string[], values: any[][] }, el: HTMLElement): void { + // This is your CURRENT rendering logic (presumably rendering as divs, list items, etc.) + // For demonstration, let's make a simple list: + el.empty(); + const list = el.createEl("ul", {cls: "sql-results-list"}); + results.values.forEach(valueRow => { + const item = list.createEl("li"); + const content = results.columns.map((col, index) => `${col}: ${valueRow[index] ?? 'N/A'}`).join(" | "); + item.createEl("pre", { text: content }); // Use pre for simple formatting + }); +} + +// Don't forget to export the new function if helpers.ts is a module +// (The other helpers like parseSqlParams, validateTable, buildSqlQuery should already be exported) \ No newline at end of file diff --git a/src/codeblocks/processSqlBlock/helpers/validateTable.ts b/src/codeblocks/processSqlBlock/helpers/validateTable.ts index 1949f0b..0f7543b 100644 --- a/src/codeblocks/processSqlBlock/helpers/validateTable.ts +++ b/src/codeblocks/processSqlBlock/helpers/validateTable.ts @@ -1,60 +1,95 @@ +import { DBService } from "../../../DBService"; import { SqlParams, ValidationError } from "../types"; -export async function validateTable(db: any, params: SqlParams) { - // Get table info - const tableInfo = db.exec(`PRAGMA table_info("${params.table}")`); - if (!tableInfo || tableInfo.length === 0) { +export async function validateTable(dbService: DBService, params: SqlParams): Promise { + const tableName = params.table; + let availableColumns: string[] = []; + + try { + // Fetch column info based on DB mode + if (dbService.mode === "remote") { + const result = await dbService.getQuery<{ name: string }>( + `PRAGMA table_info("${tableName}")` + ); + if (!result || result.length === 0) { + // Handle case where table might exist but PRAGMA returns empty (less likely) + // Or if getQuery returns null/empty for other reasons + return { + message: `Unable to fetch column info for table "${tableName}". It might not exist or there was an issue.`, + availableColumns: [] + }; + } + availableColumns = result.map((row: { name: string }) => row.name); + } else { + // Local mode + const db = dbService.getDB(); + if (!db) { + // Should ideally not happen if processSqlBlock checks first, but good practice + return { message: "Database not loaded." }; + } + const tableInfo = db.exec(`PRAGMA table_info("${tableName}")`); + // Check if table exists based on PRAGMA result + if (!tableInfo || tableInfo.length === 0 || !tableInfo[0].values || tableInfo[0].values.length === 0) { + return { + message: `Table "${tableName}" does not exist or is empty.` + }; + } + // Column name is typically the second item (index 1) in the PRAGMA result row + availableColumns = tableInfo[0].values.map((row: any) => row[1] as string); + } + + } catch (error) { + console.error(`[validateTable] Failed to fetch columns for table "${tableName}"`, error); + // Return a generic error if PRAGMA fails return { - message: `Table "${params.table}" does not exist.` + message: `Error fetching column info for table "${tableName}".`, + availableColumns: [] }; } - const availableColumns = tableInfo[0].values.map((row: any) => row[1] as string); - // Validate columns if specified if (params.columns) { const requestedColumns = params.columns.split(',').map(c => c.trim()); - for (const col of requestedColumns) { - if (!availableColumns.includes(col)) { - return { - message: `Column "${col}" does not exist in table "${params.table}".`, - availableColumns - }; - } + const invalidColumns = requestedColumns.filter(col => !availableColumns.includes(col)); + if (invalidColumns.length > 0) { + return { + message: `The following columns do not exist in table "${tableName}": ${invalidColumns.join(", ")}.`, + availableColumns + }; } } // Validate dateColumn if specified if (params.dateColumn && !availableColumns.includes(params.dateColumn)) { return { - message: `Date column "${params.dateColumn}" does not exist in table "${params.table}".`, + message: `Date column "${params.dateColumn}" does not exist in table "${tableName}".`, availableColumns }; } // Validate filterColumn if specified if (params.filterColumn) { - const filterColumns = Array.isArray(params.filterColumn) - ? params.filterColumn - : [params.filterColumn]; - - for (const col of filterColumns) { - if (!availableColumns.includes(col)) { - return { - message: `Filter column "${col}" does not exist in table "${params.table}".`, - availableColumns - }; - } + const filterColumns = Array.isArray(params.filterColumn) + ? params.filterColumn + : params.filterColumn.split(',').map(c => c.trim()); // Handle comma-separated string too + + const invalidFilterCols = filterColumns.filter(col => !availableColumns.includes(col)); + if (invalidFilterCols.length > 0) { + return { + message: `The following filter columns do not exist in table "${tableName}": ${invalidFilterCols.join(", ")}.`, + availableColumns + }; } } // Validate orderBy if specified if (params.orderBy && !availableColumns.includes(params.orderBy)) { return { - message: `Order by column "${params.orderBy}" does not exist in table "${params.table}".`, + message: `Order by column "${params.orderBy}" does not exist in table "${tableName}".`, availableColumns }; } + // All checks passed return null; } \ No newline at end of file diff --git a/src/codeblocks/processSqlBlock/index.ts b/src/codeblocks/processSqlBlock/index.ts index 6bd1828..b5634e1 100644 --- a/src/codeblocks/processSqlBlock/index.ts +++ b/src/codeblocks/processSqlBlock/index.ts @@ -1,36 +1,31 @@ import { Notice } from "obsidian"; -import { DBService } from "../../dbService"; +import { DBService } from "../../DBService"; import { parseSqlParams, validateTable, buildSqlQuery, renderResults } from "./helpers"; +import { renderResultsAsTable } from "./helpers/renderResultsAsTable"; export async function processSqlBlock(dbService: DBService, source: string, el: HTMLElement) { - const db = dbService.getDB(); - if (!db) { - new Notice("No DB loaded. Please open the DB first."); - el.createEl("p", { text: "Database not loaded." }); - return; - } - const params = parseSqlParams(source); + if (!params) { el.createEl("p", { text: "Missing required parameter: table" }); el.createEl("p", { text: "Example usage:" }); - el.createEl("pre", { + el.createEl("pre", { text: `table: tasks - columns: title, status - filterColumn: status, priority - filterValue: active, high - dateColumn: dueDate - startDate: 2024-01-01 - endDate: 2024-12-31 - limit: 10 - orderBy: dueDate - orderDirection: asc` +columns: title, status +filterColumn: status, priority +filterValue: active, high +dateColumn: dueDate +startDate: 2024-01-01 +endDate: 2024-12-31 +limit: 10 +orderBy: dueDate +orderDirection: asc` }); return; } try { - const validationError = await validateTable(db, params); + const validationError = await validateTable(dbService, params); if (validationError) { el.createEl("p", { text: validationError.message }); if (validationError.availableColumns) { @@ -40,41 +35,64 @@ export async function processSqlBlock(dbService: DBService, source: string, el: } const { query, queryParams } = buildSqlQuery(params); - const result = db.exec(query, queryParams); - if (!result || result.length === 0 || result[0].values.length === 0) { - el.createEl("p", { - text: `No rows found in table "${params.table}"`, - }); - - // Show the applied filters to help debug - if (params.keyColumn && params.value) { - el.createEl("p", { text: `Filter: ${params.keyColumn} = ${params.value}` }); + let resultsToRender: { columns: string[], values: any[][] } | null = null; + + // Handle remote vs local DB + if (dbService.mode === "remote") { + const rows = await dbService.getQuery(query, queryParams); + if (!rows || rows.length === 0) { + // Keep existing no-rows message logic + } else { + // Convert remote result (array of objects) to the format renderResults expects + const columns = Object.keys(rows[0]); + const values = rows.map(obj => columns.map(col => obj[col])); + resultsToRender = { columns, values }; } - if (params.dateColumn && (params.startDate || params.endDate)) { - el.createEl("p", { - text: `Date range: ${params.dateColumn} from ${params.startDate || 'start'} to ${params.endDate || 'end'}` - }); + } else { + // Local mode logic + const db = dbService.getDB(); + if (!db) { + new Notice("No DB loaded. Please open the DB first."); + el.createEl("p", { text: "Database not loaded." }); + return; // Exit renderSqlBlock early } - if (params.filterColumn && params.filterValue) { - el.createEl("p", { text: `Additional filter: ${params.filterColumn} = ${params.filterValue}` }); + const result = db.exec(query, queryParams); + if (!result || result.length === 0 || result[0].values.length === 0) { + // Keep existing no-rows message logic + } else { + resultsToRender = result[0]; } - - el.createEl("p", { - text: "Try adjusting your filters or date range.", - }); + } + + //? Check if we have results to render + if (!resultsToRender) { + el.createEl("p", { text: `No rows found matching the criteria.` }); + // Optionally display the specific criteria used from 'params' object + let criteriaMsg = `Table: "${params.table}"`; + if(params.startDate || params.endDate) criteriaMsg += ` | Dates: ${params.startDate} to ${params.endDate}`; + if(params.filterColumn) criteriaMsg += ` | Filter: ${params.filterColumn}=${params.filterValue}`; + el.createEl("p", { text: `Criteria: ${criteriaMsg}`, cls: 'sql-block-criteria-summary'}); // Add a class for styling return; } - renderResults(result[0], el); - - } catch (error) { - el.createEl("p", { text: "An error occurred while processing the SQL block." }); - el.createEl("p", { text: String(error) }); - // Optionally show the query that caused the error in development - if (process.env.NODE_ENV === 'development') { - const { query, queryParams } = buildSqlQuery(params); - el.createEl("pre", { text: `Query: ${query}\nParams: ${JSON.stringify(queryParams)}` }); + // --- Choose Renderer based on displayFormat --- + el.empty(); // Clear loading message or previous content + if (params.displayFormat === 'table') { + renderResultsAsTable(resultsToRender, el); + } else { // Default to 'list' + renderResults(resultsToRender, el); // Assuming renderResults is the list renderer } + + } catch (error) { + el.empty(); // Clear before showing error + el.createEl("p", { text: "An error occurred processing the SQL block." }); + el.createEl("p", { text: String(error) }); + console.error("SQL Block Error:", error); + // Optionally show query info on error + try { + const { query, queryParams } = buildSqlQuery(params); // Try building query again for error display + el.createEl("pre", { text: `Query: ${query}\nParams: ${JSON.stringify(queryParams)}` }); + } catch (buildError) { /* Ignore if query build fails */ } } } \ No newline at end of file diff --git a/src/codeblocks/processSqlBlock/types.ts b/src/codeblocks/processSqlBlock/types.ts index 28c75b4..cc002f2 100644 --- a/src/codeblocks/processSqlBlock/types.ts +++ b/src/codeblocks/processSqlBlock/types.ts @@ -11,6 +11,7 @@ export interface SqlParams { orderBy?: string; orderDirection?: string; limit?: number; + displayFormat?: string; } export interface ValidationError { diff --git a/src/codeblocks/processSqlChartBlock/helpers/buildSqlQuery.ts b/src/codeblocks/processSqlChartBlock/helpers/buildSqlQuery.ts index 884c2b8..c67c114 100644 --- a/src/codeblocks/processSqlChartBlock/helpers/buildSqlQuery.ts +++ b/src/codeblocks/processSqlChartBlock/helpers/buildSqlQuery.ts @@ -1,56 +1,118 @@ -import { ChartConfig } from "./parseChartParams"; +import { ChartConfig, PieChartConfig } from "./parseChartParams"; -export function buildSqlQuery(config: ChartConfig) { - let query, params = []; - +function isPieConfig(config: ChartConfig): config is PieChartConfig { + return config.chartType === "pie"; +} + +// Helper function to build the date part of the WHERE clause +function buildDateWhereClause( + dateColumn: string | undefined, + startDate: string | undefined, + endDate: string | undefined +): { clause: string; params: string[] } | null { + + if (!dateColumn || !startDate || !endDate) { + return null; // No date filtering needed + } + + try { + // --- Calculate the day AFTER the end date --- + const endDateObj = new Date(endDate + 'T00:00:00Z'); // Parse as UTC midnight + if (isNaN(endDateObj.getTime())) { + throw new Error(`Invalid end date format: ${endDate}`); + } + endDateObj.setUTCDate(endDateObj.getUTCDate() + 1); // Add 1 day UTC + const nextDayString = endDateObj.toISOString().split('T')[0]; // Format 'YYYY-MM-DD' + + // --- Build the SQL WHERE Clause --- + // Quote the column name for safety + const clause = `"${dateColumn}" >= ? AND "${dateColumn}" < ?`; + const params = [startDate, nextDayString]; // Use string dates + + return { clause, params }; + + } catch (dateError) { + console.error("Error processing date range for SQL query:", dateError); + return null; + } +} + + +export function buildSqlQuery(config: ChartConfig): { query: string; params: any[] } { + let selectClause: string; + const fromClause = `FROM "${config.table}"`; + let whereClauses: string[] = []; + let groupByClause: string = ""; + let orderByClause: string = ""; + let queryParams: any[] = []; // Use any[] for params as they can be strings, numbers etc. + + // --- 1. Build Date WHERE Clause (Common Logic) --- + const dateFilter = buildDateWhereClause(config.dateColumn, config.startDate, config.endDate); + if (dateFilter) { + whereClauses.push(dateFilter.clause); + queryParams.push(...dateFilter.params); + } + + + // --- 2. Build Chart-Type Specific Clauses --- switch (config.chartType) { case 'pie': - query = ` - SELECT - ${config.categoryColumn}, - SUM(${config.valueColumn}) as value - FROM "${config.table}" - `; - - if (config.startDate && config.endDate) { - query += ` WHERE ${config.dateColumn} BETWEEN ? AND ?`; - params.push(config.startDate, config.endDate); - } - - query += ` GROUP BY ${config.categoryColumn}`; - query += ` ORDER BY value DESC`; + // Assert config is PieChartConfig for type safety (optional but good) + const pieConfig = config as PieChartConfig; + + // Handle special duration calculation + const isTimeDuration = + pieConfig.table.toLowerCase() === "time" && + pieConfig.valueColumn === "duration"; + + const valueExpr = isTimeDuration + // Ensure valueColumn is quoted if it might contain special chars + ? `SUM(strftime('%s', '1970-01-01T' || "${pieConfig.valueColumn}") - strftime('%s', '1970-01-01T00:00:00'))` + : `SUM("${pieConfig.valueColumn}")`; // Quote value column + + // Quote category column + selectClause = `SELECT "${pieConfig.categoryColumn}", ${valueExpr} as value`; + groupByClause = `GROUP BY "${pieConfig.categoryColumn}"`; + orderByClause = `ORDER BY value DESC`; break; case 'line': case 'bar': + // Quote columns + const quotedYColumns = config.yColumns.map(col => `"${col}"`).join(', '); + const quotedXColumn = `"${config.xColumn}"`; + if (config.categoryColumn) { - query = ` - SELECT - ${config.xColumn}, - ${config.categoryColumn}, - ${config.yColumns.join(', ')} - FROM "${config.table}" - `; + const quotedCategoryColumn = `"${config.categoryColumn}"`; + selectClause = `SELECT ${quotedXColumn}, ${quotedCategoryColumn}, ${quotedYColumns}`; + // Line/bar charts often don't need explicit GROUP BY if not aggregating in SQL + // ORDER BY xColumn first, then category for consistent multi-series ordering + orderByClause = `ORDER BY ${quotedXColumn}, ${quotedCategoryColumn}`; } else { - query = ` - SELECT - ${config.xColumn}, - ${config.yColumns.join(', ')} - FROM "${config.table}" - `; - } - - if (config.startDate && config.endDate) { - query += ` WHERE ${config.dateColumn} BETWEEN ? AND ?`; - params.push(config.startDate, config.endDate); - } - - query += ` ORDER BY ${config.xColumn}`; - if (config.categoryColumn) { - query += `, ${config.categoryColumn}`; + selectClause = `SELECT ${quotedXColumn}, ${quotedYColumns}`; + orderByClause = `ORDER BY ${quotedXColumn}`; } break; + + default: + // Handle unknown chart type if necessary + throw new Error(`Unsupported chart type`); } - return { query, params }; + // --- 3. Assemble the Final Query --- + let query = selectClause + "\n" + fromClause; // Add newline for readability + + if (whereClauses.length > 0) { + query += "\nWHERE " + whereClauses.join(" AND "); + } + + if (groupByClause) { + query += "\n" + groupByClause; + } + + if (orderByClause) { + query += "\n" + orderByClause; + } + + return { query, params: queryParams }; } \ No newline at end of file diff --git a/src/codeblocks/processSqlChartBlock/helpers/processChartData.ts b/src/codeblocks/processSqlChartBlock/helpers/processChartData.ts index 47eeae1..3473b83 100644 --- a/src/codeblocks/processSqlChartBlock/helpers/processChartData.ts +++ b/src/codeblocks/processSqlChartBlock/helpers/processChartData.ts @@ -9,31 +9,47 @@ function isPieConfig(config: ChartConfig): config is PieChartConfig { return config.chartType === 'pie'; } -export function processChartData(rowObj: any, config: ChartConfig) { - if (isPieConfig(config)) { - const labels = rowObj.values.map((row: any[]) => row[0]); - const datasets = [{ - data: rowObj.values.map((row: any[]) => row[1]), - label: config.valueColumn - }]; - return { labels, datasets }; - } +function formatSeconds(totalSeconds: number): string { + const hrs = Math.floor(totalSeconds / 3600); + const mins = Math.floor((totalSeconds % 3600) / 60); + const secs = Math.floor(totalSeconds % 60); - let labels: any[]; - let datasets: any[]; - - if (config.categoryColumn) { - const { labels: groupLabels, datasets: groupDatasets } = processGroupedData(rowObj, config); - labels = groupLabels; - datasets = groupDatasets; - } else { - labels = rowObj.values.map((row: any[]) => row[0]); - datasets = processUngroupedData(rowObj, config); - } - - return { labels, datasets }; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`; } +export function processChartData(rowObj: any, config: ChartConfig) { + if (isPieConfig(config)) { + const isDuration = config.table === 'Time' && config.valueColumn === 'duration'; + + const labels = rowObj.values.map((row: any[]) => { + const label = row[0]; + const value = row[1]; + return isDuration ? `${label} | ${formatSeconds(value)}` : label; + }); + + const datasets = [{ + data: rowObj.values.map((row: any[]) => row[1]), + label: config.valueColumn + }]; + + return { labels, datasets }; + } + + let labels: any[]; + let datasets: any[]; + + if (config.categoryColumn) { + const { labels: groupLabels, datasets: groupDatasets } = processGroupedData(rowObj, config); + labels = groupLabels; + datasets = groupDatasets; + } else { + labels = rowObj.values.map((row: any[]) => row[0]); + datasets = processUngroupedData(rowObj, config); + } + + return { labels, datasets }; +} function processGroupedData(rowObj: any, config: TimeSeriesChartConfig) { const groupedData = new Map(); diff --git a/src/codeblocks/processSqlChartBlock/helpers/validateColumns.ts b/src/codeblocks/processSqlChartBlock/helpers/validateColumns.ts index b0f4d1e..183a401 100644 --- a/src/codeblocks/processSqlChartBlock/helpers/validateColumns.ts +++ b/src/codeblocks/processSqlChartBlock/helpers/validateColumns.ts @@ -1,33 +1,50 @@ import { ChartConfig } from "./parseChartParams"; +import { DBService } from "../../../DBService"; -export async function validateColumns(db: any, config: ChartConfig) { - const tableInfo = db.exec(`PRAGMA table_info("${config.table}");`); - const availableColumns = tableInfo[0].values.map((row: any[]) => row[1]); +export async function validateColumns(dbService: DBService, config: ChartConfig) { + const tableName = config.table; + + let columns: string[] = []; - let requestedColumns = []; - - if (config.chartType === 'pie') { - requestedColumns = [config.categoryColumn, config.valueColumn]; - if (config.dateColumn && (config.startDate || config.endDate)) { - requestedColumns.push(config.dateColumn); - } - } else { - requestedColumns = [config.xColumn, ...config.yColumns]; - if (config.categoryColumn) { - requestedColumns.push(config.categoryColumn); - } - if (config.dateColumn && (config.startDate || config.endDate)) { - requestedColumns.push(config.dateColumn); - } - } - - const invalidColumns = requestedColumns.filter(col => !availableColumns.includes(col)); - if (invalidColumns.length > 0) { - return { - message: `The following columns do not exist: ${invalidColumns.join(", ")}`, - availableColumns - }; - } + try { + const result = await dbService.getQuery<{ name: string }>( + `PRAGMA table_info("${tableName}")` + ); - return null; -} \ No newline at end of file + columns = result.map((row: { name: string }) => row.name); + } catch (error) { + console.error(`[validateColumns] Failed to fetch columns for table "${tableName}"`, error); + return { + message: `Unable to fetch column info for table "${tableName}".`, + availableColumns: [] + }; + } + + let requestedColumns: string[] = []; + + if (config.chartType === "pie") { + requestedColumns = [config.categoryColumn, config.valueColumn]; + if (config.dateColumn && (config.startDate || config.endDate)) { + requestedColumns.push(config.dateColumn); + } + } else { + requestedColumns = [config.xColumn, ...config.yColumns]; + if (config.categoryColumn) { + requestedColumns.push(config.categoryColumn); + } + if (config.dateColumn && (config.startDate || config.endDate)) { + requestedColumns.push(config.dateColumn); + } + } + + const invalidColumns = requestedColumns.filter(col => !columns.includes(col)); + + if (invalidColumns.length > 0) { + return { + message: `The following columns do not exist: ${invalidColumns.join(", ")}`, + availableColumns: columns + }; + } + + return null; +} diff --git a/src/codeblocks/processSqlChartBlock/index.ts b/src/codeblocks/processSqlChartBlock/index.ts index f5487ee..1ae171c 100644 --- a/src/codeblocks/processSqlChartBlock/index.ts +++ b/src/codeblocks/processSqlChartBlock/index.ts @@ -1,59 +1,88 @@ -import { Notice } from "obsidian"; -import { DBService } from "../../dbService"; +import { DBService } from "../../DBService"; import { parseChartParams, validateColumns, buildSqlQuery, processChartData } from "./helpers"; import { ChartType, createChart } from "./charts"; +import { Notice } from "obsidian"; export async function processSqlChartBlock(dbService: DBService, source: string, el: HTMLElement) { - const db = dbService.getDB(); - if (!db) { - new Notice("No DB loaded. Please open the DB first."); - el.createEl("p", { text: "Database not loaded." }); - return; - } + await renderChartBlock(); - const config = parseChartParams(source); - if (!config) { - // @ts-ignore - const message = config?.chartType === 'pie' - ? "Required parameters for pie chart are: table, categoryColumn, valueColumn" - : "Required parameters are: table, xColumn, yColumns"; - el.createEl("p", { text: message }); - return; - } + async function renderChartBlock() { + const config = parseChartParams(source); - try { - const validationError = await validateColumns(db, config); - if (validationError) { - el.createEl("p", { text: validationError.message }); - if (validationError.availableColumns) { - el.createEl("p", { text: `Available columns are: ${validationError.availableColumns.join(", ")}` }); + if (!config) { + // @ts-ignore + const message = config?.chartType === 'pie' + ? "Required parameters for pie chart are: table, categoryColumn, valueColumn" + : "Required parameters are: table, xColumn, yColumns"; + el.createEl("p", { text: message }); + return; + } + + try { + const validationError = await validateColumns(dbService, config); + if (validationError) { + el.createEl("p", { text: validationError.message }); + if (validationError.availableColumns) { + el.createEl("p", { text: `Available columns are: ${validationError.availableColumns.join(", ")}` }); + } + return; } - return; + + const { query, params } = buildSqlQuery(config); + + let rows: any[]; + let labels, datasets; + + if (dbService.mode === "remote") { + // Use remote query + rows = await dbService.getQuery(query, params); + if (!rows || rows.length === 0) { + el.createEl("p", { text: "No data found for the specified parameters." }); + return; + } + + const columns = Object.keys(rows[0]); + const values = rows.map(obj => columns.map(col => obj[col])); + const execStyleResult = { columns, values }; + + ({ labels, datasets } = processChartData(execStyleResult, config)); + } else { + // Local mode: use exec + const db = dbService.getDB(); + if (!db) { + new Notice("No DB loaded. Please open the DB first."); + el.createEl("p", { text: "Database not loaded." }); + return; + } + const result = db.exec(query, params as any); + if (!result || result.length === 0 || result[0].values.length === 0) { + el.createEl("p", { text: "No data found for the specified parameters." }); + return; + } + + const columns = result[0].columns; + const values = result[0].values; + rows = values.map(rowArr => + Object.fromEntries(rowArr.map((val, idx) => [columns[idx], val])) + ); + + ({ labels, datasets } = processChartData(rows, config)); + } + + const chartData = createChart( + config.chartType as ChartType, + labels, + datasets, + config.chartOptions + ); + + const wrapper = el.createDiv({ cls: 'obsidian-sql-chart' }); + // @ts-ignore + window.renderChart(chartData, wrapper); + } catch (error) { + el.createEl("p", { text: "An error occurred while processing the chart." }); + el.createEl("p", { text: String(error) }); + console.error(error); } - - const { query, params } = buildSqlQuery(config); - const result = db.exec(query, params); - - if (!result || result.length === 0 || result[0].values.length === 0) { - el.createEl("p", { text: "No data found for the specified parameters." }); - return; - } - - const { labels, datasets } = processChartData(result[0], config); - - const chartData = createChart( - config.chartType as ChartType, - labels, - datasets, - config.chartOptions - ); - - // @ts-ignore - window.renderChart(chartData, el); - - } catch (error) { - el.createEl("p", { text: "An error occurred while processing the chart." }); - el.createEl("p", { text: String(error) }); - console.error(error); } } \ No newline at end of file diff --git a/src/commands/convertEntriesInNotes.ts b/src/commands/convertEntriesInNotes.ts index c991ca8..80e8776 100644 --- a/src/commands/convertEntriesInNotes.ts +++ b/src/commands/convertEntriesInNotes.ts @@ -1,5 +1,5 @@ import { App, Notice, TFile, TFolder, normalizePath, FileSystemAdapter } from "obsidian"; -import { DBService } from "../dbService"; +import { DBService } from "../DBService"; import { ColumnPickerModal } from "../components/ColumnPickerModal"; /** diff --git a/src/commands/index.ts b/src/commands/index.ts index a003c08..3c8c190 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,4 +1,6 @@ import { inspectTableStructure } from "./inspectTableStructure"; import { convertEntriesInNotes } from "./convertEntriesInNotes"; +import { syncDBToJournals } from "./syncDBToJournals"; +import { syncJournalsToDB } from "./syncJournalsToDB"; -export { inspectTableStructure, convertEntriesInNotes }; +export { inspectTableStructure, convertEntriesInNotes, syncDBToJournals, syncJournalsToDB }; diff --git a/src/commands/inspectTableStructure.ts b/src/commands/inspectTableStructure.ts index 4acf405..1b6ead3 100644 --- a/src/commands/inspectTableStructure.ts +++ b/src/commands/inspectTableStructure.ts @@ -1,5 +1,5 @@ import { Notice, Editor } from "obsidian"; -import { DBService } from "../dbService"; +import { DBService } from "../DBService"; import { TablePickerModal } from "../components/TablePickerModal"; /** @@ -10,40 +10,84 @@ export async function inspectTableStructure( 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`); + // Check if we're in local or remote mode + if (dbService.mode === "local") { + const db = dbService.getDB(); + if (!db) { + new Notice("No DB loaded. Please open the DB first."); 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; + // 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); - let output = `\n**Columns in "${selectedTable}"**\n`; - output += columns.join(", "); - output += "\n\n**First row**\n"; - output += rows[0].join(", "); - editor.replaceSelection(output + "\n"); - }); + // 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; + } - modal.open(); + // 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(); + } else { + // Remote mode - Use the DBService methods which handle API calls + try { + // 1) get all table names using the getQuery method + const tables = await dbService.getQuery<{name: string}>("SELECT name FROM sqlite_master WHERE type='table';"); + if (!tables || tables.length === 0) { + new Notice("No tables found in the DB."); + return; + } + const tableNames = tables.map(table => table.name); + + // 2) show a modal to pick one + const modal = new TablePickerModal(app, tableNames, async (selectedTable) => { + try { + // 3) query first 10 rows from that table + const rows = await dbService.getQuery(`SELECT * FROM "${selectedTable}" LIMIT 10;`); + if (!rows || rows.length === 0) { + editor.replaceSelection(`\nNo rows found in table ${selectedTable}.\n`); + return; + } + + // For remote results, we get objects directly + const columns = Object.keys(rows[0]); + + let output = `\n**Columns in "${selectedTable}"**\n`; + output += columns.join(", "); + output += "\n\n**First row**\n"; + output += columns.map(col => rows[0][col]).join(", "); + editor.replaceSelection(output + "\n"); + } catch (error) { + console.error("Error querying table rows:", error); + new Notice(`Error querying table: ${(error as Error).message}`); + editor.replaceSelection(`\nError querying table ${selectedTable}.\n`); + } + }); + + modal.open(); + } catch (error) { + console.error("Error fetching tables:", error); + new Notice(`Error fetching tables: ${(error as Error).message}`); + } + } } diff --git a/src/commands/syncDBToJournals.ts b/src/commands/syncDBToJournals.ts new file mode 100644 index 0000000..3ac5532 --- /dev/null +++ b/src/commands/syncDBToJournals.ts @@ -0,0 +1,183 @@ +import { App, Notice, TFile, TFolder, normalizePath, stringifyYaml, MetadataCache, Vault } from "obsidian"; +import { DBService } from "../DBService"; // Adjust path + +/** + * Syncs database rows from a table to notes in a specified folder. + * - Queries the DB table. + * - For each row, tries to find a matching note using 'db_id' in frontmatter. + * - Creates new notes for DB rows not found in the folder. + * - Updates existing notes if the DB row is newer (based on updated_at). + */ +export async function syncDBToJournals(dbService: DBService, tableName: string, folderPath: string, app: App) { + const { vault, metadataCache } = app; + const db = dbService.getDB(); // Assuming local for now + if (!db && dbService.mode === "local") { + new Notice("Local DB not loaded."); + return; + } + + // --- Ensure Target Folder Exists --- + const targetFolder = await ensureFolderExists(folderPath, app.vault); + if (!targetFolder) { + new Notice(`Failed to find or create folder "${folderPath}".`); + return; + } + + // --- Get All Notes in Target Folder with db_id --- + const notesInFolder = targetFolder.children.filter((file): file is TFile => file instanceof TFile && file.extension === 'md'); + const notesMap = new Map(); // Map db_id -> note info + + for (const note of notesInFolder) { + const fm = metadataCache.getFileCache(note)?.frontmatter; + if (fm && fm.uuid) { + notesMap.set(fm.uuid, { + file: note, + fmUpdatedAt: fm.updatedAt || null, + mtime: note.stat.mtime + }); + } + } + + // --- Query DB --- + // Adapt column names! Fetch all relevant columns including uuid and updated_at + const query = `SELECT uuid, date, place, text, createdAt, updatedAt FROM "${tableName}"`; + const dbRows = await dbService.getQuery(query); // Adapt for local/remote + + if (!dbRows || dbRows.length === 0) { + new Notice(`No rows found in DB table "${tableName}".`); + return; + } + + let createdCount = 0; + let updatedCount = 0; + let skippedCount = 0; + let errorCount = 0; + + for (const row of dbRows) { + try { + const uuid = row.uuid; // Adapt column name + const dbUpdatedAt = row.updatedAt; // Adapt column name + + if (!uuid || !dbUpdatedAt) { + console.warn("Skipping DB row - missing 'uuid' or 'updatedAt'.", row); + errorCount++; + continue; + } + + const existingNoteInfo = notesMap.get(uuid); + + // --- Decide Action --- + if (existingNoteInfo) { + // Note exists - check if update needed + const noteFile = existingNoteInfo.file; + const noteMtime = existingNoteInfo.mtime; + const dbTimestamp = new Date(dbUpdatedAt).getTime(); + + // Compare DB timestamp with note modification time. + // If DB is significantly newer, update the note. + // Add a small buffer (e.g., 1-2 seconds) to avoid sync loops if timestamps are very close. + const buffer = 2000; // 2 seconds + if (dbTimestamp > (noteMtime + buffer)) { + console.log(`DB row ${uuid} is newer than note "${noteFile.path}". Updating note.`); + await updateOrCreateNoteFromDbRow(row, tableName, targetFolder.path, app, noteFile); + updatedCount++; + } else { + // console.log(`Note "${noteFile.path}" is up-to-date with DB row ${dbId}. Skipping.`); + skippedCount++; + } + // Remove from map so remaining notes can be potentially deleted later (optional) + notesMap.delete(uuid); + } else { + // Note does not exist - create it + console.log(`DB row ${uuid} not found in notes. Creating new note.`); + await updateOrCreateNoteFromDbRow(row, tableName, targetFolder.path, app); + createdCount++; + } + } catch (error) { + console.error(`Error processing DB row with uuid ${row.uuid}:`, error); + new Notice(`Error processing DB row ${row.uuid}. Check console.`); + errorCount++; + } + } + + new Notice(`Sync DB to Notes complete. Created: ${createdCount}, Updated: ${updatedCount}, Skipped: ${skippedCount}, Errors: ${errorCount}.`); +} + + +// Helper Function to Create/Update a Note from a DB Row +async function updateOrCreateNoteFromDbRow( + dbRow: any, + tableName: string, + folderPath: string, + app: App, + existingFile?: TFile | null +) { + const { vault } = app; + + // --- Map DB columns to Frontmatter keys --- (Adapt these!) + const frontmatterData: Record = { + uuid: dbRow.uuid, + db_table: tableName, + date: dbRow.date, + place: dbRow.place, + createdAt: dbRow.createdAt, + updatedAt: dbRow.updatedAt, + }; + // Remove null/undefined values from frontmatter if desired + Object.keys(frontmatterData).forEach(key => + (frontmatterData[key] === null || frontmatterData[key] === undefined) && delete frontmatterData[key] + ); + + const noteBody = dbRow.text || ''; // Adapt column name + + // --- Construct File Content --- + const frontmatterString = stringifyYaml(frontmatterData); + const fileContent = `---\n${frontmatterString}---\n\n${noteBody}`; + + if (existingFile) { + // --- Update Existing File --- + const currentContent = await vault.cachedRead(existingFile); + if (fileContent !== currentContent) { // Only modify if content changed + await vault.modify(existingFile, fileContent); + } + } else { + // --- Create New File --- + const datePart = dbRow.date ? new Date(dbRow.date).toISOString().split('T')[0] : 'UnknownDate'; + const safeBaseName = sanitizeFilename(`Journal ${datePart}`); + let notePath = normalizePath(`${folderPath}/${safeBaseName}.md`); + + // Handle filename collisions + let suffix = 1; + while (vault.getAbstractFileByPath(notePath)) { + suffix++; + notePath = normalizePath(`${folderPath}/${safeBaseName}_${suffix}.md`); + } + await vault.create(notePath, fileContent); + } +} + + +// Helper to ensure folder exists +async function ensureFolderExists(folderPath: string, vault: Vault): Promise { + const normPath = normalizePath(folderPath); + let folder = vault.getAbstractFileByPath(normPath); + + if (folder && !(folder instanceof TFolder)) { + new Notice(`"${normPath}" exists but is not a folder.`); + return null; + } + if (!folder) { + try { + folder = await vault.createFolder(normPath); + } catch (err) { + console.error(`Error creating folder "${normPath}":`, err); + return null; + } + } + return folder as TFolder; +} + +// Re-use sanitizeFilename function from your original code +function sanitizeFilename(name: string): string { + return name.replace(/[\\\/:*?"<>|]/g, "_").trim(); +} \ No newline at end of file diff --git a/src/commands/syncJournalsToDB.ts b/src/commands/syncJournalsToDB.ts new file mode 100644 index 0000000..dcd55d9 --- /dev/null +++ b/src/commands/syncJournalsToDB.ts @@ -0,0 +1,200 @@ +import { App, Notice, TFile, TFolder, normalizePath, stringifyYaml } from "obsidian"; +import { DBService } from "../DBService"; // Adjust path + +// Define an interface for your Journal structure (optional but helpful) +interface JournalData { + uuid?: string; + date: string; // ISO Format + place?: string; + text: string; + createdAt?: string; // ISO Format + updatedAt?: string; // ISO Format +} + +/** + * Syncs notes from a specified folder to a database table. + * - Finds notes in the folder. + * - Parses frontmatter and content. + * - Uses 'db_id' frontmatter key to link to DB rows. + * - Performs UPSERT (UPDATE or INSERT) based on db_id and timestamps. + * - Updates note frontmatter with db_id and timestamps after INSERT. + */ +export async function syncJournalsToDB(dbService: DBService, folderPath: string, tableName: string, app: App) { + const { vault, metadataCache } = app; + const db = dbService.getDB(); // Assuming local for now, adapt for remote + if (!db && dbService.mode === "local") { + new Notice("Local DB not loaded."); + return; + } + + const folder = vault.getAbstractFileByPath(normalizePath(folderPath)); + if (!folder || !(folder instanceof TFolder)) { + new Notice(`Folder "${folderPath}" not found.`); + return; + } + + const notes = folder.children.filter((file): file is TFile => file instanceof TFile && file.extension === 'md'); + let updatedCount = 0; + let insertedCount = 0; + let errorCount = 0; + + for (const note of notes) { + try { + const fileContent = await vault.cachedRead(note); + const frontmatter = metadataCache.getFileCache(note)?.frontmatter || {}; + const noteMtime = note.stat.mtime; // Note's last modified time + + // --- Extract data from note --- + const journalData: Partial = { + uuid: frontmatter.uuid, + date: frontmatter.date, + place: frontmatter.place, + // Extract content AFTER frontmatter + text: extractNoteContent(fileContent), + createdAt: frontmatter.createdAt, + updatedAt: frontmatter.updatedAt, + }; + + // Basic validation + if (!journalData.date) { + // Try to extract date from filename if not in frontmatter + const dateFromFilename = extractDateFromFilename(note.name); + if (dateFromFilename) { + journalData.date = dateFromFilename; + console.log(`Extracted date from filename for "${note.path}": ${dateFromFilename}`); + } else { + console.warn(`Skipping note "${note.path}" - missing 'date' in frontmatter and filename.`); + continue; + } + } + + // --- Sync Logic --- + let syncAction: 'skip' | 'update' | 'insert' = 'skip'; + let dbUpdatedAt: string | null = null; + + if (journalData.uuid) { + // Note has an ID - check DB and timestamps + const checkQuery = `SELECT updatedAt FROM "${tableName}" WHERE uuid = ?`; // Use your actual ID column name + const existingRow = await dbService.getQuery(checkQuery, [journalData.uuid]); // Adapt for local/remote + + if (existingRow && existingRow.length > 0) { + dbUpdatedAt = existingRow[0].updatedAt; + // Compare note mtime with DB updated_at timestamp + // If note is newer than DB record, update DB. + // (More robust: compare note mtime > frontmatter.updated_at) + if (noteMtime > (new Date(dbUpdatedAt!)).getTime()) { + syncAction = 'update'; + console.log(`Note "${note.path}" is newer than DB record. Scheduling UPDATE.`); + } else { + console.log(`Note "${note.path}" is up-to-date with DB record. Skipping.`); + } + } else { + // Note has ID, but not found in DB (maybe deleted in DB?) -> Re-insert + console.warn(`Note "${note.path}" has uuid ${journalData.uuid} but not found in DB. Scheduling INSERT.`); + syncAction = 'insert'; + journalData.uuid = dbService.generateUuid(); + } + } else { + // Note has no ID - it's new -> Insert + syncAction = 'insert'; + journalData.uuid = dbService.generateUuid(); // Generate new UUID for insertion + console.log(`Note "${note.path}" has no uuid. Scheduling INSERT with new ID ${journalData.uuid}.`); + } + + // --- Perform DB Action --- + if (syncAction === 'insert') { + const now = new Date().toISOString(); + journalData.createdAt = now; + journalData.updatedAt = now; + + // Adapt column names and parameter order! + const insertSql = `INSERT INTO "${tableName}" (uuid, date, place, text, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`; + const params = [ + journalData.uuid, + journalData.date, + journalData.place || null, // Handle optional fields + journalData.text, + journalData.createdAt, + journalData.updatedAt, + ]; + await dbService.runQuery(insertSql, params); // Adapt for local/remote + + // --- IMPORTANT: Update Note Frontmatter with new ID and timestamps --- + await updateNoteFrontmatter(note, { + uuid: journalData.uuid, + createdAt: journalData.createdAt, + updatedAt: journalData.updatedAt, + // Keep other existing frontmatter + ...frontmatter, + date: journalData.date, // Ensure date is saved back if defaulted + place: journalData.place, // Ensure location is saved back + }, fileContent, app); + + insertedCount++; + + } else if (syncAction === 'update') { + const now = new Date().toISOString(); + journalData.updatedAt = now; // Update timestamp + + // Adapt column names and parameter order! + const updateSql = `UPDATE "${tableName}" SET date = ?, place = ?, text = ?, updatedAt = ? WHERE uuid = ?`; + const params = [ + journalData.date, + journalData.place || null, + journalData.text, + journalData.updatedAt, + journalData.uuid, // WHERE clause parameter + ]; + await dbService.runQuery(updateSql, params); // Adapt for local/remote + + // --- IMPORTANT: Update Note Frontmatter with new updated_at --- + await updateNoteFrontmatter(note, { + updatedAt: journalData.updatedAt, + // Keep other existing frontmatter + ...frontmatter, + date: journalData.date, + place: journalData.place, + }, fileContent, app); + + updatedCount++; + } + + } catch (error) { + console.error(`Error processing note "${note.path}":`, error); + new Notice(`Error syncing note "${note.name}". Check console.`); + errorCount++; + } + } + + new Notice(`Sync Notes to DB complete. Inserted: ${insertedCount}, Updated: ${updatedCount}, Errors: ${errorCount}.`); +} + + +// Helper to extract content after frontmatter +function extractNoteContent(fileContent: string): string { + const match = fileContent.match(/^---\s*([\s\S]*?)\s*---(\s*[\s\S]*)$/); + return match ? (match[2] || '').trim() : fileContent.trim(); +} + +// Helper to update note frontmatter non-destructively +async function updateNoteFrontmatter(file: TFile, newData: Record, currentContent: string, app: App) { + const contentOnly = extractNoteContent(currentContent); + const newFrontmatter = stringifyYaml(newData); // Use Obsidian's YAML stringifier + const newFileContent = `---\n${newFrontmatter}---\n\n${contentOnly}`; + + // Avoid triggering another update immediately by comparing content + if (newFileContent !== currentContent) { + await app.vault.modify(file, newFileContent); + console.log(`Updated frontmatter for "${file.path}"`); + } +} + +// Helper to extract date from filename (format "Journal YYYY-MM-DD") +function extractDateFromFilename(filename: string): string | null { + // Match "Journal YYYY-MM-DD" pattern + const match = filename.match(/Journal\s+(\d{4}-\d{2}-\d{2})/i); + if (match && match[1]) { + return match[1]; + } + return null; +} \ No newline at end of file diff --git a/src/components/AddTextEntryModal.ts b/src/components/AddTextEntryModal.ts new file mode 100644 index 0000000..baf21b6 --- /dev/null +++ b/src/components/AddTextEntryModal.ts @@ -0,0 +1,146 @@ +import { App, Modal, Notice, TextAreaComponent, Setting } from 'obsidian'; +import { DBService } from '../DBService'; // Adjust path +import { AddTextButtonConfig } from '../webcomponents/AddTextButton/AddTextButton'; // Import config type + +export class AddTextEntryModal extends Modal { + private dbService: DBService; + private config: AddTextButtonConfig; + private inputText: string = ''; + private textArea: TextAreaComponent | null = null; + + constructor(app: App, dbService: DBService, config: AddTextButtonConfig) { + super(app); + this.dbService = dbService; + this.config = config; // Store the received configuration + this.modalEl.addClass('add-text-entry-modal'); + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('add-text-modal-content'); + + // Use button text or a generic title + contentEl.createEl('h2', { text: this.config.buttonText || 'Add Text Entry' }); + + // Display the target table/column for clarity (optional) + contentEl.createEl('p', { + text: `Adding to table "${this.config.dbTable}", column "${this.config.textColumn}".`, + cls: 'add-text-modal-info' + }); + // Display extra data being added (optional) + if (Object.keys(this.config.extraData).length > 0) { + const extraDataStr = Object.entries(this.config.extraData) + .map(([key, value]) => `${key}: ${value}`) + .join(', '); + contentEl.createEl('p', { + text: `With fixed data: ${extraDataStr}`, + cls: 'add-text-modal-info' + }); + } + + // --- Text Input Area --- + // Create a container div for the text area to center it + const textAreaContainer = contentEl.createDiv({ cls: 'text-area-container' }); + textAreaContainer.style.display = 'flex'; + textAreaContainer.style.justifyContent = 'center'; + textAreaContainer.style.width = '100%'; + textAreaContainer.style.margin = '1rem 0'; + + // Create the text area directly instead of using a Setting + this.textArea = new TextAreaComponent(textAreaContainer); + this.textArea.setPlaceholder("Type your text here...") + .setValue(this.inputText) + .onChange((value) => { + this.inputText = value; + }); + + // Style the text area + this.textArea.inputEl.style.width = '90%'; + this.textArea.inputEl.style.height = '150px'; + this.textArea.inputEl.style.margin = '0 auto'; + this.textArea.inputEl.style.display = 'block'; + this.textArea.inputEl.addClass('add-text-entry-input'); + + // --- Action Buttons --- + new Setting(contentEl) + .addButton((btn) => + btn + .setButtonText('Save Entry') + .setCta() + .onClick(() => { + this.handleSave(); + }) + ) + .addButton((btn) => + btn + .setButtonText('Cancel') + .onClick(() => { + this.close(); + }) + ); + + // Focus the textarea when the modal opens + this.textArea?.inputEl.focus(); + } + + private async handleSave() { + const textToSave = this.inputText.trim(); + + if (!textToSave) { + new Notice("Please enter some text."); + this.textArea?.inputEl.focus(); // Focus back + return; + } + + // --- Prepare SQL --- + const columnsToInsert = [this.config.textColumn]; + const valuesToInsert = [textToSave]; + const placeholders = ['?']; + + // Add extra data columns and values + for (const [key, value] of Object.entries(this.config.extraData)) { + columnsToInsert.push(key); // Use key directly (e.g., 'period', 'key') + valuesToInsert.push(value); + placeholders.push('?'); + } + + // generate a random uuid for the entry + const uuid = this.dbService.generateUuid(); + const createdAt = new Date().toISOString(); + // Add the uuid to the columns and values + columnsToInsert.push("uuid"); + valuesToInsert.push(uuid); + placeholders.push('?'); + columnsToInsert.push("createdAt"); + columnsToInsert.push("updatedAt"); + valuesToInsert.push(createdAt); + valuesToInsert.push(createdAt); + placeholders.push('?'); + placeholders.push('?'); + + // Quote table and column names + const quotedTable = `"${this.config.dbTable}"`; + const quotedColumns = columnsToInsert.map(col => `"${col}"`).join(', '); + const placeholderString = placeholders.join(', '); + + const sql = `INSERT INTO ${quotedTable} (${quotedColumns}) VALUES (${placeholderString});`; + const params = valuesToInsert; + + // --- Execute Query --- + try { + await this.dbService.runQuery(sql, params); // Assumes runQuery for INSERT + new Notice(`Entry added to "${this.config.dbTable}"!`); + this.close(); + // Optionally trigger a refresh or callback here if needed + } catch (error) { + console.error(`Error saving text entry to ${this.config.dbTable}:`, error); + new Notice(`Failed to save entry. Check console. Error: ${error.message}`); + } + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} \ No newline at end of file diff --git a/src/components/DatePickerModal/DatePickerModal.ts b/src/components/DatePickerModal/DatePickerModal.ts new file mode 100644 index 0000000..f8ab4ea --- /dev/null +++ b/src/components/DatePickerModal/DatePickerModal.ts @@ -0,0 +1,101 @@ +import { Modal, App, Notice } from "obsidian"; +import { buildDatePickerHeader } from "./dom/buildDatePickerHeader"; +import { buildWeekdayHeaders } from "./dom/buildWeekdayHeaders"; +import { buildCalendarGrid } from "./dom/buildCalendarGrid"; +import { buildDatePickerFooter } from "./dom/buildDatePickerFooter"; +import { formatDateISO, parseDateISO } from "src/helpers/dateUtils"; + +export class DatePickerModal extends Modal { + private selectedDateObj: Date; //? Internal state as Date object + private currentDisplayMonth: Date; //? Month being displayed in the calendar + private today: Date; //? Today's date (normalized) + + //? Callback to notify the parent component of the selection + private onSelectCallback: (newIsoDate: string) => void; + + constructor(app: App, currentIsoDate: string, onSelect: (newIsoDate: string) => void) { + super(app); + this.onSelectCallback = onSelect; + + const initialDate = parseDateISO(currentIsoDate); + //? Ensure selectedDateObj is always a valid Date object, default to today if parse fails + this.selectedDateObj = initialDate && !isNaN(initialDate.getTime()) ? initialDate : new Date(); + //? Set time to 00:00:00 UTC to compare dates easily + this.selectedDateObj.setUTCHours(0, 0, 0, 0); + + this.today = new Date(); + this.today.setUTCHours(0, 0, 0, 0); + + //? Start display month from the selected date's month + this.currentDisplayMonth = new Date(Date.UTC(this.selectedDateObj.getUTCFullYear(), this.selectedDateObj.getUTCMonth(), 1)); + + } + + onOpen() { + this.contentEl.addClass("custom-datepicker-modal-content"); + this.render(); + } + + private render() { + const { contentEl } = this; + contentEl.empty(); // Clear previous content + + // --- Build Header --- + const headerEl = buildDatePickerHeader( + this.currentDisplayMonth, + this._handlePrevMonth, // Pass handler references + this._handleNextMonth + ); + contentEl.appendChild(headerEl); + + // --- Build Weekday Headers --- + const weekdaysEl = buildWeekdayHeaders(true); // Include week number header + contentEl.appendChild(weekdaysEl); + + // --- Build Calendar Grid --- + const gridEl = buildCalendarGrid( + this.currentDisplayMonth, + this.selectedDateObj, + this.today, + this._handleDateSelect // Pass date selection handler + ); + contentEl.appendChild(gridEl); + + // --- Build Footer --- + const footerEl = buildDatePickerFooter( + this.selectedDateObj, + this._confirmSelection // Pass confirmation handler + ); + contentEl.appendChild(footerEl); + } + + // --- Handlers passed to builders --- + + private _handlePrevMonth = (): void => { + this.currentDisplayMonth.setUTCMonth(this.currentDisplayMonth.getUTCMonth() - 1); + this.render(); // Re-render the whole modal + }; + + private _handleNextMonth = (): void => { + this.currentDisplayMonth.setUTCMonth(this.currentDisplayMonth.getUTCMonth() + 1); + this.render(); // Re-render the whole modal + }; + + private _handleDateSelect = (selected: Date): void => { + this.selectedDateObj = selected; // Update internal state + //? Re-render to update selection highlight and footer display + //? Alternatively, could update only specific elements for performance + this.render(); + }; + + private _confirmSelection = (): void => { + const isoDate = formatDateISO(this.selectedDateObj); + this.onSelectCallback(isoDate); // Notify parent component + new Notice(`Date set to ${isoDate}`); + this.close(); + }; + + onClose() { + this.contentEl.empty(); + } +} \ No newline at end of file diff --git a/src/components/DatePickerModal/datePickerUtils.ts b/src/components/DatePickerModal/datePickerUtils.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/components/DatePickerModal/dom/buildCalendarGrid.ts b/src/components/DatePickerModal/dom/buildCalendarGrid.ts new file mode 100644 index 0000000..ea21214 --- /dev/null +++ b/src/components/DatePickerModal/dom/buildCalendarGrid.ts @@ -0,0 +1,94 @@ +import { getISOWeekNumber } from "src/helpers/dateUtils"; + +/** + * Builds the main grid of days for the calendar month. + * @param currentDisplayMonth - The month/year being displayed (Date object, 1st of month UTC). + * @param selectedDateObj - The currently selected date (Date object, normalized to UTC midnight). + * @param today - Today's date (Date object, normalized to UTC midnight). + * @param onDateSelect - Callback function executed when a day button is clicked, passing the selected Date object. + * @returns The HTMLElement representing the calendar grid. + */ +export function buildCalendarGrid( + currentDisplayMonth: Date, + selectedDateObj: Date, + today: Date, + onDateSelect: (selected: Date) => void +): HTMLElement { + const grid = document.createElement("div"); + grid.className = "calendar-grid"; // Grid container + + const year = currentDisplayMonth.getUTCFullYear(); + const month = currentDisplayMonth.getUTCMonth(); + + //? Day of the week for the 1st of the month (0=Sun, 6=Sat). Use UTC methods. + const firstDayWeekday = new Date(Date.UTC(year, month, 1)).getUTCDay(); + //? Calculate offset for Monday start (0=Mon, 6=Sun) + const startOffset = (firstDayWeekday + 6) % 7; + + //? Last day of the *current* month. Use UTC month+1, day 0. + const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + + let dayCounter = 1 - startOffset; // Start day counter including offset for leading empty cells + let currentWeekNumber = -1; // Track week number to avoid duplicates per row + + while (dayCounter <= daysInMonth) { + // --- Add Week Number Cell (at the start of each visual row) --- + const dateForWeekNum = new Date(Date.UTC(year, month, dayCounter > 0 ? dayCounter : 1)); + const weekNumber = getISOWeekNumber(dateForWeekNum); + + //? Add week number cell at the start of grid row (every 8th element: 1 week# + 7 days) + if (grid.children.length % 8 === 0) { + const weekEl = document.createElement("div"); + weekEl.className = "week-number"; + weekEl.textContent = `${String(weekNumber).padStart(2, "0")}`; + grid.appendChild(weekEl); + currentWeekNumber = weekNumber; + } + + // --- Add 7 Day Cells for the current week --- + for (let i = 0; i < 7; i++) { + const cell = document.createElement("div"); + cell.className = "calendar-day-cell"; // Container for potential button + + if (dayCounter < 1 || dayCounter > daysInMonth) { + //? Empty cell (before start or after end of month) + cell.classList.add("empty"); + } else { + //? Valid day within the month + const currentDate = new Date(Date.UTC(year, month, dayCounter)); + //? No need to normalize time, UTC dates are already at midnight + + const dayButton = document.createElement("button"); + dayButton.textContent = String(dayCounter); + dayButton.className = "calendar-day"; + dayButton.setAttribute("aria-label", currentDate.toLocaleDateString(undefined, { // Use local format for aria-label + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' + })); + + //? Check if this is the currently selected date + if (currentDate.getTime() === selectedDateObj.getTime()) { + dayButton.classList.add("selected"); + dayButton.setAttribute("aria-selected", "true"); + } else { + dayButton.setAttribute("aria-selected", "false"); + } + + //? Check if this is today's date + if (currentDate.getTime() === today.getTime()) { + dayButton.classList.add("today"); + } + + //? Attach click handler + dayButton.onclick = () => { + onDateSelect(currentDate); // Pass the Date object back to the modal's handler + }; + + cell.appendChild(dayButton); + } + grid.appendChild(cell); + dayCounter++; + } + } + + return grid; +} \ No newline at end of file diff --git a/src/components/DatePickerModal/dom/buildDatePickerFooter.ts b/src/components/DatePickerModal/dom/buildDatePickerFooter.ts new file mode 100644 index 0000000..a258ac8 --- /dev/null +++ b/src/components/DatePickerModal/dom/buildDatePickerFooter.ts @@ -0,0 +1,25 @@ +import { formatDateForDisplay } from "src/helpers/dateUtils"; + +/** + * Builds the footer section of the date picker modal (Selected Date Display, Confirm Button). + * @param selectedDateObj - The currently selected date (Date object). + * @param onConfirm - Callback function to execute when the 'Select' button is clicked. + * @returns The HTMLElement representing the footer. + */ +export function buildDatePickerFooter( + selectedDateObj: Date, + onConfirm: () => void +): HTMLElement { + const footer = document.createElement("div"); + footer.className = "calendar-footer"; + + // --- Display Currently Selected Date --- + const selectedDateDisplay = footer.createEl("span", { cls: "selected-date-display" }); + selectedDateDisplay.textContent = `Selected: ${formatDateForDisplay(selectedDateObj)}`; + + // --- Confirm Button --- + const confirmBtn = footer.createEl("button", { text: "Select", cls: "mod-cta" }); // Use Obsidian's primary button style + confirmBtn.onclick = onConfirm; // Assign the provided handler + + return footer; +} \ No newline at end of file diff --git a/src/components/DatePickerModal/dom/buildDatePickerHeader.ts b/src/components/DatePickerModal/dom/buildDatePickerHeader.ts new file mode 100644 index 0000000..375620e --- /dev/null +++ b/src/components/DatePickerModal/dom/buildDatePickerHeader.ts @@ -0,0 +1,38 @@ + +/** + * Builds the header section of the date picker modal (Prev, Title, Next). + * @param currentDisplayMonth - The Date object representing the month/year being displayed. + * @param onPrevMonth - Callback function to execute when the 'Previous' button is clicked. + * @param onNextMonth - Callback function to execute when the 'Next' button is clicked. + * @returns The HTMLElement representing the header. + */ +export function buildDatePickerHeader( + currentDisplayMonth: Date, + onPrevMonth: () => void, + onNextMonth: () => void +): HTMLElement { + const header = document.createElement("div"); + header.className = "calendar-header"; + + // --- Previous Month Button --- + const prevBtn = header.createEl("button", { text: "←", cls: "clickable-icon" }); + prevBtn.setAttribute("aria-label", "Previous month"); + prevBtn.onclick = onPrevMonth; // Assign the provided handler + + // --- Month/Year Title --- + const title = header.createEl("span", { + cls: "calendar-title", + text: currentDisplayMonth.toLocaleString("default", { // Use user's default locale + month: "long", + year: "numeric", + timeZone: 'UTC' // Ensure month/year is based on UTC date + }), + }); + + // --- Next Month Button --- + const nextBtn = header.createEl("button", { text: "→", cls: "clickable-icon" }); + nextBtn.setAttribute("aria-label", "Next month"); + nextBtn.onclick = onNextMonth; // Assign the provided handler + + return header; +} \ No newline at end of file diff --git a/src/components/DatePickerModal/dom/buildWeekdayHeaders.ts b/src/components/DatePickerModal/dom/buildWeekdayHeaders.ts new file mode 100644 index 0000000..16f221e --- /dev/null +++ b/src/components/DatePickerModal/dom/buildWeekdayHeaders.ts @@ -0,0 +1,20 @@ +/** + * Builds the row displaying weekday abbreviations (Mo, Tu, etc.). + * @param includeWeekNumber - If true, adds a "W#" header cell at the beginning. + * @returns The HTMLElement representing the weekday header row. + */ +export function buildWeekdayHeaders(includeWeekNumber: boolean = false): HTMLElement { + const weekdaysContainer = document.createElement("div"); + weekdaysContainer.className = "calendar-weekdays"; + + //? Header for Week# column (optional) + if (includeWeekNumber) { + weekdaysContainer.createDiv({ cls: "week-number-header", text: "W#" }); + } + + //? Standard Monday-first weekdays + const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; + weekdays.forEach(day => weekdaysContainer.createDiv({ cls: "calendar-weekday", text: day })); + + return weekdaysContainer; +} \ No newline at end of file diff --git a/src/components/MoodNoteModal/MoodNoteEntryModal.tsx b/src/components/MoodNoteModal/MoodNoteEntryModal.tsx new file mode 100644 index 0000000..f5b7dfc --- /dev/null +++ b/src/components/MoodNoteModal/MoodNoteEntryModal.tsx @@ -0,0 +1,238 @@ +import { App, Modal, Notice, TextAreaComponent, Setting } from 'obsidian'; +import { DBService } from '../../DBService'; +import { getContrastYIQ } from './helpers/getContrastYIQ'; + +interface MoodTag { + text: string; + type: string; + color: string; +} + +export class MoodNoteEntryModal extends Modal { + private dbService: DBService; + private availableTags: MoodTag[] = []; + private selectedTags: Set = new Set(); // Store names of selected tags + private comment: string = ''; + private commentComponent: TextAreaComponent | null = null; + private tagContainerEl: HTMLElement | null = null; + private rating: number = 5; // Default rating value + private ratingComponent: HTMLElement | null = null; + + private onSaveCallback?: () => void; + + constructor(app: App, dbService: DBService, onSave?: () => void) { + super(app); + this.dbService = dbService; + this.onSaveCallback = onSave; + this.modalEl.addClass('mood-note-entry-modal'); + } + + async onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('mood-note-modal-content'); + + contentEl.createEl('h2', { text: 'Add Mood Note 💭' }); + + // --- Fetch Available Tags --- + await this.fetchTags(); + + // --- Tag Selection Area --- + contentEl.createEl('h4', { text: 'Select Tags:' }); + this.tagContainerEl = contentEl.createDiv({ cls: 'mood-tag-selection-area' }); + this.renderTags(); + + // --- Comment Area --- + contentEl.createEl('h4', { text: 'Comment' }); + contentEl.createEl('p', { text: 'Add any details about your current mood.', cls: 'setting-item-description' }); + + const commentContainer = contentEl.createDiv({ cls: 'mood-note-comment-container' }); + const textArea = new TextAreaComponent(commentContainer); + this.commentComponent = textArea; + textArea.setPlaceholder("How are you feeling?") + .setValue(this.comment) + .onChange((value) => { + this.comment = value; + }); + textArea.inputEl.rows = 6; + textArea.inputEl.style.width = '100%'; + textArea.inputEl.addClass('mood-note-comment-input'); + + // --- Rating Area --- + contentEl.createEl('h4', { text: 'Rating' }); + contentEl.createEl('p', { text: 'How would you rate your mood from 1 to 10?', cls: 'setting-item-description' }); + + const ratingContainer = contentEl.createDiv({ cls: 'mood-note-rating-container' }); + + // Create slider container + const sliderContainer = ratingContainer.createDiv({ cls: 'mood-note-slider-container' }); + + // Create slider + const slider = sliderContainer.createEl('input', { + type: 'range', + attr: { + min: '1', + max: '10', + value: this.rating.toString(), + step: '1' + }, + cls: 'mood-note-slider' + }); + + // Create value display + const valueDisplay = sliderContainer.createEl('span', { + text: this.rating.toString(), + cls: 'mood-note-rating-value' + }); + + // Update value display when slider changes + slider.addEventListener('input', (e) => { + const value = (e.target as HTMLInputElement).value; + this.rating = parseInt(value); + valueDisplay.setText(value); + }); + + this.ratingComponent = ratingContainer; + + // --- Action Buttons --- + new Setting(contentEl) + .addButton((btn) => + btn + .setButtonText('Save Mood Note') + .setCta() // Makes it visually prominent + .onClick(() => { + this.handleSave(); + }) + ) + .addButton((btn) => + btn + .setButtonText('Cancel') + .onClick(() => { + this.close(); + }) + ); + } + + private async fetchTags() { + try { + // Adjust query based on your schema + const query = `SELECT DISTINCT text, color FROM Tags WHERE type = 'moodTag' ORDER BY text ASC;`; + + const results = await this.dbService.getQuery(query); + + if (results && Array.isArray(results)) { + this.availableTags = results.map(row => ({ text: row.text, type: 'moodTag', color: row.color })); + } else { + console.warn("Could not fetch mood tags or no tags found."); + this.availableTags = []; + } + } catch (error) { + console.error("Error fetching mood tags:", error); + new Notice("Error fetching tags. Check console."); + this.availableTags = []; + } + } + + private renderTags() { + if (!this.tagContainerEl) return; + this.tagContainerEl.empty(); + this.tagContainerEl.addClass('tag-checkbox-container'); + + if (this.availableTags.length === 0) { + this.tagContainerEl.setText('No mood tags found or configured.'); + return; + } + + this.availableTags.forEach(tag => { + const tagId = `mood-tag-${tag.text.replace(/[^a-zA-Z0-9]/g, '-')}`; + const isChecked = this.selectedTags.has(tag.text); + + const label = this.tagContainerEl?.createEl('label', { + cls: `mood-tag-label ${isChecked ? 'is-checked' : ''}`, + attr: { for: tagId } + }); + + if (!label) { + console.error("Failed to create label for tag:", tag); + return; + } + + // --- Apply Color Styling --- + if (tag.color) { + try { + const textColor = getContrastYIQ(tag.color); + label.style.backgroundColor = tag.color; + label.style.color = textColor; + // Make border slightly darker or use the same color initially + label.style.borderColor = tag.color; + + // Ensure checkbox contrast might need specific styling if colors are very light/dark + // but usually browsers handle this okay. + } catch (e) { + console.error(`Failed to apply color ${tag.color} for tag ${tag.text}:`, e); + // Apply default styles if color fails + label.style.backgroundColor = ''; + label.style.color = ''; + label.style.borderColor = ''; + } + } else { + // Reset styles if no color defined for this tag + label.style.backgroundColor = ''; + label.style.color = ''; + label.style.borderColor = ''; + } + + const checkbox = label.createEl('input', { + type: 'checkbox', + attr: { id: tagId } + }); + checkbox.checked = isChecked; + + label.appendText(tag.text); + + checkbox.onchange = (e) => { + if ((e.target as HTMLInputElement).checked) { + this.selectedTags.add(tag.text); + label.addClass('is-checked'); // Update style immediately + } else { + this.selectedTags.delete(tag.text); + label.removeClass('is-checked'); // Update style immediately + } + }; + + }); + } + + private async handleSave() { + const tagsString = Array.from(this.selectedTags).join(','); + const comment = this.comment.trim(); + const timestamp = new Date().toISOString(); + const uuid = this.dbService.generateUuid(); + const createdAt = new Date().toISOString(); + const updatedAt = new Date().toISOString(); + + const sql = ` + INSERT INTO Mood (uuid, date, tag, comment, rating, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?); + `; + + const params = [uuid, timestamp, tagsString, comment, this.rating, createdAt, updatedAt]; + + try { + await this.dbService.runQuery(sql, params); // Use runQuery or appropriate method for INSERT + new Notice(`Mood Note saved: ${tagsString || 'No tags'} - ${comment.substring(0, 20)}...`); + this.close(); // Close modal on success + if (this.onSaveCallback) { + this.onSaveCallback(); // Trigger refresh if provided + } + } catch (error) { + console.error("Error saving mood note:", error); + new Notice("Failed to save mood note. Check console."); + } + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} \ No newline at end of file diff --git a/src/components/MoodNoteModal/helpers/getContrastYIQ.ts b/src/components/MoodNoteModal/helpers/getContrastYIQ.ts new file mode 100644 index 0000000..22a3c2b --- /dev/null +++ b/src/components/MoodNoteModal/helpers/getContrastYIQ.ts @@ -0,0 +1,37 @@ +/** + * Calculates whether black or white text provides better contrast against a given hex background color. + * Uses YIQ formula. + * @param hexcolor - The background color in hex format (e.g., "#RRGGBB", "#RGB"). + * @returns '#000000' (black) or '#FFFFFF' (white). + */ +export function getContrastYIQ(hexcolor: string): string { + if (!hexcolor) return '#000000'; // Default to black if no color provided + + // Remove '#' if present + hexcolor = hexcolor.replace("#", ""); + + let r: number, g: number, b: number; + + // Handle short hex codes (#RGB) + if (hexcolor.length === 3) { + r = parseInt(hexcolor.substr(0, 1).repeat(2), 16); + g = parseInt(hexcolor.substr(1, 1).repeat(2), 16); + b = parseInt(hexcolor.substr(2, 1).repeat(2), 16); + } + // Handle full hex codes (#RRGGBB) + else if (hexcolor.length === 6) { + r = parseInt(hexcolor.substr(0, 2), 16); + g = parseInt(hexcolor.substr(2, 2), 16); + b = parseInt(hexcolor.substr(4, 2), 16); + } else { + // Invalid format, default to black + console.warn(`Invalid hex color format for contrast calculation: ${hexcolor}`); + return '#000000'; + } + + // Calculate YIQ (luminance) + const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000; + + // Threshold (standard value is 128, adjust if needed) + return (yiq >= 128) ? '#000000' : '#FFFFFF'; +} \ No newline at end of file diff --git a/src/components/TimePickerModal/TimePickerModal.ts b/src/components/TimePickerModal/TimePickerModal.ts new file mode 100644 index 0000000..ebf509c --- /dev/null +++ b/src/components/TimePickerModal/TimePickerModal.ts @@ -0,0 +1,64 @@ +import { App, Modal } from "obsidian"; +import { buildTimePickerDOM, buildTimePickerActions, TimePickerDOMElements } from "./dom/buildTimePickerDOM"; +import { parseTimeString, formatTimeString } from "./timePickerUtils"; + +export class TimePickerModal extends Modal { + private initialHour: number; + private initialMinute: number; + private onSelectCallback: (selectedTime: string) => void; + + //? References to the dropdown elements + private uiElements: TimePickerDOMElements | null = null; + + constructor(app: App, initialTime: string | undefined, onSelect: (selectedTime: string) => void) { + super(app); + this.onSelectCallback = onSelect; + + //? Parse initial time into numbers + const { hour, minute } = parseTimeString(initialTime ?? ""); // Provide default if undefined + this.initialHour = hour; + this.initialMinute = minute; + + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("time-picker-modal-content"); // For styling + + contentEl.createEl("h2", { text: "Select Time" }); + + // --- Build Dropdowns --- + this.uiElements = buildTimePickerDOM(contentEl, this.initialHour, this.initialMinute); + + // --- Build Actions --- + buildTimePickerActions(contentEl, this._handleCancel, this._handleConfirm); + } + + // --- Handlers --- + + private _handleCancel = (): void => { + this.close(); + }; + + private _handleConfirm = (): void => { + if (!this.uiElements) { + console.error("[TimePickerModal] Cannot confirm: UI elements not found."); + return; + } + //? Get current values from dropdowns + const selectedHour = this.uiElements.hourSelect.value; + const selectedMinute = this.uiElements.minuteSelect.value; + + //? Format the selected time + const selectedTime = formatTimeString(selectedHour, selectedMinute); + + this.onSelectCallback(selectedTime); // Pass HH:MM string back + this.close(); + }; + + onClose() { + this.contentEl.empty(); + this.uiElements = null; // Clear references + } +} \ No newline at end of file diff --git a/src/components/TimePickerModal/dom/buildTimePickerDOM.ts b/src/components/TimePickerModal/dom/buildTimePickerDOM.ts new file mode 100644 index 0000000..1535bdf --- /dev/null +++ b/src/components/TimePickerModal/dom/buildTimePickerDOM.ts @@ -0,0 +1,66 @@ +import { Setting } from "obsidian"; +import { createHourOptions, createMinuteOptions } from "../timePickerUtils"; + +//? Interface for the references to the created select elements +export interface TimePickerDOMElements { + hourSelect: HTMLSelectElement; + minuteSelect: HTMLSelectElement; +} + +/** + * Builds the core UI elements (dropdowns) for the Time Picker Modal. + * @param contentEl - The HTMLElement to append the controls to. + * @param initialHour - The initially selected hour (0-23). + * @param initialMinute - The initially selected minute (0-59). + * @returns Object containing references to the select elements. + */ +export function buildTimePickerDOM( + contentEl: HTMLElement, + initialHour: number, + initialMinute: number +): TimePickerDOMElements { + + // Use Obsidian's Setting components for structure and alignment + const setting = new Setting(contentEl) + .setName("Select Time") + .setClass("time-picker-controls"); // Add class for specific styling + + // --- Hour Dropdown --- + const hourSelect = document.createElement("select"); + hourSelect.classList.add("dropdown", "time-picker-select", "time-picker-hour"); + hourSelect.setAttribute("aria-label", "Hour"); + hourSelect.appendChild(createHourOptions(initialHour)); // Populate options + setting.controlEl.appendChild(hourSelect); // Add to setting's control area + + // --- Separator --- + const separator = document.createElement("span"); + separator.textContent = ":"; + separator.addClass("time-picker-separator"); + setting.controlEl.appendChild(separator); + + // --- Minute Dropdown --- + const minuteSelect = document.createElement("select"); + minuteSelect.classList.add("dropdown", "time-picker-select", "time-picker-minute"); + minuteSelect.setAttribute("aria-label", "Minute"); + minuteSelect.appendChild(createMinuteOptions(initialMinute)); // Populate options + setting.controlEl.appendChild(minuteSelect); + + return { hourSelect, minuteSelect }; +} + +/** Builds the action buttons (Cancel, Confirm) for the modal. */ +export function buildTimePickerActions( + contentEl: HTMLElement, + onCancel: () => void, + onConfirm: () => void +): void { + new Setting(contentEl) + .setClass("time-picker-actions") // Add class for styling + .addButton(button => button + .setButtonText("Cancel") + .onClick(onCancel)) + .addButton(button => button + .setButtonText("Confirm") + .setCta() // Make it the primary action + .onClick(onConfirm)); +} \ No newline at end of file diff --git a/src/components/TimePickerModal/timePickerUtils.ts b/src/components/TimePickerModal/timePickerUtils.ts new file mode 100644 index 0000000..98b36bb --- /dev/null +++ b/src/components/TimePickerModal/timePickerUtils.ts @@ -0,0 +1,60 @@ + +/** Generates