diff --git a/main.ts b/main.ts index 608f551..5d79ee5 100644 --- a/main.ts +++ b/main.ts @@ -1,21 +1,21 @@ import { - App, Plugin, - PluginSettingTab, - Setting, FileSystemAdapter, Editor, MarkdownView, MarkdownPostProcessorContext } from "obsidian"; -import { DBService } from "./src/dbService"; +import { DBService } from "./src/DBService"; +import { SQLiteDBSettingTab } from "./src/settingTab"; import { inspectTableStructure, convertEntriesInNotes } from "./src/commands"; import { processSqlBlock, processSqlChartBlock, renderDatePicker } from "./src/codeblocks"; import { pickTableName } from "./src/helpers"; import { SQLiteDBSettings, DEFAULT_SETTINGS } from "./src/types"; + import { injectDatePickerStyles } from "src/styles/datePickerInject"; -import "./src/webcomponents/habitCounter"; + +import { registerHabitCounter } from "./src/webcomponents/HabitCounter/registerHabitCounter"; export default class SQLiteDBPlugin extends Plugin { settings: SQLiteDBSettings; @@ -29,11 +29,14 @@ export default class SQLiteDBPlugin extends Plugin { injectDatePickerStyles(); + this.registerMarkdownPostProcessor((el, ctx) => { + registerHabitCounter(el, this.dbService); + }); + 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); }, @@ -106,29 +109,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..f6764d7 --- /dev/null +++ b/src/DBService.ts @@ -0,0 +1,184 @@ +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 { + 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 = ""; + + 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; + + if (!settings.dbFilePath) { + new Notice("No DB path set in plugin settings."); + return false; + } + + // Only reload if forced or not already loaded + if (!this.db || forceReload) { + try { + console.log("DBService: Initializing SQL.js and loading DB..."); + this.SQL = await initSqlJs({ + locateFile: (file) => `${basePath}/${this.app.vault.configDir}/plugins/sqlite-db/${file}`, + }); + + console.log(`DBService: Reading DB file from: ${settings.dbFilePath}`); + const fileBuffer = readFileSync(settings.dbFilePath); + this.db = new this.SQL.Database(fileBuffer); + console.log("DBService: Database loaded into memory."); + + // 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.`); + console.log("DBService: DB Loaded successfully."); + } else { + new Notice("DB Loaded, but found no tables."); + console.log("DBService: 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 { + console.log("DBService: Database already loaded."); + 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.db) { + console.error("DBService.getQuery: Database is not loaded."); + throw new Error("Database not loaded."); + } + + 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; + } catch (error) { + console.error(`DBService.getQuery: Error executing SQL: ${sql}`, params, error); + throw error; // Re-throw + } finally { + if (stmt) { + try { stmt.free(); } catch (freeError) { console.error("DBService.getQuery: Error freeing statement:", freeError); } + } + } + } + + /** + * 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.db) { + console.error("DBService.runQuery: Database is not loaded."); + throw new Error("Database not loaded."); + } + + let stmt: Statement | null = null; + try { + stmt = this.db.prepare(sql); + stmt.bind(params); + stmt.run(); + + await this._saveDBInternal(); + + } catch (error) { + console.error(`DBService.runQuery: Error executing SQL: ${sql}`, params, error); + throw error; + } finally { + if (stmt) { + try { stmt.free(); } catch (freeError) { console.error("DBService.runQuery: Error freeing statement:", freeError); } + } + } + } + + /** + * 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; + } + } + + /** + * 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/processSqlBlock/index.ts b/src/codeblocks/processSqlBlock/index.ts index d29a21a..7ce6f11 100644 --- a/src/codeblocks/processSqlBlock/index.ts +++ b/src/codeblocks/processSqlBlock/index.ts @@ -1,5 +1,5 @@ import { Notice } from "obsidian"; -import { DBService } from "../../dbService"; +import { DBService } from "../../DBService"; import { parseSqlParams, validateTable, buildSqlQuery, renderResults } from "./helpers"; import { pluginState } from "../../pluginState"; diff --git a/src/codeblocks/processSqlChartBlock/index.ts b/src/codeblocks/processSqlChartBlock/index.ts index 3e131f6..a2ceea8 100644 --- a/src/codeblocks/processSqlChartBlock/index.ts +++ b/src/codeblocks/processSqlChartBlock/index.ts @@ -1,5 +1,5 @@ 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 { pluginState } from "../../pluginState"; 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/inspectTableStructure.ts b/src/commands/inspectTableStructure.ts index 4acf405..7b326eb 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"; /** diff --git a/src/dbService.ts b/src/dbService.ts deleted file mode 100644 index 9e297cd..0000000 --- a/src/dbService.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Notice, App } from "obsidian"; -import initSqlJs, { Database } from "sql.js"; -import { readFileSync } from "fs"; -import { SQLiteDBSettings } from "./types"; - -/** - * This class manages loading/reloading the SQLite DB using sql.js - */ -export class DBService { - private db: Database | null = null; - private app: App; - - 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) { - if (!settings.dbFilePath) { - new Notice("No DB path set in plugin settings."); - return; - } - - if (!this.db || forceReload) { - try { - const SQL = await initSqlJs({ - locateFile: (file) => `${basePath}/${this.app.vault.configDir}/plugins/sqlite-db/${file}`, - }); - const fileBuffer = readFileSync(settings.dbFilePath); - const uint8Array = new Uint8Array(fileBuffer); - this.db = new SQL.Database(uint8Array); - - // Check if we have at least one table - const result = this.db.exec("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1;"); - if (result?.[0]?.values?.[0]?.[0]) { - new Notice(`DB Loaded.`); - } else { - new Notice("DB Loaded, but found no tables."); - } - } catch (err) { - console.error(err); - new Notice("Error reading DB: " + (err as Error).message); - } - } - } - - /** - * Returns the current Database instance, or null if not loaded. - */ - getDB(): Database | null { - return this.db; - } -} diff --git a/src/helpers/pickTableName.ts b/src/helpers/pickTableName.ts index a3ad71a..edb1771 100644 --- a/src/helpers/pickTableName.ts +++ b/src/helpers/pickTableName.ts @@ -1,5 +1,5 @@ import { App, Notice } from "obsidian"; -import { DBService } from "../dbService"; +import { DBService } from "../DBService"; import { TablePickerModal } from "../components/TablePickerModal"; /** diff --git a/src/settingTab.ts b/src/settingTab.ts new file mode 100644 index 0000000..2fbf121 --- /dev/null +++ b/src/settingTab.ts @@ -0,0 +1,29 @@ +import SQLiteDBPlugin from "main"; +import { App, PluginSettingTab, Setting } from "obsidian"; + +export 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/webcomponents/HabitCounter/HabitCounter.ts b/src/webcomponents/HabitCounter/HabitCounter.ts new file mode 100644 index 0000000..da99e0e --- /dev/null +++ b/src/webcomponents/HabitCounter/HabitCounter.ts @@ -0,0 +1,121 @@ +import { DBService } from "../../DBService"; +import { HabitDataService } from "./services/HabitDataService"; +import { applyHabitCounterStyles } from "./styles/applyHabitCounterStyles"; +import { buildHabitCounterDOM } from "./dom/buildHabitCounterDOM"; +import { createDecrementHandler, createIncrementHandler } from "./eventHandlers/clickHandlers"; +import { loadHabitData } from "./data/loadHabitData"; +import { updateHabitData } from "./data/updateHabitData"; +import { + updateDisplayValue, + updateStaticUI, + setComponentErrorState, + clearComponentErrorState, + HabitCounterUIElements +} from "./dom/uiUpdaters"; + +//? Main Web Component class, acts as an orchestrator. +export class HabitCounter extends HTMLElement { + // --- State --- + public habitKey: string = ""; //? Make public if needed by handlers/helpers + public initialDate: string = ""; + public emoji: string = ""; + public table: string = ""; + public currentValue: number = 0; + private _initialLoadTriggered: boolean = false; + private _isInitialized: boolean = false; + + // --- Dependencies --- + public habitDataService: HabitDataService | null = null; //? Public for helpers + + // --- Shadow DOM Element References --- + //? Store the object returned by the builder + private uiElements: HabitCounterUIElements = { + wrapper: null, valueDisplay: null, labelElement: null, minusButton: null, plusButton: null + }; + + // --- Lifecycle --- + connectedCallback() { + if (this._isInitialized) return; + //& console.log("[HabitCounter Component] connectedCallback - Initializing..."); + + //~ Create handlers bound to this instance *before* building DOM + const handlers = { + handleDecrement: createDecrementHandler(this), + handleIncrement: createIncrementHandler(this), + }; + + this.uiElements = buildHabitCounterDOM(this.attachShadow({ mode: "open" }), handlers); //~ Build DOM via helper + applyHabitCounterStyles(this.shadowRoot!); //~ Apply styles via helper + this._isInitialized = true; + //& console.log("[HabitCounter Component] Initialization complete."); + } + + // --- Public Methods --- + public setDbService(service: DBService): void { + //& console.log("[HabitCounter Component] setDbService called."); + if (!service) { + console.error("[HabitCounter Component] Invalid DBService provided."); + this.showErrorState("Setup Error"); //~ Use helper + return; + } + try { + this.habitDataService = new HabitDataService(service); //~ Initialize service dependency + } catch (error) { + console.error("[HabitCounter Component] Failed to initialize HabitDataService:", error); + this.showErrorState("Setup Error"); + return; + } + this._readAttributesAndInitLoad(); //~ Trigger attribute reading and loading + } + + // --- Internal Orchestration --- + private _readAttributesAndInitLoad(): void { + //& console.log("[HabitCounter Component] _readAttributesAndInitLoad running."); + this.habitKey = this.getAttribute("habit") ?? ""; + this.initialDate = this.getAttribute("date") ?? "@date"; + this.emoji = this.getAttribute("emoji") ?? "❓"; + this.table = this.getAttribute("table") ?? ""; + + updateStaticUI(this.uiElements, this.emoji, this.habitKey); //~ Update static parts of UI + + if (!this.table) { + console.warn(`[HabitCounter Component ${this.habitKey}] Table attribute missing.`); + this.showErrorState("No table"); + return; + } + + if (!this._initialLoadTriggered) { + this._initialLoadTriggered = true; + //& console.log(`[HabitCounter Component ${this.habitKey}] Triggering initial loadHabitData...`); + loadHabitData(this).catch(err => console.error("Unhandled error during initial load:", err)); //~ Load data via helper + } + } + + //? Public method called by click handlers via instance reference + public async _updateData(delta: number): Promise { + //& console.log(`[HabitCounter Component ${this.habitKey}] _updateData called with delta: ${delta}`); + await updateHabitData(this, delta); //~ Update data via helper + } + + //? Public method called by data helpers to update display + public _updateDisplay(newValue: number): void { + //& console.log(`[HabitCounter Component ${this.habitKey}] _updateDisplay called with value: ${newValue}`); + this.currentValue = newValue; //? Update internal state first + updateDisplayValue(this.uiElements, newValue); //~ Update DOM via helper + this.clearErrorState(); //? Clear errors on successful update + } + + //? Public methods for UI state helpers + public showErrorState(message?: string): void { + setComponentErrorState(this.uiElements, message); //~ Set error via helper + } + + public clearErrorState(): void { + clearComponentErrorState(this.uiElements); //~ Clear error via helper + } +} + +// --- Custom Element Definition --- +if (!customElements.get("habit-counter")) { + customElements.define("habit-counter", HabitCounter); +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/HabitCounter.types.ts b/src/webcomponents/HabitCounter/HabitCounter.types.ts new file mode 100644 index 0000000..c829d0a --- /dev/null +++ b/src/webcomponents/HabitCounter/HabitCounter.types.ts @@ -0,0 +1,16 @@ +//? Defines the expected structure of a record fetched from the habit table. +export interface HabitRecord { + value: number; //^ Assuming the column name for the count is 'value' +} + +//? Defines the properties needed to identify and update a habit entry. +export interface HabitDataArgs { + table: string; + habitKey: string; //^ Assuming the column name for the habit identifier is 'habitKey' + date: string; +} + +//? Combined arguments including the new value for updates. +export interface UpdateHabitDataArgs extends HabitDataArgs { + newValue: number; +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/data/calculateEffectiveDate.ts b/src/webcomponents/HabitCounter/data/calculateEffectiveDate.ts new file mode 100644 index 0000000..429f44c --- /dev/null +++ b/src/webcomponents/HabitCounter/data/calculateEffectiveDate.ts @@ -0,0 +1,12 @@ +import { pluginState } from "../../../pluginState"; + +/** + * Calculates the effective date to use for data operations. + * @param initialDate - The date value from the component's 'date' attribute ("YYYY-MM-DD" or "@date"). + * @returns The effective date string (YYYY-MM-DD). + */ +export function calculateEffectiveDate(initialDate: string): string { + const effectiveDate = initialDate === "@date" ? pluginState.selectedDate : initialDate; + //& console.log(`[CalculateEffectiveDate] Calculated: ${effectiveDate} (Initial: ${initialDate}, Global: ${pluginState.selectedDate})`); + return effectiveDate; +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/data/loadHabitData.ts b/src/webcomponents/HabitCounter/data/loadHabitData.ts new file mode 100644 index 0000000..6b461ca --- /dev/null +++ b/src/webcomponents/HabitCounter/data/loadHabitData.ts @@ -0,0 +1,40 @@ +import { HabitDataService } from "../services/HabitDataService"; +import { calculateEffectiveDate } from "./calculateEffectiveDate"; + +//? Interface describing the necessary parts of the component instance for loading. +interface LoadComponentInstance { + habitDataService: HabitDataService | null; + table: string; + habitKey: string; + initialDate: string; // Needed for effective date calculation + _updateDisplay: (value: number) => void; // Method to update UI + showErrorState: (message: string) => void; // Method to show error UI +} + +/** + * //? Orchestrates loading the habit data using the HabitDataService. + * @param instance - The HabitCounter component instance. + */ +export async function loadHabitData(instance: LoadComponentInstance): Promise { + //& console.log(`[LoadHabitData ${instance.habitKey}] loadHabitData running.`); + if (!instance.habitDataService || !instance.table || !instance.habitKey) { + console.error(`[LoadHabitData ${instance.habitKey}] Cannot load data: Missing service, table, or habitKey.`); + instance.showErrorState("Load Error"); + return; + } + + const args = { + table: instance.table, + habitKey: instance.habitKey, + date: calculateEffectiveDate(instance.initialDate), //~ Delegate date calculation + }; + + try { + const value = await instance.habitDataService.fetchHabitValue(args); + //& console.log(`[LoadHabitData ${instance.habitKey}] Data loaded successfully: ${value}`); + instance._updateDisplay(value); + } catch (error) { + console.error(`[LoadHabitData ${instance.habitKey}] Failed to load data:`, error); + instance.showErrorState("Load Error"); + } +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/data/updateHabitData.ts b/src/webcomponents/HabitCounter/data/updateHabitData.ts new file mode 100644 index 0000000..aea8bb7 --- /dev/null +++ b/src/webcomponents/HabitCounter/data/updateHabitData.ts @@ -0,0 +1,52 @@ +import { HabitDataService } from "../services/HabitDataService"; +import { calculateEffectiveDate } from "./calculateEffectiveDate"; +import { Notice } from "obsidian"; + +//? Interface describing the necessary parts of the component instance for updating. +interface UpdateComponentInstance { + habitDataService: HabitDataService | null; + table: string; + habitKey: string; + initialDate: string; // Needed for effective date calculation + currentValue: number; // The current value held by the component + _updateDisplay: (value: number) => void; // Method to update UI + showErrorState: (message: string) => void; // Method to show error UI + clearErrorState: () => void; // Method to clear error UI +} + + +/** + * Orchestrates updating the habit data via the HabitDataService. + * @param instance - The HabitCounter component instance. + * @param delta - The amount to change the value by (+1 or -1). + */ +export async function updateHabitData(instance: UpdateComponentInstance, delta: number): Promise { + //& console.log(`[UpdateHabitData ${instance.habitKey}] updateHabitData running with delta: ${delta}.`); + if (!instance.habitDataService || !instance.table || !instance.habitKey) { + console.error(`[UpdateHabitData ${instance.habitKey}] Cannot update data: Missing service, table, or habitKey.`); + instance.showErrorState("Save Error"); + new Notice("Cannot update habit: Setup incomplete."); + return; + } + + const currentNumericValue = typeof instance.currentValue === 'number' ? instance.currentValue : 0; + const newValue = Math.max(0, currentNumericValue + delta); //? Prevent negative values + + const args = { + table: instance.table, + habitKey: instance.habitKey, + date: calculateEffectiveDate(instance.initialDate), //~ Delegate date calculation + newValue: newValue, + }; + + try { + await instance.habitDataService.updateHabitValue(args); + //& console.log(`[UpdateHabitData ${instance.habitKey}] Data updated successfully to: ${newValue}`); + instance._updateDisplay(newValue); //? Update UI only on successful save + instance.clearErrorState(); + } catch (error) { + console.error(`[UpdateHabitData ${instance.habitKey}] Failed to update data:`, error); + instance.showErrorState("Save Error"); + new Notice(`Error saving value for ${instance.habitKey}`); + } +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/dom/buildHabitCounterDOM.ts b/src/webcomponents/HabitCounter/dom/buildHabitCounterDOM.ts new file mode 100644 index 0000000..408e6ae --- /dev/null +++ b/src/webcomponents/HabitCounter/dom/buildHabitCounterDOM.ts @@ -0,0 +1,48 @@ +import type { HabitCounterUIElements } from "./uiUpdaters"; + +//? Defines the expected structure for event handlers passed to the builder. +export interface HabitCounterEventHandlers { + handleDecrement: () => void; + handleIncrement: () => void; +} + +/** + * Builds the Shadow DOM structure for the habit counter. + * @param shadowRoot - The ShadowRoot to append elements to. + * @param handlers - Object containing click handler functions. + * @returns An object containing references to the created UI elements. + */ +export function buildHabitCounterDOM( + shadowRoot: ShadowRoot, + handlers: HabitCounterEventHandlers +): HabitCounterUIElements { + //& console.log("[HabitCounterDOM] buildHabitCounterDOM called."); + if (!shadowRoot) throw new Error("Cannot build DOM: ShadowRoot is null."); + if (!handlers || typeof handlers.handleDecrement !== 'function' || typeof handlers.handleIncrement !== 'function') { + throw new Error("Cannot build DOM: Invalid event handlers provided."); + } + + const wrapper = document.createElement("div"); + wrapper.className = "habit-wrapper"; + + const labelElement = document.createElement("span"); + labelElement.className = "habit-label"; + + const minusButton = document.createElement("button"); + minusButton.textContent = "−"; + minusButton.addEventListener('click', handlers.handleDecrement); + + const plusButton = document.createElement("button"); + plusButton.textContent = "+"; + plusButton.addEventListener('click', handlers.handleIncrement); + + const valueDisplay = document.createElement("span"); + valueDisplay.className = "habit-value"; + valueDisplay.textContent = "..."; //? Initial loading state + + wrapper.append(labelElement, minusButton, valueDisplay, plusButton); + shadowRoot.append(wrapper); + + //? Return references to the important elements + return { wrapper, valueDisplay, labelElement, minusButton, plusButton }; +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/dom/uiUpdaters.ts b/src/webcomponents/HabitCounter/dom/uiUpdaters.ts new file mode 100644 index 0000000..e578565 --- /dev/null +++ b/src/webcomponents/HabitCounter/dom/uiUpdaters.ts @@ -0,0 +1,58 @@ +// src/webcomponents/habitCounter/dom/uiUpdaters.ts + +//? Interface defining the elements needed by UI updaters. +export interface HabitCounterUIElements { + wrapper: HTMLElement | null; + valueDisplay: HTMLElement | null; + labelElement: HTMLElement | null; + minusButton: HTMLButtonElement | null; + plusButton: HTMLButtonElement | null; +} + +/** Updates the text content of the value display. */ +export function updateDisplayValue(elements: HabitCounterUIElements, newValue: number | string): void { + //& console.log(`[HabitCounterUI] updateDisplayValue called with: ${newValue}`); + if (elements.valueDisplay) { + elements.valueDisplay.textContent = String(newValue); + } else { + console.error("[HabitCounterUI] valueDisplay element not found for update."); + } +} + +/** Updates label text and button aria-labels based on component state. */ +export function updateStaticUI(elements: HabitCounterUIElements, emoji: string, habitKey: string): void { + //& console.log(`[HabitCounterUI] updateStaticUI called for: ${habitKey}`); + if (elements.labelElement) { + elements.labelElement.textContent = `${emoji} ${habitKey}:`; + //& console.log(`[HabitCounterUI] Set label text to: "${elements.labelElement.textContent}"`); + } + if (elements.minusButton) { + elements.minusButton.setAttribute("aria-label", `Decrease ${habitKey} value`); + } + if (elements.plusButton) { + elements.plusButton.setAttribute("aria-label", `Increase ${habitKey} value`); + } +} + + +/** Sets the component to visually indicate an error state. */ +export function setComponentErrorState(elements: HabitCounterUIElements, message: string = "Error"): void { + //& console.warn(`[HabitCounterUI] setComponentErrorState called with message: "${message}"`); + updateDisplayValue(elements, "ERR"); + if (elements.wrapper) { + elements.wrapper.style.borderColor = "var(--text-error)"; + elements.wrapper.style.backgroundColor = "var(--background-secondary-alt)"; + elements.wrapper.setAttribute("title", message); + } +} + +/** Clears any visual error indication. */ +export function clearComponentErrorState(elements: HabitCounterUIElements): void { + //& console.log(`[HabitCounterUI] clearComponentErrorState called.`); + //? Only reset if actually in error state (check border color) + if (elements.wrapper && elements.wrapper.style.borderColor !== "transparent") { + elements.wrapper.style.borderColor = "transparent"; + elements.wrapper.style.backgroundColor = ""; + elements.wrapper.removeAttribute("title"); + } +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/eventHandlers/clickHandlers.ts b/src/webcomponents/HabitCounter/eventHandlers/clickHandlers.ts new file mode 100644 index 0000000..193582a --- /dev/null +++ b/src/webcomponents/HabitCounter/eventHandlers/clickHandlers.ts @@ -0,0 +1,26 @@ +//? Type for the component instance +//? It needs access to the method that triggers the data update. +interface HabitCounterInstance { + _updateData: (delta: number) => Promise; + habitKey: string; +} + +/** Creates the handler for the decrement button click. */ +export function createDecrementHandler(instance: HabitCounterInstance): () => void { + return () => { + //& console.log(`[HabitCounterClickHandler ${instance.habitKey}] Decrement clicked.`); + instance._updateData(-1).catch(err => { + console.error(`[HabitCounterClickHandler ${instance.habitKey}] Error during decrement update:`, err); + }); + }; +} + +/** Creates the handler for the increment button click. */ +export function createIncrementHandler(instance: HabitCounterInstance): () => void { + return () => { + //& console.log(`[HabitCounterClickHandler ${instance.habitKey}] Increment clicked.`); + instance._updateData(1).catch(err => { + console.error(`[HabitCounterClickHandler ${instance.habitKey}] Error during increment update:`, err); + }); + }; +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/registerHabitCounter.ts b/src/webcomponents/HabitCounter/registerHabitCounter.ts new file mode 100644 index 0000000..ae3b01b --- /dev/null +++ b/src/webcomponents/HabitCounter/registerHabitCounter.ts @@ -0,0 +1,62 @@ +// src/webcomponents/registration.ts +import { DBService } from "../../DBService"; +import { HabitCounter } from "./HabitCounter"; + +//? Define element if needed here or ensure it's defined elsewhere before this runs +if (!customElements.get("habit-counter")) { + customElements.define("habit-counter", HabitCounter); +} + +/** + * Finds placeholder elements and replaces them with initialized HabitCounter components. + * @param el The container element to search within. + * @param dbService The DBService instance for dependency injection. + */ +export const registerHabitCounter = (el: HTMLElement, dbService: DBService) => { + const placeholders = el.querySelectorAll("span.habit-counter-placeholder"); + + placeholders.forEach((placeholderEl) => { //~ Removed index as it wasn't used + if (!(placeholderEl instanceof HTMLElement)) return; + + const habitKey = placeholderEl.dataset.habit; //^ Read 'habit' data attribute + const date = placeholderEl.dataset.date; + const emoji = placeholderEl.dataset.emoji; + const table = placeholderEl.dataset.table; + + // --- Validation --- + if (!habitKey) { + console.warn("[HabitCounter Reg] Placeholder missing data-habit:", placeholderEl); + placeholderEl.textContent = "[Missing habit name]"; + return; + } + if (!table) { + console.warn(`[HabitCounter Reg] Placeholder for habit "${habitKey}" missing data-table:`, placeholderEl); + placeholderEl.textContent = "[Missing table name]"; + return; + } + // --- End Validation --- + + try { + //& console.log(`[HabitCounter Reg] Creating component for habit: ${habitKey}, table: ${table}`); + const component = document.createElement("habit-counter") as HabitCounter; + + //^ Set attributes that the component will read later + component.setAttribute("habit", habitKey); // Set 'habit' attribute + component.setAttribute("table", table); + if (date) component.setAttribute("date", date); + else component.setAttribute("date", "@date"); + if (emoji) component.setAttribute("emoji", emoji); + + //& console.log(`[HabitCounter Reg] Injecting DBService for habit: ${habitKey}`); + //? Inject the DBService dependency - component reads attributes inside this call now + component.setDbService(dbService); + + placeholderEl.replaceWith(component); + //& console.log(`[HabitCounter Reg] Replacement successful for habit: ${habitKey}`); + + } catch (error) { + console.error(`[HabitCounter Reg] Error processing placeholder for habit "${habitKey}":`, error, placeholderEl); + placeholderEl.textContent = "[Error Loading Counter]"; + } + }); +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/services/HabitDataService.ts b/src/webcomponents/HabitCounter/services/HabitDataService.ts new file mode 100644 index 0000000..d710c87 --- /dev/null +++ b/src/webcomponents/HabitCounter/services/HabitDataService.ts @@ -0,0 +1,61 @@ +import { DBService } from "src/dbService"; +import { HabitRecord, HabitDataArgs, UpdateHabitDataArgs } from "../HabitCounter.types"; +import { quoteSqlIdentifier } from "./utils/quoteSqlIdentifier"; +import { validateFetchArgs, validateUpdateArgs } from "./utils/validateHabitArgs"; + +//? Service for database interactions related to habits. +export class HabitDataService { + private dbService: DBService; + + constructor(dbService: DBService) { + if (!dbService) throw new Error("HabitDataService requires a valid DBService instance."); + this.dbService = dbService; + //& console.log("[HabitDataService] Initialized."); + } + + async fetchHabitValue(args: HabitDataArgs): Promise { + //& console.log(`[HabitDataService] fetchHabitValue called with:`, args); + try { + validateFetchArgs(args); //~ Delegate validation + const safeTable = quoteSqlIdentifier(args.table); //~ Delegate quoting + const sql = `SELECT value FROM ${safeTable} WHERE habitKey = ? AND date = ?`; + const params = [args.habitKey, args.date]; + + //& console.log(`[HabitDataService] Executing fetch SQL: ${sql} PARAMS:`, params); + const result = await this.dbService.getQuery(sql, params); + //& console.log(`[HabitDataService] Fetch result:`, result); + + const value = result?.[0]?.value; + return (typeof value === 'number') ? value : 0; //? Default to 0 if no record or invalid value + } catch (error) { + console.error(`[HabitDataService] Error in fetchHabitValue for ${args.habitKey}:`, error); + throw error; //? Propagate error + } + } + + async updateHabitValue(args: UpdateHabitDataArgs): Promise { + //& console.log(`[HabitDataService] updateHabitValue called with:`, args); + try { + validateUpdateArgs(args); //~ Delegate validation + const safeTable = quoteSqlIdentifier(args.table); //~ Delegate quoting + const sql = ` + INSERT INTO ${safeTable} (habitKey, date, value) VALUES (?, ?, ?) + ON CONFLICT(habitKey, date) DO UPDATE SET value = excluded.value; + `; + //! Requires UNIQUE constraint on (habitKey, date) + const params = [args.habitKey, args.date, args.newValue]; + + //& console.log(`[HabitDataService] Executing update SQL: ${sql} PARAMS:`, params); + await this.dbService.runQuery(sql, params); + //& console.log(`[HabitDataService] Update successful.`); + } catch (error) { + console.error(`[HabitDataService] Error in updateHabitValue for ${args.habitKey}:`, error); + //? Log specific guidance for constraint errors + if (error instanceof Error && error.message.includes("ON CONFLICT clause does not match")) { + console.error(`//! DB Constraint Error: Table '${args.table}' needs a UNIQUE index/key on (habitKey, date). Run: CREATE UNIQUE INDEX IF NOT EXISTS idx_habit_date ON ${quoteSqlIdentifier(args.table)} (habitKey, date);`); + throw new Error(`Missing UNIQUE constraint on (habitKey, date) in table ${args.table}. ${error.message}`); + } + throw error; //? Propagate other errors + } + } +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/services/utils/quoteSqlIdentifier.ts b/src/webcomponents/HabitCounter/services/utils/quoteSqlIdentifier.ts new file mode 100644 index 0000000..e6a9027 --- /dev/null +++ b/src/webcomponents/HabitCounter/services/utils/quoteSqlIdentifier.ts @@ -0,0 +1,16 @@ +/** + * Safely quotes an identifier (table or column name) for use in SQL queries. + * Prevents basic SQL injection via identifiers and handles reserved words. + * Only allows alphanumeric characters and underscores. Wraps in double quotes. + * @param identifier - The identifier string to quote. + * @returns The quoted identifier string. + * @throws Error if the identifier contains invalid characters. + */ +export function quoteSqlIdentifier(identifier: string): string { + //? Basic security check for valid characters + if (!/^[a-zA-Z0-9_]+$/.test(identifier)) { + throw new Error(`Invalid identifier used: "${identifier}". Only alphanumeric and underscores allowed.`); + } + //? SQLite standard quoting is double quotes + return `"${identifier}"`; +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/services/utils/validateHabitArgs.ts b/src/webcomponents/HabitCounter/services/utils/validateHabitArgs.ts new file mode 100644 index 0000000..5520993 --- /dev/null +++ b/src/webcomponents/HabitCounter/services/utils/validateHabitArgs.ts @@ -0,0 +1,15 @@ +import { HabitDataArgs, UpdateHabitDataArgs } from "../../HabitCounter.types"; + +/** Validates arguments required for fetching habit data. */ +export function validateFetchArgs(args: HabitDataArgs): void { + if (!args.table || !args.habitKey || !args.date) { + throw new Error(`Missing required arguments for fetch: table='${args.table}', habitKey='${args.habitKey}', date='${args.date}'`); + } +} + +/** Validates arguments required for updating habit data. */ +export function validateUpdateArgs(args: UpdateHabitDataArgs): void { + if (!args.table || !args.habitKey || !args.date || typeof args.newValue !== 'number' || args.newValue < 0) { + throw new Error(`Invalid or missing arguments for update: table='${args.table}', habitKey='${args.habitKey}', date='${args.date}', newValue='${args.newValue}'`); + } +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/styles/applyHabitCounterStyles.ts b/src/webcomponents/HabitCounter/styles/applyHabitCounterStyles.ts new file mode 100644 index 0000000..4ad358f --- /dev/null +++ b/src/webcomponents/HabitCounter/styles/applyHabitCounterStyles.ts @@ -0,0 +1,13 @@ +import { getHabitCounterStyles } from "./getHabitCounterStyles"; + +/** Creates a style element and appends it to the shadow root. */ +export function applyHabitCounterStyles(shadowRoot: ShadowRoot): void { + //& console.log("[HabitCounterStyles] applyHabitCounterStyles called."); + if (!shadowRoot) { + console.error("[HabitCounterStyles] Cannot apply styles: ShadowRoot is null."); + return; + } + const style = document.createElement("style"); + style.textContent = getHabitCounterStyles(); + shadowRoot.appendChild(style); +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/styles/getHabitCounterStyles.ts b/src/webcomponents/HabitCounter/styles/getHabitCounterStyles.ts new file mode 100644 index 0000000..d002fb0 --- /dev/null +++ b/src/webcomponents/HabitCounter/styles/getHabitCounterStyles.ts @@ -0,0 +1,30 @@ +/** Returns the CSS string for the component's Shadow DOM. */ +export function getHabitCounterStyles(): string { + //& console.log("[HabitCounterStyles] getHabitCounterStyles called."); + + return ` + :host { display: inline-flex; vertical-align: middle; } + .habit-wrapper { + display: flex; align-items: center; gap: 0.5em; + font-family: var(--font-text); padding: 2px 5px; + border: 1px solid transparent; border-radius: 4px; + transition: border-color 0.2s ease-in-out, background-color 0.2s ease-in-out; + } + .habit-label { white-space: nowrap; } + button { + background-color: var(--background-secondary); color: var(--text-normal); + border: 1px solid var(--background-modifier-border); padding: 1px 7px; + font-size: var(--font-ui-small); cursor: pointer; font-weight: bold; + border-radius: 4px; line-height: 1.2; min-width: 20px; + } + button:hover { + background-color: var(--background-modifier-hover); border-color: var(--interactive-accent-hover); + color: var(--text-accent-hover); + } + button:active { background-color: var(--background-modifier-active); color: var(--text-accent); } + .habit-value { + min-width: 2ch; text-align: center; font-weight: bold; + padding: 0 2px; color: var(--text-normal); + } + `; +} \ No newline at end of file diff --git a/src/webcomponents/habitCounter.ts b/src/webcomponents/habitCounter.ts deleted file mode 100644 index e9fa041..0000000 --- a/src/webcomponents/habitCounter.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { DBService } from "../dbService"; - -export class HabitCounter extends HTMLElement { - private habit: string; - private date: string; - private count = 0; - - constructor() { - super(); - const shadow = this.attachShadow({ mode: "open" }); - - this.habit = this.getAttribute("habit") ?? ""; - this.date = this.getAttribute("date") ?? new Date().toISOString().split("T")[0]; - - const wrapper = document.createElement("div"); - wrapper.className = "habit-wrapper"; - - const label = document.createElement("span"); - label.className = "habit-label"; - label.textContent = `🚬 ${this.habit}:`; - - const minus = document.createElement("button"); - minus.textContent = "−"; - minus.onclick = () => this.updateValue(-1); - - const plus = document.createElement("button"); - plus.textContent = "+"; - plus.onclick = () => this.updateValue(1); - - const valueDisplay = document.createElement("span"); - valueDisplay.className = "habit-value"; - valueDisplay.textContent = String(this.count); - - wrapper.append(label, minus, valueDisplay, plus); - shadow.append(wrapper); - - this.loadValue().then(value => { - this.count = value; - valueDisplay.textContent = String(this.count); - }); - - const style = document.createElement("style"); - style.textContent = ` - .habit-wrapper { - display: flex; - align-items: center; - gap: 0.5em; - font-family: var(--font-text); - } - button { - background: var(--background-secondary); - border: none; - padding: 2px 8px; - cursor: pointer; - font-weight: bold; - border-radius: 4px; - } - button:hover { - background: var(--interactive-accent-hover); - color: var(--text-on-accent); - } - .habit-value { - min-width: 2ch; - text-align: center; - } - `; - shadow.appendChild(style); - } - - private async loadValue(): Promise { - // ⚠️ Replace this with actual DBService call - // For now, just simulate with 5 - return 5; - } - - private async updateValue(delta: number) { - this.count = Math.max(0, this.count + delta); - // this.shadowRoot?.querySelector(".habit-value")!.textContent = String(this.count); - - // ⚠️ Write to DB here, for now just console.log - console.log(`[HabitCounter] Updated ${this.habit} on ${this.date} to`, this.count); - } -} - -customElements.define("habit-counter", HabitCounter); diff --git a/src/webcomponents/ji b/src/webcomponents/ji new file mode 100644 index 0000000..e69de29