diff --git a/main.ts b/main.ts index 67c88e7..a79cd83 100644 --- a/main.ts +++ b/main.ts @@ -11,7 +11,7 @@ import { SQLiteDBSettingTab } from "./src/settingTab"; import { inspectTableStructure, convertEntriesInNotes } from "./src/commands"; import { processSqlBlock, processSqlChartBlock, DateNavigatorRenderer } from "./src/codeblocks"; import { pickTableName } from "./src/helpers"; -import { SQLiteDBSettings, DEFAULT_SETTINGS } from "./src/types"; +import { replacePlaceholders } from "src/helpers/replacePlaceholders"; import { injectDatePickerStyles } from "src/styles/datePickerInject"; import { injectDateNavigatorStyles, removeDateNavigatorStyles } from './src/styles/dateNavigationInject'; @@ -20,6 +20,9 @@ import { registerHabitCounter } from "./src/webcomponents/HabitCounter/registerH import { registerBooleanSwitch } from "src/webcomponents/BooleanSwitch/registerBooleanSwitch"; import { registerTextInput } from "src/webcomponents/TextInput/registerTextInput"; +import { SQLiteDBSettings, DEFAULT_SETTINGS } from "./src/types"; +import { pluginState } from "src/pluginState"; + export default class SQLiteDBPlugin extends Plugin { settings: SQLiteDBSettings; private dbService: DBService; @@ -30,6 +33,8 @@ export default class SQLiteDBPlugin extends Plugin { this.dbService = new DBService(this.app); await this.openDatabase(); + pluginState.initialize(this.app); + injectDatePickerStyles(); injectDateNavigatorStyles(); @@ -40,6 +45,32 @@ export default class SQLiteDBPlugin extends Plugin { registerTextInput(el, this.app, 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", @@ -64,29 +95,6 @@ export default class SQLiteDBPlugin extends Plugin { }, }); - //? Codeblocks - this.registerMarkdownCodeBlockProcessor( - "sql", - async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { - await processSqlBlock(this.dbService, source, el); - } - ); - - this.registerMarkdownCodeBlockProcessor( - "sql-chart", - async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { - await processSqlChartBlock(this.dbService, source, el); - } - ); - - this.registerMarkdownCodeBlockProcessor( - "date-header", - (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { - ctx.addChild(new DateNavigatorRenderer(el, this.app, source)); - - } - ); - this.addSettingTab(new SQLiteDBSettingTab(this.app, this)); } 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 7ce6f11..cbca740 100644 --- a/src/codeblocks/processSqlBlock/index.ts +++ b/src/codeblocks/processSqlBlock/index.ts @@ -1,40 +1,30 @@ import { Notice } from "obsidian"; import { DBService } from "../../DBService"; import { parseSqlParams, validateTable, buildSqlQuery, renderResults } from "./helpers"; -import { pluginState } from "../../pluginState"; 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); - // replace @date with the selected date - const injectedSource = source.replace(/@date/g, pluginState.selectedDate); - const params = parseSqlParams(injectedSource); - 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) { @@ -44,41 +34,59 @@ 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); + // Render results if found + renderResults(resultsToRender, el); } catch (error) { - el.createEl("p", { text: "An error occurred while processing the SQL block." }); + el.empty(); // Clear before showing error + el.createEl("p", { text: "An error occurred 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); + 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/processSqlChartBlock/index.ts b/src/codeblocks/processSqlChartBlock/index.ts index b6d7651..d45d0f0 100644 --- a/src/codeblocks/processSqlChartBlock/index.ts +++ b/src/codeblocks/processSqlChartBlock/index.ts @@ -1,23 +1,13 @@ import { DBService } from "../../DBService"; import { parseChartParams, validateColumns, buildSqlQuery, processChartData } from "./helpers"; import { ChartType, createChart } from "./charts"; -import { pluginState, DATE_CHANGED_EVENT_NAME } from "../../pluginState"; import { Notice } from "obsidian"; export async function processSqlChartBlock(dbService: DBService, source: string, el: HTMLElement) { await renderChartBlock(); - const onDateChange = async (e: CustomEvent) => { - el.empty(); // Clear existing chart or messages - await renderChartBlock(); - }; - - document.addEventListener(DATE_CHANGED_EVENT_NAME, onDateChange as EventListener); - async function renderChartBlock() { - // replace @date with the selected date - const injectedSource = source.replace(/@date/g, pluginState.selectedDate); - const config = parseChartParams(injectedSource); + const config = parseChartParams(source); if (!config) { // @ts-ignore diff --git a/src/helpers/datePeriodUtils.ts b/src/helpers/datePeriodUtils.ts index dbfe86a..b8061b0 100644 --- a/src/helpers/datePeriodUtils.ts +++ b/src/helpers/datePeriodUtils.ts @@ -133,4 +133,52 @@ export function formatPeriodForDisplay(isoDate: string, period: NavigationPeriod default: return formatDateForDisplay(dateObj); } +} + +/** Calculates the ISO Week string (YYYY-Www). */ +export function getIsoWeekId(date: Date): string { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const weekNum = getISOWeekNumber(date); + + if (weekNum === 1 && month === 11) return `${year + 1}-W01`; + if (weekNum >= 52 && month === 0) return `${year - 1}-W${String(weekNum).padStart(2, '0')}`; + return `${year}-W${String(weekNum).padStart(2, '0')}`; +} + +/** Calculates the Quarter string (YYYY-Qq). */ +export function getQuarterId(date: Date): string { + const year = date.getUTCFullYear(); + const quarter = Math.floor(date.getUTCMonth() / 3) + 1; + return `${year}-Q${quarter}`; +} + +/** Calculates the Month string (YYYY-MM). */ +export function getMonthId(date: Date): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + return `${year}-${month}`; +} + +/** + * Calculates the specific period identifier string based on the date and period type. + * e.g., '2024-W15', '2024-04', '2024-Q2', '2024', '2024-04-15' +*/ +export function getPeriodId(isoDate: string, period: NavigationPeriod): string { + const dateObj = parseDateISO(isoDate); + if (!dateObj) { + console.warn(`[getPeriodId] Invalid date: ${isoDate}, returning date string.`); + return isoDate; // Fallback + } + + switch (period) { + case 'day': return isoDate; // Returns 'YYYY-MM-DD' + case 'week': return getIsoWeekId(dateObj); // Returns 'YYYY-Www' + case 'month': return getMonthId(dateObj); // Returns 'YYYY-MM' + case 'quarter': return getQuarterId(dateObj); // Returns 'YYYY-Qq' + case 'year': return String(dateObj.getUTCFullYear()); // Returns 'YYYY' + default: + console.warn(`[getPeriodId] Unknown period type: ${period}, returning ISO date.`); + return isoDate; // Fallback + } } \ No newline at end of file diff --git a/src/helpers/replacePlaceholders.ts b/src/helpers/replacePlaceholders.ts new file mode 100644 index 0000000..109d884 --- /dev/null +++ b/src/helpers/replacePlaceholders.ts @@ -0,0 +1,40 @@ +import { pluginState } from "src/pluginState"; +import { getPeriodId } from "./datePeriodUtils"; +import { parseDateISO, getISOWeekNumber } from "./dateUtils"; + +//? Helper function to replace all date/period placeholders +export const replacePlaceholders = (source: string): string => { + let processedSource = source; + const selectedDateStr = pluginState.selectedDate; + const selectedPeriod = pluginState.currentPeriod; // The *type* ('day', 'week'...) + const startDate = pluginState.periodStartDate; // 'YYYY-MM-DD' + const endDate = pluginState.periodEndDate; // 'YYYY-MM-DD' + + //^ Calculate the specific Period ID String ('YYYY-Www', 'YYYY-MM', etc.) + const periodId = getPeriodId(selectedDateStr, selectedPeriod); + + const selectedDateObj = parseDateISO(selectedDateStr); + + // --- Perform Replacements --- + processedSource = processedSource.replace(/@startDate/g, startDate); + processedSource = processedSource.replace(/@endDate/g, endDate); + //? Replace @periodId with the calculated identifier string + processedSource = processedSource.replace(/@periodId/g, periodId); + //? Keep @date for the specific selected day + processedSource = processedSource.replace(/@date/g, selectedDateStr); + + //? Replace individual components if needed + if (selectedDateObj) { + processedSource = processedSource.replace(/@year/g, String(selectedDateObj.getUTCFullYear())); + processedSource = processedSource.replace(/@quarter/g, String(Math.floor(selectedDateObj.getUTCMonth() / 3) + 1)); + processedSource = processedSource.replace(/@month/g, String(selectedDateObj.getUTCMonth() + 1).padStart(2, '0')); + // Use the week part from the period ID for consistency + processedSource = processedSource.replace(/@week/g, periodId.startsWith(selectedDateObj.getUTCFullYear() + "-W") ? periodId.split('-')[1] : 'W' + String(getISOWeekNumber(selectedDateObj)).padStart(2,'0')); // Extracts Www + processedSource = processedSource.replace(/@day/g, String(selectedDateObj.getUTCDate()).padStart(2, '0')); + } else { + // Clear date component placeholders if date is invalid + processedSource = processedSource.replace(/@year|@quarter|@month|@week|@day/g, ''); + } + + return processedSource; +}; \ No newline at end of file diff --git a/src/pluginState.ts b/src/pluginState.ts index ef079aa..9839b10 100644 --- a/src/pluginState.ts +++ b/src/pluginState.ts @@ -1,18 +1,27 @@ +import { App, MarkdownView } from "obsidian"; import { NavigationPeriod } from "./codeblocks/dateHeader/dateNavigator.types"; import { calculatePeriodRange } from "./helpers/datePeriodUtils"; export const DATE_CHANGED_EVENT_NAME = 'plugin-date-changed'; export class PluginState { + private app: App | null = null; + private _selectedDate: string; // YYYY-MM-DD (Represents a specific day within the current period) private _currentPeriod: NavigationPeriod; private _periodStartDate: string; // YYYY-MM-DD private _periodEndDate: string; // YYYY-MM-DD - constructor(initialDate?: string, initialPeriod: NavigationPeriod = 'day') { - // Initialize state, calculating initial range + public initialize(app: App, initialDate?: string, initialPeriod: NavigationPeriod = 'day'): void { + this.app = app; this._currentPeriod = initialPeriod; - this.selectedDate = initialDate ?? new Date().toISOString().split("T")[0]; // Trigger setter logic + // Call setter to perform initial calculation and dispatch (if needed) + this.selectedDate = initialDate ?? new Date().toISOString().split("T")[0]; + // Ensure initial range is set if date wasn't changed by setter + if (!this._periodStartDate) { + this._recalculatePeriodRange(); + } + console.log("[PluginState] Initialized."); } // --- Getters --- @@ -34,7 +43,7 @@ export class PluginState { if (this._selectedDate !== newIsoDate) { this._selectedDate = newIsoDate; this._recalculatePeriodRange(); // Recalculate start/end based on new selected date and current period - this._dispatchDateChangeEvent(); + this._dispatchDateChangeEventAndRefreshViews(); } } @@ -43,12 +52,10 @@ export class PluginState { if (this._currentPeriod !== newPeriod) { this._currentPeriod = newPeriod; this._recalculatePeriodRange(); // Recalculate start/end based on the new period and current selected date - this._dispatchDateChangeEvent(); // Also notify listeners of period change implicitly via date change event + this._dispatchDateChangeEventAndRefreshViews(); // Also notify listeners of period change implicitly via date change event } } - // --- Internal Methods --- - /** Recalculates start and end dates based on current _selectedDate and _currentPeriod */ private _recalculatePeriodRange(): void { const range = calculatePeriodRange(this._selectedDate, this._currentPeriod); @@ -65,15 +72,26 @@ export class PluginState { } /** Dispatches the custom event to notify listeners of changes */ - private _dispatchDateChangeEvent(): void { + private _dispatchDateChangeEventAndRefreshViews(): void { document.dispatchEvent(new CustomEvent(DATE_CHANGED_EVENT_NAME, { - detail: { - newIsoDate: this._selectedDate, // The specific selected day - newPeriod: this._currentPeriod, // The current period type - newStartDate: this._periodStartDate, // Start of the period - newEndDate: this._periodEndDate // End of the period - } + detail: { /* ... date/period details ... */ } })); + + if (this.app) { + this.app.workspace.getLeavesOfType('markdown').forEach(leaf => { + if (leaf.view instanceof MarkdownView) { + const view = leaf.view; + //? Force re-render of the preview mode. + //? This should cause code blocks to be re-processed. + view.previewMode?.rerender(true); + //? For Live Preview, forcing re-render is more complex and might + //? require interacting with the CodeMirror view state if the simple + //? previewMode rerender isn't sufficient. Start with this. + } + }); + } else { + console.warn("[PluginState] Cannot refresh views: App instance not available."); + } } } diff --git a/src/webcomponents/HabitCounter/registerHabitCounter.ts b/src/webcomponents/HabitCounter/registerHabitCounter.ts index 5f0e546..82e2863 100644 --- a/src/webcomponents/HabitCounter/registerHabitCounter.ts +++ b/src/webcomponents/HabitCounter/registerHabitCounter.ts @@ -17,7 +17,7 @@ export const registerHabitCounter = (el: HTMLElement, dbService: DBService) => { 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 habitKey = placeholderEl.dataset.habit; const date = placeholderEl.dataset.date; const emoji = placeholderEl.dataset.emoji; const table = placeholderEl.dataset.table;