new plugin state to switch placeholders

This commit is contained in:
stfrigerio 2025-04-09 14:55:27 +02:00
parent bddc9800a8
commit cbf9d4f81d
8 changed files with 271 additions and 124 deletions

56
main.ts
View file

@ -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));
}

View file

@ -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<ValidationError | null> {
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;
}

View file

@ -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 */ }
}
}

View file

@ -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

View file

@ -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
}
}

View file

@ -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;
};

View file

@ -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.");
}
}
}

View file

@ -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;