Compare commits

...

38 commits

Author SHA1 Message Date
stfrigerio
b67a99dad2 1.0.3 2025-11-05 14:04:41 +01:00
stfrigerio
21a5565f1f fixed local db issues 2025-11-05 14:04:22 +01:00
stfrigerio
2e9f605cfb hotfix 2025-05-09 17:31:50 +02:00
stfrigerio
c209aef215 1.0.2 2025-05-09 17:28:07 +02:00
Stefano Frigerio
c6663b61b0
Merge pull request #2 from stfrigerio/daily-notes-reader
Daily notes reader
2025-05-09 17:25:48 +02:00
Stefano Frigerio
c44f52bce2
Merge branch 'master' into daily-notes-reader 2025-05-09 17:25:41 +02:00
stfrigerio
0b17f4f3c2 updated readme 2025-05-09 17:23:41 +02:00
stfrigerio
9ab65793df new settings 2025-04-18 19:24:04 +02:00
stfrigerio
add626d956 added sql render webcomponent 2025-04-17 15:02:34 +02:00
stfrigerio
148c874f02 new add text modal 2025-04-16 19:26:47 +02:00
stfrigerio
df2f429049 journal name fix 2025-04-13 15:10:44 +02:00
stfrigerio
0b0fb67f36 added journals 2025-04-13 14:57:11 +02:00
stfrigerio
49af0fb0a2 added mood notes button 2025-04-13 14:31:35 +02:00
stfrigerio
08ab87653d tabled results 2025-04-13 13:51:38 +02:00
stfrigerio
96b6832e25 fixed same day calculations 2025-04-13 13:11:45 +02:00
stfrigerio
84ad5370fb wrapper for graphs 2025-04-13 12:41:56 +02:00
stfrigerio
6d6759bda1 timestamp button 2025-04-10 17:37:14 +02:00
stfrigerio
cbf9d4f81d new plugin state to switch placeholders 2025-04-09 14:55:27 +02:00
stfrigerio
bddc9800a8 update date header to be period agnostic 2025-04-09 12:15:31 +02:00
stfrigerio
48cb74f09a fixed button text input and time picker 2025-04-08 16:59:35 +02:00
stfrigerio
6550a6570e pfft 2025-04-07 21:06:51 +02:00
stfrigerio
d8cc77f863 created text input 2025-04-07 18:11:01 +02:00
stfrigerio
6ca1207333 adapted sqlchart block to remote db 2025-04-07 10:30:29 +02:00
stfrigerio
466b239153 added updatedAt 2025-04-06 18:06:55 +02:00
stfrigerio
3de4c0be71 adapted to remote logic 2025-04-06 17:28:15 +02:00
stfrigerio
fadd4061e1 workin for a remote db 2025-04-06 17:06:42 +02:00
stfrigerio
3cb1f783af pushino 2025-04-06 16:54:40 +02:00
stfrigerio
e892ce9d9c smol cleanup 2025-04-05 17:56:01 +02:00
stfrigerio
ad87e0e851 added boolean switch 2025-04-05 17:55:00 +02:00
stfrigerio
9c1020b8d3 date navigator 2025-04-05 16:50:18 +02:00
stfrigerio
cba7bda90a upsert with uuid or fail for schema 2025-04-05 14:52:34 +02:00
stfrigerio
0d1bf53ea8 abstracted the column logic 2025-04-05 14:16:39 +02:00
stfrigerio
6ced6f6cca fixed emoji 2025-04-05 14:01:01 +02:00
stfrigerio
944afbd61b creating web components for habit counter 2025-04-05 13:56:27 +02:00
stfrigerio
90f087ffe3 testing webcomponets 2025-04-04 18:35:26 +02:00
stfrigerio
a760527acf fixed wrong selected date 2025-04-04 18:30:39 +02:00
stfrigerio
f97f55ff67 date selector modal and date parsing in blocks 2025-04-04 18:09:53 +02:00
Stefano Frigerio
1700e84f15
Update README.md 2025-03-18 18:49:37 +01:00
109 changed files with 6279 additions and 552 deletions

2
.gitignore vendored
View file

@ -20,3 +20,5 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
.env

148
README.md
View file

@ -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**.
@ -27,20 +27,36 @@ The **SQLite DB Plugin for Obsidian** allows you to interact with SQLite databas
Search for **SQLite DB Plugin**, then click **Install**.
3. **Enable the Plugin**
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)
---
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.
## Configuration
🔀 Database Mode
Choose whether the plugin interacts with a local SQLite file or a remote API server.
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`
📁 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:
@ -65,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 |
@ -93,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 |
| --------------- | ------------------------------------------------------------------- | --------------------------------- |
@ -107,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
@ -134,7 +146,6 @@ chartOptions: {
```
### Bar Chart
```sql-chart
table: QuantifiableHabits
@ -157,8 +168,6 @@ chartOptions: {
}
```
### Pie Chart
```sql-chart
table: Money
@ -175,4 +184,79 @@ chartOptions: {
showLegend: true,
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 <boolean-switch> 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.
```
<span class="boolean-switch-placeholder"
data-habit="Meditate" <!-- the name of the habit in the db -->
data-emoji="🧘" <!-- the emoji (only for display) -->
data-date="2025-05-09" <!-- the date associated with the specific row -->
data-table="BooleanHabits" <!-- the name of the table -->
data-habit-id-col="habitKey" <!-- the name of the column where the habit names are stored -->
data-value-col="value" <!-- the name of the column where the values are stored -->
data-date-col="date"> <!-- the name of the column that holds the date -->
</span>
```
![Boolean Switch](./assets/boolean.png)
#### Habit Counter
Use the <habit-counter> component to track numerical habits, such as how many coffees you had or how many cigarettes you smoked today.
```
<span class="habit-counter-placeholder"
data-habit="Cigarettes" <!-- the name of the habit in the db -->
data-emoji="🚬" <!-- optional emoji for display -->
data-date="2025-05-09" <!-- the date associated with the specific row -->
data-table="QuantifiableHabits" <!-- the name of the table -->
data-habit-id-col="habitKey" <!-- the column where the habit keys are stored -->
data-value-col="value" <!-- the column for storing numerical values -->
data-date-col="date"> <!-- the column for storing dates -->
</span>
```
![Habit Counter](./assets/habits.png)
#### Text Input
Use the <text-input> component to display and optionally update text fields in your database.
```
<span class="text-input-placeholder"
data-table="DailyNotes" <!-- Table where the value is stored -->
data-date="@date" <!-- The date for the entry -->
data-value-col="dayRating" <!-- Column to store the input -->
data-date-col="date" <!-- Column storing the date -->
placeholder="How do u feel today?" <!-- Placeholder text when empty -->
data-label="Day Rating 📈" <!-- Label shown next to the input -->
/>
```
It supports both inline editing and a "button-triggered" modal input, making it versatile for journal prompts, mood logs, notes, or custom fields.
```
<span
class="text-input-placeholder"
data-table="DailyNotes"
data-value-col="sleepTime"
data-date-col="date"
data-date="@date"
data-is-button="true" <!-- Makes it a button -->
data-modal-type="time-picker" <!-- On click opens a modal, time-picker or date-picker -->
placeholder="Select Time"
data-label="Sleep Time 💤"
/>
```
![Text Input](./assets/text-input.png)

BIN
assets/boolean.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

BIN
assets/habits.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
assets/text-input.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

159
main.ts
View file

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

View file

@ -1,7 +1,7 @@
{
"id": "sqlite-db",
"name": "SQLite DB",
"version": "1.0.1",
"version": "1.0.3",
"minAppVersion": "0.15.0",
"description": "Interact with local SQLite files in your notes",
"author": "Stefano Frigerio",

162
package-lock.json generated
View file

@ -1,14 +1,15 @@
{
"name": "sqlitedb",
"version": "1.0.1",
"version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sqlitedb",
"version": "1.0.1",
"version": "1.0.3",
"license": "MIT",
"dependencies": {
"chart.js": "^4.5.1",
"sql.js": "^1.12.0"
},
"devDependencies": {
@ -403,7 +404,6 @@
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
"integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==",
"dev": true,
"peer": true,
"dependencies": {
"eslint-visitor-keys": "^3.4.3"
},
@ -422,7 +422,6 @@
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
"integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
"dev": true,
"peer": true,
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
@ -432,7 +431,6 @@
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
"dev": true,
"peer": true,
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
@ -456,7 +454,6 @@
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
"dev": true,
"peer": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
@ -467,7 +464,6 @@
"integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
"deprecated": "Use @eslint/config-array instead",
"dev": true,
"peer": true,
"dependencies": {
"@humanwhocodes/object-schema": "^2.0.3",
"debug": "^4.3.1",
@ -482,7 +478,6 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"dev": true,
"peer": true,
"engines": {
"node": ">=12.22"
},
@ -496,15 +491,19 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"deprecated": "Use @eslint/object-schema instead",
"dev": true,
"peer": true
"dev": true
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
@ -640,6 +639,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.29.0.tgz",
"integrity": "sha512-ruKWTv+x0OOxbzIw9nW5oWlUopvP/IQDjB5ZqmTglLIoDTctLlAJpAQFpNPJP/ZI7hTT9sARBosEfaKbcFuECw==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.29.0",
"@typescript-eslint/types": "5.29.0",
@ -790,8 +790,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/acorn": {
"version": "8.14.0",
@ -811,7 +810,6 @@
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
"peer": true,
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
@ -821,7 +819,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -838,7 +835,6 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
@ -848,7 +844,6 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"peer": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@ -863,8 +858,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/array-union": {
"version": "2.1.0",
@ -879,15 +873,13 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"peer": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -922,7 +914,6 @@
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=6"
}
@ -932,7 +923,6 @@
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@ -944,12 +934,23 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"peer": true,
"dependencies": {
"color-name": "~1.1.4"
},
@ -961,22 +962,19 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"peer": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@ -1007,8 +1005,7 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/dir-glob": {
"version": "3.0.1",
@ -1027,7 +1024,6 @@
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"dev": true,
"peer": true,
"dependencies": {
"esutils": "^2.0.2"
},
@ -1077,7 +1073,6 @@
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"peer": true,
"engines": {
"node": ">=10"
},
@ -1199,7 +1194,6 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
"dev": true,
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
@ -1216,7 +1210,6 @@
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"peer": true,
"engines": {
"node": ">=4.0"
}
@ -1226,7 +1219,6 @@
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"dev": true,
"peer": true,
"dependencies": {
"acorn": "^8.9.0",
"acorn-jsx": "^5.3.2",
@ -1244,7 +1236,6 @@
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
"dev": true,
"peer": true,
"dependencies": {
"estraverse": "^5.1.0"
},
@ -1257,7 +1248,6 @@
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"peer": true,
"engines": {
"node": ">=4.0"
}
@ -1297,7 +1287,6 @@
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -1306,8 +1295,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/fast-glob": {
"version": "3.3.3",
@ -1341,15 +1329,13 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/fastq": {
"version": "1.18.0",
@ -1365,7 +1351,6 @@
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
"dev": true,
"peer": true,
"dependencies": {
"flat-cache": "^3.0.4"
},
@ -1390,7 +1375,6 @@
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"peer": true,
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@ -1407,7 +1391,6 @@
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
"dev": true,
"peer": true,
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.3",
@ -1421,15 +1404,13 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz",
"integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/functional-red-black-tree": {
"version": "1.0.1",
@ -1443,7 +1424,6 @@
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"peer": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@ -1464,7 +1444,6 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"peer": true,
"dependencies": {
"is-glob": "^4.0.3"
},
@ -1477,7 +1456,6 @@
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"dev": true,
"peer": true,
"dependencies": {
"type-fest": "^0.20.2"
},
@ -1512,15 +1490,13 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
@ -1539,7 +1515,6 @@
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
"dev": true,
"peer": true,
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
@ -1556,7 +1531,6 @@
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true,
"peer": true,
"engines": {
"node": ">=0.8.19"
}
@ -1567,7 +1541,6 @@
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"peer": true,
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
@ -1577,8 +1550,7 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/is-extglob": {
"version": "2.1.1",
@ -1615,7 +1587,6 @@
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
@ -1624,15 +1595,13 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"peer": true,
"dependencies": {
"argparse": "^2.0.1"
},
@ -1644,29 +1613,25 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"peer": true,
"dependencies": {
"json-buffer": "3.0.1"
}
@ -1676,7 +1641,6 @@
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"dev": true,
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
@ -1690,7 +1654,6 @@
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"peer": true,
"dependencies": {
"p-locate": "^5.0.0"
},
@ -1705,8 +1668,7 @@
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/merge2": {
"version": "1.4.1",
@ -1735,7 +1697,6 @@
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"peer": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@ -1762,8 +1723,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/obsidian": {
"version": "1.7.2",
@ -1784,7 +1744,6 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"peer": true,
"dependencies": {
"wrappy": "1"
}
@ -1794,7 +1753,6 @@
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dev": true,
"peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@ -1812,7 +1770,6 @@
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"peer": true,
"dependencies": {
"yocto-queue": "^0.1.0"
},
@ -1828,7 +1785,6 @@
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"peer": true,
"dependencies": {
"p-limit": "^3.0.2"
},
@ -1844,7 +1800,6 @@
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
"peer": true,
"dependencies": {
"callsites": "^3.0.0"
},
@ -1857,7 +1812,6 @@
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
@ -1867,7 +1821,6 @@
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -1877,7 +1830,6 @@
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
@ -1908,7 +1860,6 @@
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true,
"peer": true,
"engines": {
"node": ">= 0.8.0"
}
@ -1918,7 +1869,6 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"peer": true,
"engines": {
"node": ">=6"
}
@ -1960,7 +1910,6 @@
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"peer": true,
"engines": {
"node": ">=4"
}
@ -1981,7 +1930,6 @@
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@ -2032,7 +1980,6 @@
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"peer": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@ -2045,7 +1992,6 @@
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
@ -2069,7 +2015,6 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@ -2082,7 +2027,6 @@
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
},
@ -2094,15 +2038,13 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@ -2114,8 +2056,7 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/to-regex-range": {
"version": "5.0.1",
@ -2161,7 +2102,6 @@
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"dev": true,
"peer": true,
"dependencies": {
"prelude-ls": "^1.2.1"
},
@ -2174,7 +2114,6 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=10"
},
@ -2187,6 +2126,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz",
"integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -2200,7 +2140,6 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"peer": true,
"dependencies": {
"punycode": "^2.1.0"
}
@ -2209,15 +2148,13 @@
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"peer": true,
"dependencies": {
"isexe": "^2.0.0"
},
@ -2233,7 +2170,6 @@
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -2242,15 +2178,13 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=10"
},

View file

@ -1,6 +1,6 @@
{
"name": "sqlitedb",
"version": "1.0.1",
"version": "1.0.3",
"description": "A plugin that allows to interact with sqlite databases in Obsidian",
"main": "main.js",
"scripts": {
@ -24,6 +24,7 @@
"typescript": "4.7.4"
},
"dependencies": {
"chart.js": "^4.5.1",
"sql.js": "^1.12.0"
}
}

216
src/DBService.ts Normal file
View file

@ -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<boolean> { // 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<T extends Record<string, any>>(sql: string, params: any[] = []): Promise<T[]> {
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<void> {
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<void> {
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;
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { processSqlBlock } from "./processSqlBlock";
import { processSqlChartBlock } from "./processSqlChartBlock";
import { DateNavigatorRenderer } from "./dateHeader/dateNavigatorRenderer";
export { processSqlBlock, processSqlChartBlock };
export { processSqlBlock, processSqlChartBlock, DateNavigatorRenderer };

View file

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

View file

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

View file

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

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

View file

@ -11,6 +11,7 @@ export interface SqlParams {
orderBy?: string;
orderDirection?: string;
limit?: number;
displayFormat?: string;
}
export interface ValidationError {

View file

@ -66,6 +66,7 @@ export function createBarChart(labels: string[], datasets: any[], options: BarCh
legend: {
display: chartOptions.showLegend,
position: 'top' as const,
align: 'center' as const,
labels: {
usePointStyle: false,
padding: 15,

View file

@ -62,6 +62,7 @@ export function createLineChart(labels: string[], datasets: any[], options: Line
legend: {
display: chartOptions.showLegend,
position: 'top' as const,
align: 'center' as const,
labels: {
usePointStyle: true,
padding: 15,

View file

@ -62,6 +62,17 @@ export function createPieChart(labels: string[], datasets: any[], options: PieCh
display: chartOptions.showLegend,
position: 'right' as const,
align: 'center' as const,
onClick: (e: any, legendItem: any, legend: any) => {
const index = legendItem.index;
const chart = legend.chart;
const meta = chart.getDatasetMeta(0);
// Toggle the data visibility
meta.data[index].hidden = !meta.data[index].hidden;
// Update the chart
chart.update();
},
labels: {
padding: 20,
boxWidth: 15,
@ -71,14 +82,24 @@ export function createPieChart(labels: string[], datasets: any[], options: PieCh
},
generateLabels: (chart: any) => {
const data = chart.data;
return data.labels.map((label: string, index: number) => ({
text: `${label}: ${data.datasets[0].data[index]}`,
fillStyle: data.datasets[0].backgroundColor[index],
strokeStyle: data.datasets[0].borderColor[index],
lineWidth: 1,
hidden: isNaN(data.datasets[0].data[index]),
index
}));
if (data.labels.length && data.datasets.length) {
return data.labels.map((label: string, index: number) => {
const meta = chart.getDatasetMeta(0);
const hidden = meta.data[index] ? meta.data[index].hidden : false;
return {
text: `${label}: ${data.datasets[0].data[index]}`,
fillStyle: hidden ? 'rgba(128, 128, 128, 0.3)' : data.datasets[0].backgroundColor[index],
strokeStyle: hidden ? 'rgba(128, 128, 128, 0.3)' : data.datasets[0].borderColor[index],
fontColor: hidden ? 'rgba(128, 128, 128, 0.5)' : undefined,
lineWidth: 1,
hidden: hidden,
index: index,
datasetIndex: 0
};
});
}
return [];
}
}
},

View file

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

View file

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

View file

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

View file

@ -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";
import { renderChart } from "./renderChart";
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({ columns, values }, config));
}
const chartData = createChart(
config.chartType as ChartType,
labels,
datasets,
config.chartOptions
);
const wrapper = el.createDiv({ cls: 'obsidian-sql-chart' });
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);
}
}

View file

@ -0,0 +1,104 @@
import { Chart, ChartConfiguration, registerables } from 'chart.js';
// Register Chart.js components
Chart.register(...registerables);
function getObsidianTextColor(): string {
// Get the computed color from Obsidian's CSS variables
const style = getComputedStyle(document.body);
return style.getPropertyValue('--text-normal').trim() || '#dcddde';
}
function applyThemeColors(chartConfig: any) {
const textColor = getObsidianTextColor();
// Apply text color to all text elements in the chart
if (chartConfig.options) {
// Initialize plugins object if it doesn't exist
if (!chartConfig.options.plugins) {
chartConfig.options.plugins = {};
}
// Legend text color
if (!chartConfig.options.plugins.legend) {
chartConfig.options.plugins.legend = {};
}
if (!chartConfig.options.plugins.legend.labels) {
chartConfig.options.plugins.legend.labels = {};
}
// Store the original generateLabels function if it exists
const originalGenerateLabels = chartConfig.options.plugins.legend.labels.generateLabels;
// Set the text color
chartConfig.options.plugins.legend.labels.color = textColor;
// If there's a custom generateLabels function, wrap it to add color and hidden styling
if (originalGenerateLabels) {
chartConfig.options.plugins.legend.labels.generateLabels = function(chart: any) {
const labels = originalGenerateLabels(chart);
// Add the text color to each generated label and apply gray style to hidden items
return labels.map((label: any) => ({
...label,
fontColor: label.hidden ? 'rgba(128, 128, 128, 0.5)' : (label.fontColor || textColor)
}));
};
} else {
// For charts without custom generateLabels (line, bar), add a default handler
chartConfig.options.plugins.legend.labels.generateLabels = function(chart: any) {
const original = Chart.defaults.plugins.legend.labels.generateLabels;
const labels = original.call(this, chart);
return labels.map((label: any) => ({
...label,
fontColor: label.hidden ? 'rgba(128, 128, 128, 0.5)' : textColor
}));
};
}
// Tooltip text color
if (!chartConfig.options.plugins.tooltip) {
chartConfig.options.plugins.tooltip = {};
}
chartConfig.options.plugins.tooltip.titleColor = textColor;
chartConfig.options.plugins.tooltip.bodyColor = textColor;
// X-axis text color
if (chartConfig.options.scales?.x?.ticks) {
chartConfig.options.scales.x.ticks.color = textColor;
}
// Y-axis text color
if (chartConfig.options.scales?.y?.ticks) {
chartConfig.options.scales.y.ticks.color = textColor;
}
}
return chartConfig;
}
export function renderChart(chartConfig: any, container: HTMLElement) {
// Set container styles to prevent layout shifts
container.style.width = '100%';
container.style.display = 'block';
container.style.position = 'relative';
// Create canvas element for the chart
const canvas = container.createEl('canvas');
// Set canvas dimensions - remove maxWidth to allow full width
canvas.style.width = '100%';
canvas.style.height = '400px';
// Apply Obsidian theme colors
const themedConfig = applyThemeColors(chartConfig);
// Ensure responsive is true and maintainAspectRatio is false for consistent sizing
if (!themedConfig.options) {
themedConfig.options = {};
}
themedConfig.options.responsive = true;
themedConfig.options.maintainAspectRatio = false;
// Create and render the chart
new Chart(canvas, themedConfig as ChartConfiguration);
}

View file

@ -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";
/**

View file

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

View file

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

View file

@ -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<string, { file: TFile, fmUpdatedAt: string | null, mtime: number }>(); // 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<string, any> = {
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<TFolder | null> {
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();
}

View file

@ -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<JournalData> = {
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<string, any>, 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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,60 @@
/** Generates <option> elements for hours (00-23). */
export function createHourOptions(selectedHour: number): DocumentFragment {
const fragment = document.createDocumentFragment();
for (let i = 0; i < 24; i++) {
const option = document.createElement("option");
const hourString = String(i).padStart(2, '0');
option.value = hourString;
option.textContent = hourString;
if (i === selectedHour) {
option.selected = true;
}
fragment.appendChild(option);
}
return fragment;
}
/** //? Generates <option> elements for minutes (00-59). */
export function createMinuteOptions(selectedMinute: number): DocumentFragment {
const fragment = document.createDocumentFragment();
for (let i = 0; i < 60; i++) {
const option = document.createElement("option");
const minuteString = String(i).padStart(2, '0');
option.value = minuteString;
option.textContent = minuteString;
if (i === selectedMinute) {
option.selected = true;
}
fragment.appendChild(option);
}
return fragment;
}
/** //? Parses "HH:MM" string into hours and minutes numbers. */
export function parseTimeString(timeString: string): { hour: number; minute: number } {
let hour = 12; // Default hour
let minute = 0; // Default minute
if (/^\d{1,2}:\d{1,2}$/.test(timeString)) {
const parts = timeString.split(':').map(Number);
const parsedHour = parts[0];
const parsedMinute = parts[1];
if (!isNaN(parsedHour) && parsedHour >= 0 && parsedHour <= 23) {
hour = parsedHour;
}
if (!isNaN(parsedMinute) && parsedMinute >= 0 && parsedMinute <= 59) {
minute = parsedMinute;
}
}
//& console.log(`[TimePickerUtils] Parsed "${timeString}" to H:${hour}, M:${minute}`);
return { hour, minute };
}
/** //? Formats hour and minute numbers into "HH:MM" string. */
export function formatTimeString(hour: number | string, minute: number | string): string {
const hh = String(hour).padStart(2, '0');
const mm = String(minute).padStart(2, '0');
return `${hh}:${mm}`;
}

View file

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

View file

@ -0,0 +1,184 @@
import { NavigationPeriod } from "../codeblocks/dateHeader/dateNavigator.types";
import { parseDateISO, formatDateISO, getISOWeekNumber } from "./dateUtils";
import { formatDateForDisplay } from "./dateUtils";
export interface DateRange {
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
}
/** Calculates the start and end date (YYYY-MM-DD) for a given period containing the reference date. */
export function calculatePeriodRange(refIsoDate: string, period: NavigationPeriod): DateRange | null {
const refDate = parseDateISO(refIsoDate);
if (!refDate) {
console.error(`[DatePeriodUtils] Invalid reference date: ${refIsoDate}`);
return null;
}
let startDate: Date;
let endDate: Date;
switch (period) {
case 'day':
startDate = new Date(refDate); // Clone
endDate = new Date(refDate); // Clone
break;
case 'week':
startDate = new Date(refDate); // Clone
// Find Monday (day 1, Sunday is 0 in JS Date). Adjust backward.
const dayOfWeek = startDate.getUTCDay(); // 0=Sun, 1=Mon, ..., 6=Sat
const diffToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; // If Sunday (0), go back 6 days; else go back (dayOfWeek - 1) days.
startDate.setUTCDate(startDate.getUTCDate() + diffToMonday);
endDate = new Date(startDate); // Clone start date (Monday)
endDate.setUTCDate(endDate.getUTCDate() + 6); // Add 6 days to get Sunday
break;
case 'month':
startDate = new Date(Date.UTC(refDate.getUTCFullYear(), refDate.getUTCMonth(), 1));
// End date is day 0 of the *next* month
endDate = new Date(Date.UTC(refDate.getUTCFullYear(), refDate.getUTCMonth() + 1, 0));
break;
case 'quarter':
const currentMonth = refDate.getUTCMonth(); // 0-11
const quarterStartMonth = Math.floor(currentMonth / 3) * 3; // 0, 3, 6, 9
startDate = new Date(Date.UTC(refDate.getUTCFullYear(), quarterStartMonth, 1));
// End date is day 0 of the month *after* the quarter ends
endDate = new Date(Date.UTC(refDate.getUTCFullYear(), quarterStartMonth + 3, 0));
break;
case 'year':
startDate = new Date(Date.UTC(refDate.getUTCFullYear(), 0, 1)); // Jan 1st
endDate = new Date(Date.UTC(refDate.getUTCFullYear(), 11, 31)); // Dec 31st
break;
default:
console.error(`[DatePeriodUtils] Unsupported period type: ${period}`);
return null;
}
return {
startDate: formatDateISO(startDate),
endDate: formatDateISO(endDate),
};
}
/** Calculates the date representing the start of the next/previous period. */
export function calculateAdjacentPeriodDate(refIsoDate: string, period: NavigationPeriod, direction: 'next' | 'prev'): string | null {
const refDate = parseDateISO(refIsoDate);
if (!refDate) return null;
const multiplier = direction === 'next' ? 1 : -1;
switch (period) {
case 'day':
refDate.setUTCDate(refDate.getUTCDate() + multiplier);
break;
case 'week':
refDate.setUTCDate(refDate.getUTCDate() + (7 * multiplier));
break;
case 'month':
refDate.setUTCMonth(refDate.getUTCMonth() + multiplier, 1); // Set day to 1 to avoid month skipping issues
break;
case 'quarter':
refDate.setUTCMonth(refDate.getUTCMonth() + (3 * multiplier), 1);
break;
case 'year':
refDate.setUTCFullYear(refDate.getUTCFullYear() + multiplier, 0, 1); // Set month/day to Jan 1st
break;
default:
return null;
}
return formatDateISO(refDate);
}
/** Generates a display string for the current period/date range. */
export function formatPeriodForDisplay(isoDate: string, period: NavigationPeriod): string {
const dateObj = parseDateISO(isoDate);
if (!dateObj) return "Invalid Date";
const range = calculatePeriodRange(isoDate, period);
if (!range) return formatDateForDisplay(dateObj); // Fallback
const startObj = parseDateISO(range.startDate);
const endObj = parseDateISO(range.endDate);
if (!startObj || !endObj) return formatDateForDisplay(dateObj); // Fallback
const year = startObj.getUTCFullYear(); // Assume range is within same year mostly
const optionsMonthDay: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', timeZone: 'UTC' };
switch (period) {
case 'day':
return formatDateForDisplay(dateObj); // Just show the single day
case 'week': {
const weekNum = getISOWeekNumber(dateObj);
const startStr = startObj.toLocaleDateString(undefined, optionsMonthDay);
const endStr = endObj.toLocaleDateString(undefined, optionsMonthDay);
return `Week ${weekNum}: ${startStr} - ${endStr}, ${year}`;
}
case 'month':
return dateObj.toLocaleDateString(undefined, { month: 'long', year: 'numeric', timeZone: 'UTC' });
case 'quarter': {
const quarter = Math.floor(dateObj.getUTCMonth() / 3) + 1;
const startStr = startObj.toLocaleDateString(undefined, optionsMonthDay);
const endStr = endObj.toLocaleDateString(undefined, optionsMonthDay);
// Optionally format quarter nicely: return `Q${quarter} ${year}: ${startStr} - ${endStr}`;
return `Q${quarter} ${year}`; // Simpler Q display
}
case 'year':
return String(year);
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
}
}

47
src/helpers/dateUtils.ts Normal file
View file

@ -0,0 +1,47 @@
/** Calculates the ISO 8601 week number for a given date. */
export function getISOWeekNumber(date: Date): number {
const temp = new Date(date.getTime());
temp.setHours(0, 0, 0, 0);
// Thursday in current week decides the year.
temp.setDate(temp.getDate() + 3 - ((temp.getDay() + 6) % 7));
const week1 = new Date(temp.getFullYear(), 0, 4);
return (
1 +
Math.round(
((temp.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7 // Adjusted calculation slightly
)
);
}
/** Formats a Date object into YYYY-MM-DD string */
export function formatDateISO(date: Date): string {
if (!date || isNaN(date.getTime())) {
return ""; //? Handle invalid date
}
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
/** Parses a YYYY-MM-DD string into a Date object (at UTC midnight) */
export function parseDateISO(dateString: string): Date | null {
if (!dateString || !/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
return null;
}
//? Parse as UTC to avoid timezone issues affecting the date part
const parts = dateString.split('-').map(Number);
const date = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2]));
if (isNaN(date.getTime())) {
return null;
}
return date;
}
/** Formats a date for display (e.g., "January 15, 2024") */
export function formatDateForDisplay(date: Date): string {
if (!date || isNaN(date.getTime())) return "Invalid Date";
return date.toLocaleDateString(undefined, { // Use user's locale
year: 'numeric',
month: 'long',
day: 'numeric',
// timeZone: 'UTC'
});
}

View file

@ -1,3 +1,4 @@
import { pickTableName } from "./pickTableName";
import { replacePlaceholders } from "./replacePlaceholders";
export { pickTableName };
export { pickTableName, replacePlaceholders };

View file

@ -1,5 +1,5 @@
import { App, Notice } from "obsidian";
import { DBService } from "../dbService";
import { DBService } from "../DBService";
import { TablePickerModal } from "../components/TablePickerModal";
/**

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

98
src/pluginState.ts Normal file
View file

@ -0,0 +1,98 @@
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
public initialize(app: App, initialDate?: string, initialPeriod: NavigationPeriod = 'day'): void {
this.app = app;
this._currentPeriod = initialPeriod;
// 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 ---
get selectedDate(): string { return this._selectedDate; }
get currentPeriod(): NavigationPeriod { return this._currentPeriod; }
get periodStartDate(): string { return this._periodStartDate; }
get periodEndDate(): string { return this._periodEndDate; }
// --- Setters ---
set selectedDate(newIsoDate: string) {
//? Basic validation - can be enhanced
if (!newIsoDate || !/^\d{4}-\d{2}-\d{2}$/.test(newIsoDate)) {
console.warn(`[PluginState] Invalid date format passed to selectedDate setter: ${newIsoDate}`);
// Optionally default to today or throw error? Defaulting silently for now.
newIsoDate = new Date().toISOString().split("T")[0];
}
//? Only update and recalculate if the selected date actually changes
if (this._selectedDate !== newIsoDate) {
this._selectedDate = newIsoDate;
this._recalculatePeriodRange(); // Recalculate start/end based on new selected date and current period
this._dispatchDateChangeEventAndRefreshViews();
}
}
set currentPeriod(newPeriod: NavigationPeriod) {
//? Only update if the period type changes
if (this._currentPeriod !== newPeriod) {
this._currentPeriod = newPeriod;
this._recalculatePeriodRange(); // Recalculate start/end based on the new period and current selected date
this._dispatchDateChangeEventAndRefreshViews(); // Also notify listeners of period change implicitly via date change event
}
}
/** Recalculates start and end dates based on current _selectedDate and _currentPeriod */
private _recalculatePeriodRange(): void {
const range = calculatePeriodRange(this._selectedDate, this._currentPeriod);
if (range) {
this._periodStartDate = range.startDate;
this._periodEndDate = range.endDate;
} else {
//? Handle error or default case if calculation fails
console.error(`[PluginState] Failed to calculate period range.`);
// Set default range (e.g., just the selected day)
this._periodStartDate = this._selectedDate;
this._periodEndDate = this._selectedDate;
}
}
/** Dispatches the custom event to notify listeners of changes */
private _dispatchDateChangeEventAndRefreshViews(): void {
document.dispatchEvent(new CustomEvent(DATE_CHANGED_EVENT_NAME, {
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.");
}
}
}
export const pluginState = new PluginState();

97
src/settingTab.ts Normal file
View file

@ -0,0 +1,97 @@
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 mode")
.setDesc("Choose between local file or remote API")
.addDropdown(drop => drop
.addOptions({ local: "Local", remote: "Remote" })
.setValue(this.plugin.settings.mode)
.onChange(async (value) => {
this.plugin.settings.mode = value as "local" | "remote";
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName("Database file path (local mode)")
.setDesc("Absolute path to your local SQLite .db file")
.addText(text =>
text
.setPlaceholder("/home/user/file.db")
.setValue(this.plugin.settings.dbFilePath)
.onChange(async (value) => {
this.plugin.settings.dbFilePath = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName("API base URL (remote mode)")
.setDesc("Full base URL to your remote API (e.g., http://localhost:3000/api)")
.addText(text =>
text
.setPlaceholder("http://localhost:3000/api")
.setValue(this.plugin.settings.apiBaseUrl)
.onChange(async (value) => {
this.plugin.settings.apiBaseUrl = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Journal Folder Path')
.setDesc('Path to the folder containing your journal notes (relative to vault root).')
.addText(text => text
.setPlaceholder('e.g., Journals or Notes/Daily')
.setValue(this.plugin.settings.journalFolderPath)
.onChange(async (value) => {
this.plugin.settings.journalFolderPath = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Journal Table Name')
.setDesc('The name of the database table for journal entries.')
.addText(text => text
.setPlaceholder('e.g., Journals')
.setValue(this.plugin.settings.journalTableName)
.onChange(async (value) => {
this.plugin.settings.journalTableName = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName("CF Access Client ID")
.setDesc("Cloudflare Access client ID for remote API authentication")
.addText(text =>
text
.setPlaceholder("your-client-id")
.setValue(this.plugin.settings.cfAccessClientId || "")
.onChange(async (value) => {
this.plugin.settings.cfAccessClientId = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName("CF Access Client Secret")
.setDesc("Cloudflare Access client secret for remote API authentication")
.addText(text =>
text
.setPlaceholder("your-client-secret")
.setValue(this.plugin.settings.cfAccessClientSecret || "")
.onChange(async (value) => {
this.plugin.settings.cfAccessClientSecret = value;
await this.plugin.saveSettings();
}));
}
}

View file

@ -0,0 +1,79 @@
//? Define the unique class used by the DateNavigatorRenderer container
//? Define a unique ID for the style tag specific to the navigator
const STYLE_ID = "sqlitedb-date-navigator-styles";
const NAVIGATOR_CONTAINER_WRAPPER_CLASS = "date-navigator-container-wrapper";
/** Returns the CSS string specifically for the Date Navigator component. */
function getDateNavigatorStyles(): string {
return `
.${NAVIGATOR_CONTAINER_WRAPPER_CLASS} {
padding-bottom: var(--size-4-2);
border-bottom: 1px solid var(--background-modifier-border);
}
.${NAVIGATOR_CONTAINER_WRAPPER_CLASS} .date-navigator-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size-4-2);
}
.${NAVIGATOR_CONTAINER_WRAPPER_CLASS} .date-nav-row {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
width: 100%;
}
.${NAVIGATOR_CONTAINER_WRAPPER_CLASS} .date-nav-button {
font-size: 2.5rem;
width: 3rem;
height: 3rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
color: var(--text-normal);
transition: color 0.2s ease, transform 0.2s ease;
}
.${NAVIGATOR_CONTAINER_WRAPPER_CLASS} .date-nav-button:hover {
color: var(--text-accent);
transform: scale(1.2);
}
.${NAVIGATOR_CONTAINER_WRAPPER_CLASS} h1.date-navigator-display {
margin: 0;
text-align: center;
font-size: var(--font-heading-1);
color: var(--text-normal);
white-space: nowrap;
min-width: 15ch;
padding: 0 1rem;
}
`;
}
/** Injects CSS for the Date Navigator component into the document head if not already present. */
export function injectDateNavigatorStyles(): void {
if (document.getElementById(STYLE_ID)) {
return;
}
const styleEl = document.createElement('style');
styleEl.id = STYLE_ID;
styleEl.textContent = getDateNavigatorStyles();
document.head.appendChild(styleEl);
}
/** Removes the injected Date Navigator styles from the document head. */
export function removeDateNavigatorStyles(): void {
const styleEl = document.getElementById(STYLE_ID);
if (styleEl) {
styleEl.remove();
}
}

View file

@ -0,0 +1,163 @@
export function injectDatePickerStyles() {
const styleId = "custom-datepicker-styles";
// Avoid injecting multiple times
if (document.getElementById(styleId)) {
return;
}
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
/* Style the modal content area */
.custom-datepicker-modal-content {
padding: var(--size-4-4) var(--size-4-6); /* Consistent padding */
}
/* Header: Title and Navigation Buttons */
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size-4-3); /* Space below header */
}
.calendar-title {
font-weight: var(--font-bold);
font-size: var(--font-ui-large);
}
.calendar-header button.clickable-icon {
/* Use Obsidian's icon button style */
padding: var(--size-4-1) var(--size-4-2);
font-size: var(--font-ui-large); /* Make arrows bigger */
line-height: 1;
}
/* Weekday Headers Row */
.calendar-weekdays {
display: grid;
grid-template-columns: 36px repeat(7, 1fr); /* Week# + 7 days */
gap: var(--size-4-1);
padding-bottom: var(--size-4-1);
border-bottom: 1px solid var(--background-modifier-border);
margin-bottom: var(--size-4-2);
}
.calendar-weekday,
.week-number-header {
text-align: center;
font-weight: var(--font-semibold);
font-size: var(--font-ui-small);
color: var(--text-muted);
line-height: 1; /* Ensure consistent height */
}
.week-number-header {
font-style: italic;
}
/* Calendar Grid: Week Numbers and Day Cells */
.calendar-grid {
display: grid;
grid-template-columns: 36px repeat(7, 1fr); /* Week# + 7 days */
gap: var(--size-4-1); /* Gap between cells */
align-items: center; /* Vertically center content in grid rows */
}
/* Individual cell container (holds button or is empty) */
.calendar-day-cell {
display: flex; /* Center button inside */
justify-content: center;
align-items: center;
aspect-ratio: 1 / 1; /* Make cells squareish */
min-height: 30px; /* Ensure minimum height */
}
.calendar-day-cell.empty {
/* Style for empty cells (before/after month) */
/* background-color: var(--background-secondary); */ /* Subtle background */
/* opacity: 0.5; */
/* Or just leave them blank */
}
/* Week Number Cells in the main grid */
.week-number {
text-align: center;
font-size: var(--font-ui-smaller);
color: var(--text-faint);
font-style: italic;
align-self: center; /* Align vertically in grid row */
padding: 0 var(--size-4-1); /* Add some horizontal padding */
line-height: var(--line-height-normal);
}
/* Day Buttons */
.calendar-day {
display: flex;
align-items: center;
justify-content: center;
width: 100%; /* Fill cell width */
height: 100%; /* Fill cell height */
padding: var(--size-4-1);
border: 1px solid transparent; /* Reserve space for border */
border-radius: var(--radius-s);
background-color: var(--background-secondary);
color: var(--text-normal);
font-size: var(--font-ui-small);
cursor: pointer;
transition: all 0.1s ease-in-out;
line-height: 1; /* Prevent text pushing button size */
}
.calendar-day:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal); /* Keep text readable on hover */
border-color: var(--background-modifier-border-hover);
}
/* Today's Date Indicator */
.calendar-day.today {
border-color: var(--text-accent); /* Use accent color for border */
/* Optional: Add a subtle background or different font weight */
/* background-color: var(--background-primary-alt); */
/* font-weight: var(--font-bold); */
}
/* Selected Date Styling */
.calendar-day.selected {
background-color: var(--interactive-accent); /* Use theme's accent color */
color: var(--text-on-accent);
font-weight: var(--font-bold);
border-color: var(--interactive-accent-hover); /* Slightly darker border */
}
.calendar-day.selected:hover {
background-color: var(--interactive-accent-hover); /* Darken on hover */
color: var(--text-on-accent);
}
/* Today and Selected */
.calendar-day.today.selected {
/* Optional: Make combo distinct if needed, e.g., thicker border */
/* border-width: 2px; */
}
/* Footer Area */
.calendar-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--size-4-3); /* Space above footer */
padding-top: var(--size-4-2);
border-top: 1px solid var(--background-modifier-border);
}
.selected-date-display {
font-size: var(--font-ui-small);
color: var(--text-muted);
}
.calendar-footer button {
/* Let .mod-cta handle primary button style */
/* Add margin if needed */
/* margin-left: var(--size-4-2); */
}
`;
document.head.appendChild(style);
}

5
src/styles/index.ts Normal file
View file

@ -0,0 +1,5 @@
import { injectDatePickerStyles } from "./datePickerInject";
import { injectDateNavigatorStyles, removeDateNavigatorStyles } from "./dateNavigationInject";
import { injectTimePickerStyles } from "./timePickerInject";
export { injectDatePickerStyles, injectDateNavigatorStyles, removeDateNavigatorStyles, injectTimePickerStyles };

View file

@ -0,0 +1,54 @@
const STYLE_ID = "sqlite-db-time-picker-styles";
function getTimePickerStyles(): string {
return `
.time-picker-modal-content {
padding: var(--size-4-4) var(--size-4-6);
}
.time-picker-modal-content h2 {
text-align: center;
margin-bottom: var(--size-4-4);
}
/* Style the setting containing the dropdowns */
.time-picker-controls .setting-item-control {
display: flex;
justify-content: center; /* Center the dropdowns */
align-items: center;
gap: var(--size-4-1); /* Space between hour, :, minute */
}
.time-picker-select {
min-width: 60px; /* Give dropdowns some width */
text-align: center;
}
.time-picker-separator {
font-size: var(--font-ui-large);
font-weight: bold;
line-height: var(--input-height); /* Align with dropdowns */
padding: 0 var(--size-4-1);
}
/* Style the setting containing the buttons */
.time-picker-actions {
margin-top: var(--size-4-4);
border-top: 1px solid var(--background-modifier-border);
padding-top: var(--size-4-3);
}
.time-picker-actions .setting-item-control {
justify-content: flex-end; /* Align buttons right */
}
`;
}
/** Injects CSS for the Time Picker modal into the document head. */
export function injectTimePickerStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const styleEl = document.createElement('style');
styleEl.id = STYLE_ID;
styleEl.textContent = getTimePickerStyles();
document.head.appendChild(styleEl);
}
/** Removes the injected Time Picker styles. */
export function removeTimePickerStyles(): void {
const styleEl = document.getElementById(STYLE_ID);
if (styleEl) styleEl.remove();
}

View file

@ -1,7 +1,19 @@
export interface SQLiteDBSettings {
dbFilePath: string;
mode: "local" | "remote";
apiBaseUrl: string;
journalFolderPath: string;
journalTableName: string;
cfAccessClientId: string;
cfAccessClientSecret: string;
}
export const DEFAULT_SETTINGS: SQLiteDBSettings = {
dbFilePath: "",
mode: "local",
apiBaseUrl: "",
journalFolderPath: "",
journalTableName: "",
cfAccessClientId: "",
cfAccessClientSecret: "",
};

View file

@ -0,0 +1,108 @@
import { Plugin, Notice, App, ButtonComponent, MarkdownPostProcessorContext } from 'obsidian';
import { DBService } from '../../DBService'; // Adjust path
import { AddTextEntryModal } from '../../components/AddTextEntryModal'; // Adjust path
import { replacePlaceholders } from '../../helpers/replacePlaceholders'; // Import your placeholder function - ADJUST PATH
// Re-define or import the config interface if not imported from component
export interface AddTextButtonConfig {
buttonText: string;
dbTable: string;
textColumn: string;
extraData: Record<string, any>;
}
/**
* Registers the post-processor for add-text buttons and the global event listener.
*/
export function registerAddTextSupport(plugin: Plugin, dbService: DBService) {
// --- 1. Register the Post Processor ---
plugin.registerMarkdownPostProcessor((el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
const placeholders = el.querySelectorAll<HTMLDivElement>('div.add-text-button-placeholder');
placeholders.forEach(placeholder => {
if (placeholder.dataset.processed) return;
placeholder.dataset.processed = 'true';
// --- Read Raw Configuration from Attributes ---
const rawDataTable = placeholder.dataset.table;
const rawTextColumn = placeholder.dataset.column;
const rawButtonText = placeholder.dataset.buttonText || `Add Entry to ${rawDataTable || 'Table'}`;
// --- Basic Validation (on raw attributes) ---
if (!rawDataTable || !rawTextColumn) {
placeholder.textContent = '[Error: add-text-button-placeholder requires data-table and data-column attributes]';
placeholder.style.color = 'var(--text-error)';
placeholder.style.fontFamily = 'monospace';
console.error('AddTextProcessor: Missing required attributes data-table or data-column', placeholder);
return;
}
// --- Collect Raw Extra Data ---
const rawExtraData: Record<string, string | undefined> = {};
// Iterate over dataset keys
for (const key in placeholder.dataset) {
// Include keys other than the standard ones and 'processed'
if (key !== 'table' && key !== 'column' && key !== 'buttonText' && key !== 'processed') {
rawExtraData[key] = placeholder.dataset[key];
}
}
// --- Render the Button ---
placeholder.innerHTML = ''; // Clear placeholder content
placeholder.addClass('add-text-button-container');
// Use Obsidian's ButtonComponent for styling
const button = new ButtonComponent(placeholder)
// Set initial text (placeholders will be resolved onClick)
.setButtonText(replacePlaceholders(rawButtonText)) // Resolve button text once for display
.setClass('add-text-trigger-button');
button.buttonEl.style.margin = '0.5em 0';
// --- Attach Click Handler ---
button.onClick(() => {
// --- Apply Placeholders NOW (inside onClick) ---
const processedExtraData: Record<string, any> = {};
for (const [key, rawValue] of Object.entries(rawExtraData)) {
// Only process if the raw value exists
processedExtraData[key] = rawValue !== undefined ? replacePlaceholders(rawValue) : undefined;
}
const processedButtonText = replacePlaceholders(rawButtonText);
// --- Construct Final Config ---
const finalConfig: AddTextButtonConfig = {
buttonText: processedButtonText,
dbTable: rawDataTable, // Table/Column names usually don't need placeholder replacement
textColumn: rawTextColumn,
extraData: processedExtraData
};
// --- Dispatch Event with *processed* config ---
placeholder.dispatchEvent(new CustomEvent('request-add-text-modal', {
bubbles: true,
composed: true,
detail: finalConfig // Pass the processed config
}));
});
});
});
// --- 2. Register the Global Event Listener (Remains the Same) ---
plugin.registerDomEvent(
document,
'request-add-text-modal' as keyof DocumentEventMap,
(evt: Event) => {
if (!(evt instanceof CustomEvent) || !evt.detail) return;
const config = evt.detail as AddTextButtonConfig;
if (!dbService) {
new Notice("Database service is not ready.");
return;
}
const appInstance = plugin.app;
new AddTextEntryModal(appInstance, dbService, config).open();
}
);
}

View file

@ -0,0 +1,172 @@
import { DBService } from "../../DBService";
import { BooleanSwitchDataService } from "../services/BooleanDataService";
import { applyBooleanSwitchStyles } from "./styles/applyBooleanSwitchStyles";
import { buildBooleanSwitchDOM, BooleanSwitchUIElements } from "./dom/buildBooleanSwitchDOM";
import { createChangeHandler } from "./eventHandlers/changeHandler";
import { loadBooleanValue } from "./data/loadBooleanValue";
import { upsertBooleanValue } from "./data/upsertBooleanValue";
import {
updateSwitchState, updateStaticBooleanUI, setBooleanSwitchErrorState,
clearBooleanSwitchErrorState
} from "./dom/booleanSwitchUiUpdaters";
import { DATE_CHANGED_EVENT_NAME } from "../../pluginState";
//? Unique ID counter for label/input association
let switchCounter = 0;
//? Web component for a boolean (0/1) toggle switch linked to database state.
export class BooleanSwitch extends HTMLElement {
// --- State ---
public habitKey: string = "";
public initialDate: string = "";
public emoji: string = "";
public table: string = "";
public currentValue: 0 | 1 = 0; // Default state is 'off' (0)
private _initialLoadTriggered: boolean = false;
private _isInitialized: boolean = false;
public habitIdCol: string = "";
public valueCol: string = ""; // Column name for 'value' (0/1)
public dateCol: string = "";
// --- Dependencies ---
//? Renamed service property for clarity
public booleanDataService: BooleanSwitchDataService | null = null;
// --- Shadow DOM Elements ---
private uiElements!: BooleanSwitchUIElements;
private _isListeningForDateChanges: boolean = false; // Prevent multiple listeners
// --- Instance specific ID ---
private uniqueId: string;
constructor() {
super();
this.uniqueId = `bs-${switchCounter++}`; // Assign unique ID
}
connectedCallback() {
if (this._isInitialized) return;
//~ Create bound event handler
const changeHandler = createChangeHandler(this);
this.uiElements = buildBooleanSwitchDOM(this.attachShadow({ mode: "open" }), this.uniqueId, changeHandler);
applyBooleanSwitchStyles(this.shadowRoot!);
this._isInitialized = true;
}
disconnectedCallback() {
if (this._isListeningForDateChanges) {
document.removeEventListener(DATE_CHANGED_EVENT_NAME, this._handleGlobalDateChange);
this._isListeningForDateChanges = false;
}
}
//? Inject DBService and trigger attribute reading/initial load
public setDbService(service: DBService): void {
if (!service) { /* ... error handling ... */ this.showErrorState("Setup Error"); return; }
try {
this.booleanDataService = new BooleanSwitchDataService(service);
} catch (error) { /* ... error handling ... */ this.showErrorState("Setup Error"); return; }
this._readAttributesAndInitLoad();
}
private _readAttributesAndInitLoad(): void {
// --- Read ALL attributes ---
this.habitKey = this.getAttribute("data-key") ?? "";
this.initialDate = this.getAttribute("data-date") ?? "@date";
this.emoji = this.getAttribute("data-emoji") ?? "❓";
this.table = this.getAttribute("data-table") ?? "";
this.habitIdCol = this.getAttribute("data-key-id-col") ?? "";
this.valueCol = this.getAttribute("data-value-col") ?? "";
this.dateCol = this.getAttribute("data-date-col") ?? "";
this._setupDateChangeListener();
requestAnimationFrame(() => {
updateStaticBooleanUI(this.uiElements, this.emoji, this.habitKey); //~ Update static parts of UI
if (!this.table || !this.habitKey || !this.habitIdCol || !this.valueCol || !this.dateCol) {
console.warn(`[HabitCounter Component ${this.habitKey}] Missing one or more required attributes: table, habit, data-habit-id-col, data-value-col, data-date-col.`);
this.showErrorState("Config Error");
updateStaticBooleanUI(this.uiElements, this.emoji, this.habitKey || "Config Error");
return; // Stop initialization if config is bad
}
if (!this._initialLoadTriggered) {
this._initialLoadTriggered = true;
loadBooleanValue(this).catch(err => console.error("Unhandled error during initial load:", err)); //~ Load data via helper
} else {
console.log(`[BooleanSwitch Component ${this.habitKey}] Initial load already triggered.`);
}
});
}
private _setupDateChangeListener(): void {
//? Only add listener if component uses dynamic date and not already listening
if (this.initialDate === "@date" && !this._isListeningForDateChanges) {
document.addEventListener(DATE_CHANGED_EVENT_NAME, this._handleGlobalDateChange);
this._isListeningForDateChanges = true;
} else {
//& console.log(`[BooleanSwitch ${this.habitKey}] Not adding listener (date=${this.initialDate}, listening=${this._isListeningForDateChanges})`);
}
}
// Inside BooleanSwitch class
private _handleGlobalDateChange = (event: Event): void => {
//? Type guard for CustomEvent
if (!(event instanceof CustomEvent)) {
console.log(`[BooleanSwitch ${this.habitKey}] Event was not CustomEvent.`);
return;
}
const newIsoDate = event.detail?.newIsoDate;
//? Double-check this component should react (uses @date)
if (this.initialDate === "@date") {
loadBooleanValue(this)
.catch(err => {
console.error(`[BooleanSwitch ${this.habitKey}] Error reloading data after date change:`, err);
})
} else {
//& console.log(`[BooleanSwitch ${this.habitKey}] Ignoring date change event (date=${this.initialDate}).`);
}
};
//? Public method called by change handler to trigger DB update
public async _updateData(): Promise<void> {
await upsertBooleanValue(this);
}
//? Public method called by data helpers to update display state
public _updateDisplay(newValue: 0 | 1): void {
this.currentValue = newValue;
updateSwitchState(this.uiElements, newValue === 1);
this.clearErrorState();
this._enableSwitch();
}
//? Public methods for UI state helpers
public showErrorState(message?: string): void {
setBooleanSwitchErrorState(this, this.uiElements, message); // Pass host element
}
public clearErrorState(): void {
clearBooleanSwitchErrorState(this, this.uiElements);
}
//? Enable/disable the actual input element
private _enableSwitch(): void {
if(this.uiElements.checkboxElement) this.uiElements.checkboxElement.disabled = false;
this.removeAttribute('aria-disabled');
}
private _disableSwitch(): void {
if(this.uiElements.checkboxElement) this.uiElements.checkboxElement.disabled = true;
this.setAttribute('aria-disabled', 'true');
}
}
// --- Custom Element Definition ---
if (!customElements.get("boolean-switch")) {
customElements.define("boolean-switch", BooleanSwitch);
}

View file

@ -0,0 +1,19 @@
//? Expected structure from DB fetch (aliased)
export interface BooleanRecord {
value: 0 | 1; //? Expecting 0 or 1 specifically
}
//? Base arguments for identifying an entry
export interface BooleanSwitchDataArgs {
table: string;
habitKey: string;
date: string;
habitIdCol: string; //? Column name for the 'habitKey'
valueCol: string; //? Column name for the 0/1 value
dateCol: string;
}
//? Arguments for upserting the boolean value
export interface UpsertBooleanSwitchArgs extends BooleanSwitchDataArgs {
newValue: 0 | 1; //? The new state (0 or 1)
}

View file

@ -0,0 +1,40 @@
import { BooleanSwitchDataService } from "../../services/BooleanDataService";
import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate";
//? Interface describing the component instance parts needed for loading
interface LoadBooleanComponentInstance {
booleanDataService: BooleanSwitchDataService | null;
table: string; habitKey: string; initialDate: string;
habitIdCol: string; valueCol: string; dateCol: string;
_updateDisplay: (value: 0 | 1) => void; // Expects 0 or 1
showErrorState: (message: string) => void;
}
/** Orchestrates loading the boolean value using the BooleanSwitchDataService. */
export async function loadBooleanValue(instance: LoadBooleanComponentInstance): Promise<void> {
if (!instance.booleanDataService || !instance.table || !instance.habitKey || !instance.habitIdCol || !instance.valueCol || !instance.dateCol) {
console.error(`[LoadBooleanValue ${instance.habitKey}] Cannot load data: Missing required properties.`);
instance.showErrorState("Load Error - Config");
return;
}
const effectiveDate = calculateEffectiveDate(instance.initialDate);
const args = {
table: instance.table,
habitKey: instance.habitKey,
date: effectiveDate,
habitIdCol: instance.habitIdCol,
valueCol: instance.valueCol,
dateCol: instance.dateCol,
};
try {
const value = await instance.booleanDataService.fetchBooleanValue(args);
instance._updateDisplay(value);
} catch (error) {
console.error(`[LoadBooleanValue ${instance.habitKey}] Failed to load data:`, error);
instance.showErrorState("Load Error - DB");
}
}

View file

@ -0,0 +1,47 @@
import { BooleanSwitchDataService } from "../../services/BooleanDataService";
import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate";
import { Notice } from "obsidian";
//? Interface describing the component instance parts needed for upserting
interface UpsertBooleanComponentInstance {
booleanDataService: BooleanSwitchDataService | null;
table: string; habitKey: string; initialDate: string;
currentValue: 0 | 1;
habitIdCol: string; valueCol: string; dateCol: string;
_updateDisplay: (value: 0 | 1) => void;
showErrorState: (message: string) => void;
clearErrorState: () => void;
}
/** Orchestrates upserting the boolean value via the BooleanSwitchDataService. */
export async function upsertBooleanValue(instance: UpsertBooleanComponentInstance): Promise<void> {
if (!instance.booleanDataService || !instance.table || !instance.habitKey || !instance.habitIdCol || !instance.valueCol || !instance.dateCol) {
console.error(`[UpsertBooleanValue ${instance.habitKey}] Cannot update data: Missing required properties.`);
instance.showErrorState("Save Error - Config");
new Notice("Cannot update switch: Configuration incomplete.");
return;
}
//^ Flip the current value (0 becomes 1, 1 becomes 0)
const newValue = instance.currentValue === 1 ? 0 : 1;
const args = {
table: instance.table,
habitKey: instance.habitKey,
date: calculateEffectiveDate(instance.initialDate),
newValue: newValue as 0 | 1,
habitIdCol: instance.habitIdCol,
valueCol: instance.valueCol,
dateCol: instance.dateCol,
};
try {
await instance.booleanDataService.upsertBooleanValue(args);
instance._updateDisplay(newValue); // Update UI with the new value
instance.clearErrorState();
} catch (error) {
console.error(`[UpsertBooleanValue ${instance.habitKey}] Failed to upsert data:`, error);
instance.showErrorState("Save Error - DB");
new Notice(`Error saving switch state for ${instance.habitKey}`);
}
}

View file

@ -0,0 +1,40 @@
import type { BooleanSwitchUIElements } from "./buildBooleanSwitchDOM";
/** Updates the checked state of the switch input. */
export function updateSwitchState(elements: BooleanSwitchUIElements, isChecked: boolean): void {
if (elements.checkboxElement) {
elements.checkboxElement.checked = isChecked;
elements.checkboxElement.setAttribute("aria-checked", String(isChecked));
} else {
console.error("[BooleanSwitchUI] checkboxElement not found for state update.");
}
}
/** Updates label text based on component state. */
export function updateStaticBooleanUI(elements: BooleanSwitchUIElements, emoji: string, key: string): void {
if (elements.labelElement) {
elements.labelElement.textContent = `${emoji} ${key}:`;
} else {
console.error("[BooleanSwitchUI] labelElement not found for static update.");
}
}
/** Sets the component to visually indicate an error state and disables the switch. */
export function setBooleanSwitchErrorState(hostElement: HTMLElement, elements: BooleanSwitchUIElements, message: string = "Error"): void {
if (elements.checkboxElement) {
elements.checkboxElement.disabled = true;
}
hostElement.classList.add('error'); // Add error class to host for potential styling
hostElement.setAttribute('aria-disabled', 'true');
hostElement.setAttribute('title', message); // Tooltip for error details
}
/** Clears any visual error indication and enables the switch. */
export function clearBooleanSwitchErrorState(hostElement: HTMLElement, elements: BooleanSwitchUIElements): void {
if (elements.checkboxElement) {
elements.checkboxElement.disabled = false;
}
hostElement.classList.remove('error');
hostElement.removeAttribute('aria-disabled');
hostElement.removeAttribute('title');
}

View file

@ -0,0 +1,46 @@
import type { ChangeEventHandler } from "../eventHandlers/changeHandler"; // Import type
//? Type defining the UI Elements specific to the BooleanSwitch
export interface BooleanSwitchUIElements {
wrapper: HTMLElement; // The main host element might act as wrapper
labelElement: HTMLLabelElement; // Use label for accessibility
checkboxElement: HTMLInputElement;
}
/**
* Builds the Shadow DOM structure for the boolean switch.
* @param shadowRoot - The ShadowRoot to append elements to.
* @param uniqueId - A unique ID for linking label and input.
* @param changeHandler - The function to call when the checkbox state changes.
* @returns An object containing references to the created UI elements.
*/
export function buildBooleanSwitchDOM(
shadowRoot: ShadowRoot,
uniqueId: string, // Needed for label 'for' attribute
changeHandler: ChangeEventHandler
): BooleanSwitchUIElements {
if (!shadowRoot) throw new Error("Cannot build DOM: ShadowRoot is null.");
const wrapper = document.createElement("div"); // Use an inner wrapper if needed, or style host directly
wrapper.className = "switch-wrapper";
// --- Checkbox Input (The actual switch mechanism) ---
const checkboxElement = document.createElement("input");
checkboxElement.type = "checkbox";
checkboxElement.id = `bool-switch-${uniqueId}`; // Unique ID
checkboxElement.className = "bool-switch-input";
checkboxElement.setAttribute("role", "switch"); // ARIA role
checkboxElement.addEventListener("change", changeHandler);
// --- Label (Emoji + Text, associated with checkbox) ---
const labelElement = document.createElement("label");
labelElement.htmlFor = checkboxElement.id; // Link label to input
labelElement.className = "switch-label";
//? Text content will be set by the component later
//? Append in desired visual order (Label first, then Switch)
wrapper.append(labelElement, checkboxElement);
shadowRoot.append(wrapper);
return { wrapper, labelElement, checkboxElement };
}

View file

@ -0,0 +1,20 @@
//? Type for the component instance
interface BooleanSwitchInstance {
_updateData: () => Promise<void>;
habitKey: string;
}
//? Type for the event handler function itself
export type ChangeEventHandler = (event: Event) => void;
/** Creates the handler for the checkbox's 'change' event. */
export function createChangeHandler(instance: BooleanSwitchInstance): ChangeEventHandler {
return (event: Event) => {
instance._updateData().catch(err => {
console.error(`[BooleanSwitchChangeHandler ${instance.habitKey}] Error during update:`, err);
if (event.target instanceof HTMLInputElement) {
event.target.checked = !event.target.checked;
}
});
};
}

View file

@ -0,0 +1,58 @@
import { DBService } from "../../DBService";
import { BooleanSwitch } from "./BooleanSwitch";
//? Define element if needed
if (!customElements.get("boolean-switch")) {
customElements.define("boolean-switch", BooleanSwitch);
}
/**
* Finds placeholder elements and replaces them with initialized BooleanSwitch components.
* @param el The container element to search within.
* @param dbService The DBService instance for dependency injection.
*/
export const registerBooleanSwitch = (el: HTMLElement, dbService: DBService) => {
const placeholders = el.querySelectorAll("span.boolean-switch-placeholder");
placeholders.forEach((placeholderEl) => {
if (!(placeholderEl instanceof HTMLElement)) return;
//~ Read all expected attributes from the placeholder
const habitKey = placeholderEl.dataset.habit;
const date = placeholderEl.dataset.date;
const emoji = placeholderEl.dataset.emoji;
const table = placeholderEl.dataset.table;
const habitIdCol = placeholderEl.dataset.habitIdCol;
const valueCol = placeholderEl.dataset.valueCol;
const dateCol = placeholderEl.dataset.dateCol;
// --- Basic Validation ---
if (!habitKey || !table || !habitIdCol || !valueCol || !dateCol) {
console.warn("[BooleanSwitch Reg] Placeholder missing one or more required data attributes (habitKey, table, habitIdCol, valueCol, dateCol):", placeholderEl);
placeholderEl.textContent = "[Config Error]";
return;
}
try {
const component = document.createElement("boolean-switch") as BooleanSwitch;
//~ Set all attributes on the component element
component.setAttribute("data-key", habitKey);
component.setAttribute("data-table", table);
if (date) component.setAttribute("data-date", date);
else component.setAttribute("data-date", "@date"); // Default to dynamic date
if (emoji) component.setAttribute("data-emoji", emoji);
component.setAttribute("data-key-id-col", habitIdCol);
component.setAttribute("data-value-col", valueCol);
component.setAttribute("data-date-col", dateCol);
component.setDbService(dbService);
placeholderEl.replaceWith(component);
} catch (error) {
console.error(`[BooleanSwitch Reg] Error processing placeholder for key "${habitKey}":`, error, placeholderEl);
placeholderEl.textContent = "[Error Loading]";
}
});
}

View file

@ -0,0 +1,12 @@
import { getBooleanSwitchStyles } from "./getBooleanSwitchStyles";
/** Creates a style element and appends it to the shadow root. */
export function applyBooleanSwitchStyles(shadowRoot: ShadowRoot): void {
if (!shadowRoot) {
console.error("[BooleanSwitchStyles] Cannot apply styles: ShadowRoot is null.");
return;
}
const style = document.createElement("style");
style.textContent = getBooleanSwitchStyles();
shadowRoot.appendChild(style);
}

View file

@ -0,0 +1,91 @@
/** Returns the CSS string for the BooleanSwitch component's Shadow DOM. */
export function getBooleanSwitchStyles(): string {
return `
:host {
display: inline-flex; /* Align items horizontally */
align-items: center;
gap: 0.6em; /* Space between label and switch */
font-family: var(--font-text);
cursor: pointer; /* Make the whole thing clickable */
user-select: none; /* Prevent text selection */
}
.switch-wrapper {
display: contents; /* Don't add extra layout */
}
.switch-label {
/* Styles for the emoji + text label */
white-space: nowrap;
color: var(--text-normal);
line-height: 1.2; /* Align better with switch */
}
/* Basic Checkbox Styling (Foundation) */
input[type="checkbox"] {
appearance: none; /* Remove default OS styling */
-webkit-appearance: none;
-moz-appearance: none;
position: relative; /* For positioning pseudo-elements */
width: 36px; /* Width of the switch */
height: 20px; /* Height of the switch */
background-color: var(--background-modifier-border); /* Off state background */
border-radius: 10px; /* Rounded corners */
cursor: pointer;
outline: none;
transition: background-color 0.2s ease-in-out;
vertical-align: middle; /* Align with text */
margin: 0; /* Remove default margin */
flex-shrink: 0; /* Prevent shrinking */
}
/* Styling the 'thumb' or 'knob' of the switch */
input[type="checkbox"]::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px; /* Size of the knob */
height: 16px;
background-color: var(--background-primary); /* Knob color */
border-radius: 50%; /* Make it round */
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
transition: transform 0.2s ease-in-out;
}
/* Styling when the switch is checked (On state) */
input[type="checkbox"]:checked {
background-color: var(--interactive-accent); /* Use accent color for on state */
}
/* Moving the knob when checked */
input[type="checkbox"]:checked::before {
transform: translateX(16px); /* Move knob to the right */
}
/* Focus state for accessibility */
input[type="checkbox"]:focus-visible {
box-shadow: 0 0 0 2px var(--background-primary), 0 0 0 4px var(--interactive-accent-hover);
}
/* Disabled state */
input[type="checkbox"]:disabled {
background-color: var(--background-modifier-border-hover);
cursor: not-allowed;
opacity: 0.6;
}
input[type="checkbox"]:disabled::before {
background-color: var(--background-modifier-border);
}
:host([aria-disabled="true"]) { /* Style host when disabled */
cursor: not-allowed;
opacity: 0.6;
}
/* Error state indication (optional, applied to host or wrapper) */
:host(.error) {
/* e.g., outline: 1px solid var(--text-error); */
/* e.g., border-radius: var(--radius-s); */
}
`;
}

View file

@ -0,0 +1,159 @@
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";
import { DATE_CHANGED_EVENT_NAME, } from "../../pluginState";
//? 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 habitIdCol: string = "";
public valueCol: string = "";
public dateCol: 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
private _isListeningForDateChanges: boolean = false; // Prevent multiple listeners
// --- Lifecycle ---
connectedCallback() {
if (this._isInitialized) return;
//~ 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._setupDateChangeListener(); //^ Call setup listener
this._isInitialized = true;
}
disconnectedCallback() {
document.removeEventListener(DATE_CHANGED_EVENT_NAME, this._handleGlobalDateChange);
this._isListeningForDateChanges = false;
}
// --- Public Methods ---
public setDbService(service: DBService): void {
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 {
this.habitKey = this.getAttribute("habit") ?? "";
this.initialDate = this.getAttribute("date") ?? "@date";
this.emoji = this.getAttribute("emoji") ?? "❓";
this.table = this.getAttribute("table") ?? "";
this.habitIdCol = this.getAttribute("data-habit-id-col") ?? "";
this.valueCol = this.getAttribute("data-value-col") ?? "";
this.dateCol = this.getAttribute("data-date-col") ?? "";
// --- Defer UI update and load trigger slightly ---
requestAnimationFrame(() => {
updateStaticUI(this.uiElements, this.emoji, this.habitKey); //~ Update static parts of UI
if (!this.table || !this.habitKey || !this.habitIdCol || !this.valueCol || !this.dateCol) {
console.warn(`[HabitCounter Component ${this.habitKey}] Missing one or more required attributes: table, habit, data-habit-id-col, data-value-col, data-date-col.`);
this.showErrorState("Config Error");
updateStaticUI(this.uiElements, this.emoji, this.habitKey || "Config Error");
return; // Stop initialization if config is bad
}
if (!this._initialLoadTriggered) {
this._initialLoadTriggered = true;
loadHabitData(this).catch(err => console.error("Unhandled error during initial load:", err)); //~ Load data via helper
} else {
console.log(`[HabitCounter Component ${this.habitKey}] Initial load already triggered.`);
}
});
}
private _setupDateChangeListener(): void {
//? Only add listener if component uses dynamic date and not already listening
if (this.getAttribute("date") === "@date" && !this._isListeningForDateChanges) {
document.addEventListener(DATE_CHANGED_EVENT_NAME, this._handleGlobalDateChange);
this._isListeningForDateChanges = true;
} else {
//& console.log(`[HabitCounter Component ${this.habitKey}] Not adding listener (date=${this.getAttribute("date")}, listening=${this._isListeningForDateChanges})`);
}
}
private _handleGlobalDateChange = (event: Event): void => {
//? Type guard for CustomEvent
if (!(event instanceof CustomEvent)) return;
const newIsoDate = event.detail?.newIsoDate;
//? Double-check this component should react (uses @date)
if (this.initialDate === "@date") {
//? Reload data using the same helper
loadHabitData(this).catch(err => {
console.error(`[HabitCounter Component ${this.habitKey}] Error reloading data after date change:`, err);
});
}
};
//? Public method called by click handlers via instance reference
public async _updateData(delta: number): Promise<void> {
await updateHabitData(this, delta); //~ Update data via helper
}
//? Public method called by data helpers to update display
public _updateDisplay(newValue: number): void {
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);
}

View file

@ -0,0 +1,20 @@
//? Expected structure returned by the data service fetch method.
export interface HabitRecord {
value: number; //? Service should SELECT 'actual_value_col AS value'
}
//? Base arguments for identifying a habit entry, now includes column names.
export interface HabitDataArgs {
table: string;
habitKey: string; //? The *value* identifying the specific habit
date: string;
//^ Column Names specified by the user
habitIdCol: string;
valueCol: string;
dateCol: string;
}
//? Arguments for updating, inheriting base args and adding the new value.
export interface UpdateHabitDataArgs extends HabitDataArgs {
newValue: number;
}

View file

@ -0,0 +1,44 @@
import { HabitDataService } from "../../services/HabitDataService";
import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate";
//? Interface describing the necessary parts of the component instance for loading.
interface LoadComponentInstance {
habitDataService: HabitDataService | null;
table: string;
habitKey: string;
initialDate: string;
habitIdCol: string;
valueCol: string;
dateCol: string;
_updateDisplay: (value: number) => void;
showErrorState: (message: string) => void;
}
/**
* Orchestrates loading the habit data using the HabitDataService.
* @param instance - The HabitCounter component instance.
*/
export async function loadHabitData(instance: LoadComponentInstance): Promise<void> {
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),
habitIdCol: instance.habitIdCol,
valueCol: instance.valueCol,
dateCol: instance.dateCol,
};
try {
const value = await instance.habitDataService.fetchHabitValue(args);
instance._updateDisplay(value);
} catch (error) {
console.error(`[LoadHabitData ${instance.habitKey}] Failed to load data:`, error);
instance.showErrorState("Load Error");
}
}

View file

@ -0,0 +1,56 @@
import { HabitDataService } from "../../services/HabitDataService";
import { calculateEffectiveDate } from "../../services/utils/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;
currentValue: number;
habitIdCol: string;
valueCol: string;
dateCol: string;
_updateDisplay: (value: number) => void;
showErrorState: (message: string) => void;
clearErrorState: () => void;
}
/**
* 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<void> {
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),
newValue: newValue,
habitIdCol: instance.habitIdCol,
valueCol: instance.valueCol,
dateCol: instance.dateCol,
};
try {
await instance.habitDataService.updateHabitValue(args);
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}`);
}
}

View file

@ -0,0 +1,47 @@
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 {
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 };
}

View file

@ -0,0 +1,52 @@
//? 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 {
if (elements.valueDisplay) {
elements.valueDisplay.textContent = String(newValue);
} else {
console.error("[HabitCounterUI] valueDisplay element not found for update.");
}
}
export function updateStaticUI(elements: HabitCounterUIElements, emoji: string, habitKey: string): void {
if (elements.labelElement) {
const newLabelText = `${emoji} ${habitKey}:`;
elements.labelElement.textContent = newLabelText;
} else {
console.error("[HabitCounterUI] updateStaticUI: labelElement was null.");
}
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 {
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 {
//? 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");
}
}

View file

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

View file

@ -0,0 +1,56 @@
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;
const date = placeholderEl.dataset.date;
const emoji = placeholderEl.dataset.emoji;
const table = placeholderEl.dataset.table;
const habitIdCol = placeholderEl.dataset.habitIdCol;
const valueCol = placeholderEl.dataset.valueCol;
const dateCol = placeholderEl.dataset.dateCol;
if (!habitKey || !table || !habitIdCol || !valueCol || !dateCol) {
console.warn("[HabitCounter Reg] Placeholder missing one or more required data attributes (habit, table, habit-id-col, value-col, date-col):", placeholderEl);
placeholderEl.textContent = "[Config Error]";
return;
}
try {
const component = document.createElement("habit-counter") as HabitCounter;
//^ Set attributes that the component will read later
component.setAttribute("habit", habitKey);
component.setAttribute("table", table);
if (date) component.setAttribute("date", date);
else component.setAttribute("date", "@date");
if (emoji) component.setAttribute("emoji", emoji);
component.setAttribute("data-habit-id-col", habitIdCol);
component.setAttribute("data-value-col", valueCol);
component.setAttribute("data-date-col", dateCol);
//? Inject the DBService dependency - component reads attributes inside this call now
component.setDbService(dbService);
placeholderEl.replaceWith(component);
} catch (error) {
console.error(`[HabitCounter Reg] Error processing placeholder for habit "${habitKey}":`, error, placeholderEl);
placeholderEl.textContent = "[Error Loading Counter]";
}
});
}

View file

@ -0,0 +1,12 @@
import { getHabitCounterStyles } from "./getHabitCounterStyles";
/** Creates a style element and appends it to the shadow root. */
export function applyHabitCounterStyles(shadowRoot: ShadowRoot): void {
if (!shadowRoot) {
console.error("[HabitCounterStyles] Cannot apply styles: ShadowRoot is null.");
return;
}
const style = document.createElement("style");
style.textContent = getHabitCounterStyles();
shadowRoot.appendChild(style);
}

View file

@ -0,0 +1,29 @@
/** Returns the CSS string for the component's Shadow DOM. */
export function getHabitCounterStyles(): string {
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);
}
`;
}

View file

@ -0,0 +1,54 @@
import { Plugin, MarkdownPostProcessorContext, ButtonComponent, Notice } from 'obsidian';
import { MoodNoteEntryModal } from '../../components/MoodNoteModal/MoodNoteEntryModal';
import { DBService } from '../../DBService'; // Adjust path if needed
/**
* Registers a MarkdownPostProcessor to create mood note buttons
* AND registers the global event listener to open the modal.
*/
export function registerMoodNoteButtonProcessor(plugin: Plugin, dbService: DBService) {
// --- Register the Post Processor (finds placeholders, creates buttons) ---
plugin.registerMarkdownPostProcessor((el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
const placeholders = el.querySelectorAll<HTMLDivElement>('div.mood-note-button-placeholder');
placeholders.forEach(placeholder => {
if (placeholder.dataset.processed) return;
placeholder.dataset.processed = 'true';
const buttonText = placeholder.dataset.buttonText || 'Add Mood Note 💭';
placeholder.innerHTML = '';
placeholder.addClass('mood-note-button-container');
const button = new ButtonComponent(placeholder)
.setButtonText(buttonText)
.setClass('mood-note-trigger-button');
button.buttonEl.style.margin = '0.5em 0';
button.onClick(() => {
console.log("Mood Note Button (from div) Clicked - Dispatching Event");
placeholder.dispatchEvent(new CustomEvent('request-mood-modal', {
bubbles: true,
composed: true
}));
});
});
});
// --- Register the Global Event Listener (catches event, opens modal) ---
// Use plugin.registerDomEvent for automatic cleanup on unload
plugin.registerDomEvent(document, 'request-mood-modal' as keyof DocumentEventMap, (evt: CustomEvent) => {
if (!dbService) {
new Notice("Database service is not ready.");
console.error("DBService not initialized/ready when trying to open mood modal via custom event.");
return;
}
// Access app via the plugin instance
const appInstance = plugin.app;
// Open the modal, passing app and dbService
new MoodNoteEntryModal(appInstance, dbService, () => {
new Notice("Mood note saved.");
}).open();
});
}

View file

@ -0,0 +1,49 @@
import { MarkdownPostProcessorContext, Plugin } from 'obsidian';
import { DBService } from '../../DBService';
import { processSqlBlock } from '../../codeblocks/processSqlBlock';
import { replacePlaceholders } from 'src/helpers/replacePlaceholders';
export function registerSqlRenderer(plugin: Plugin, dbService: DBService) {
plugin.registerMarkdownPostProcessor(
async (el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
// Find all divs with the class 'sql-render' inside the rendered element 'el'
const chartContainers = el.querySelectorAll<HTMLElement>('div.sql-render');
if (chartContainers.length === 0) {
return;
}
await Promise.all(Array.from(chartContainers).map(async (container) => {
if (container.dataset.sqlProcessed) {
return;
}
// Mark as processed
container.dataset.sqlProcessed = 'true';
const source = container.innerHTML.trim();
const parsedSource = replacePlaceholders(source);
container.innerHTML = ''; // Clear the container
if (!source) {
container.createEl("p", { text: "Error: SQL Chart configuration block is empty." });
console.warn("Empty sql-render block found", container);
return;
}
const loadingEl = container.createEl("p", { text: "Loading SQL..." });
try {
await processSqlBlock(dbService, parsedSource, container);
loadingEl.remove();
} catch (error: any) {
console.error("Error rendering SQL from div:", error);
container.innerHTML = '';
container.createEl("p", { text: "Error rendering SQL:" });
container.createEl("pre", { text: String(error.message || error) });
}
}));
}
);
}

View file

@ -0,0 +1,49 @@
import { MarkdownPostProcessorContext, Plugin } from 'obsidian';
import { DBService } from '../../DBService';
import { processSqlChartBlock } from '../../codeblocks/processSqlChartBlock';
import { replacePlaceholders } from 'src/helpers/replacePlaceholders';
export function registerSqlChartRenderer(plugin: Plugin, dbService: DBService) {
plugin.registerMarkdownPostProcessor(
async (el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
// Find all divs with the class 'sql-chart-render' inside the rendered element 'el'
const chartContainers = el.querySelectorAll<HTMLElement>('div.sql-chart-render');
if (chartContainers.length === 0) {
return;
}
await Promise.all(Array.from(chartContainers).map(async (container) => {
if (container.dataset.sqlChartProcessed) {
return;
}
// Mark as processed
container.dataset.sqlChartProcessed = 'true';
const source = container.innerHTML.trim();
const parsedSource = replacePlaceholders(source);
container.innerHTML = ''; // Clear the container
if (!source) {
container.createEl("p", { text: "Error: SQL Chart configuration block is empty." });
console.warn("Empty sql-chart-render block found", container);
return;
}
const loadingEl = container.createEl("p", { text: "Loading chart..." });
try {
await processSqlChartBlock(dbService, parsedSource, container);
loadingEl.remove();
} catch (error: any) {
console.error("Error rendering SQL Chart from div:", error);
container.innerHTML = '';
container.createEl("p", { text: "Error rendering SQL Chart:" });
container.createEl("pre", { text: String(error.message || error) });
}
}));
}
);
}

View file

@ -0,0 +1,212 @@
import { App } from "obsidian";
import { DBService } from "src/DBService";
import { TextInputDataService } from "../services/TextInputDataService";
import { applyTextInputStyles } from "./styles/applyTextInputStyles";
import { buildTextInputDOM, } from "./dom/buildTextInputDOM";
import { createInputChangeHandler } from "./eventHandlers/inputHandlers";
import { createModalTriggerHandler } from "./eventHandlers/modalTriggerHandlers";
import { TextInputConfig, ModalType, TextInputEventProps, TextInputDOMElements } from "./TextInput.types";
import { loadTextValue } from "./data/loadTextValue";
import { upsertTextValue } from "./data/upsertTextValue";
import { updateInputValue, setTextErrorState, clearTextErrorState, updateStaticTextUI } from "./dom/textInputUiUpdaters";
import { DATE_CHANGED_EVENT_NAME } from "src/pluginState";
let textInputCounter = 0;
export class TextInput extends HTMLElement {
// --- Dependencies ---
public appInstance: App | null = null;
public textDataService: TextInputDataService | null = null;
// --- State & Config ---
public config: TextInputConfig = {};
public currentValue: string = "";
private _isInitialized: boolean = false;
private _domBuilt: boolean = false; // Tracks if Shadow DOM exists
private _initialLoadTriggered: boolean = false; // Tracks if initial load has been triggered
//^ data related state/config
public table: string = "";
public date: string = "";
public valueCol: string = "";
public dateCol: string = "";
private _isListeningForDateChanges: boolean = false; // Prevent multiple listeners
// --- Shadow DOM Elements ---
private uiElements!: TextInputDOMElements;
private uniqueId: string; // For logging/debugging
constructor() {
super();
this.uniqueId = `ti-${textInputCounter++}`;
}
// --- Lifecycle ---
connectedCallback() {
//& console.log(`[TextInput ${this.uniqueId}] connectedCallback.`);
//? We only mark that the element is in the DOM.
//? Initialization waits for dependencies via setDependencies.
//? Setup listener only after attributes are read.
if (this._isInitialized) return;
}
disconnectedCallback() {
//& console.log(`[TextInput ${this.uniqueId}] disconnectedCallback - Cleaning up listener.`);
if (this._isListeningForDateChanges) {
document.removeEventListener(DATE_CHANGED_EVENT_NAME, this._handleGlobalDateChange);
this._isListeningForDateChanges = false;
}
}
// --- Public Methods ---
/** Injects DBService first, then App instance. */
public setDependencies(dbService: DBService, app: App): void {
//& console.log(`[TextInput ${this.uniqueId}] setDependencies called.`);
if (this._isInitialized) {
//& console.log(`[TextInput ${this.uniqueId}] Already initialized, skipping dependency set.`);
return; // Don't re-initialize if already done
}
if (!dbService || !app) {
console.error("[TextInput] Invalid DBService or App instance provided during dependency injection.");
this.textContent = "[Init Error - Dependencies]";
return;
}
this.appInstance = app;
try {
this.textDataService = new TextInputDataService(dbService);
} catch (error) {
console.error(`[TextInput ${this.uniqueId}] Failed to initialize TextInputDataService:`, error);
this.textContent = "[Data Service Error]";
return;
}
//? Start the setup process now that dependencies are available
this._readAttributesAndBuildConfig(); // Reads attributes, sets 'this.date', setups listener
this._initializeComponent(); // Builds DOM, creates handlers, applies styles, triggers load
this._isInitialized = true; // Mark initialization complete
//& console.log(`[TextInput ${this.uniqueId}] Initialization fully complete.`);
}
/** Reads data-related attributes and triggers initial load. Called AFTER dependencies are set. */
private _readAttributesAndBuildConfig(): void {
// --- Read ALL Attributes ---
this.config = {
label: this.getAttribute("data-label") ?? undefined,
placeholder: this.getAttribute("placeholder") ?? undefined,
initialValueAttr: this.getAttribute("data-initial-value") ?? "",
modalType: (this.getAttribute("data-modal-type") as ModalType) ?? 'none',
isButton: this.getAttribute("data-is-button")?.toLowerCase() === 'true',
table: this.getAttribute("data-table") ?? undefined,
date: this.getAttribute("data-date") ?? "@date",
valueCol: this.getAttribute("data-value-col") ?? undefined,
dateCol: this.getAttribute("data-date-col") ?? undefined,
};
//? Copy to component properties
this.table = this.config.table ?? "";
this.date = this.config.date ?? "@date"; // Store initial date config here
this.valueCol = this.config.valueCol ?? "";
this.dateCol = this.config.dateCol ?? "";
//^ --- Setup Listener AFTER reading attributes and setting this.date ---
this._setupDateChangeListener(); // Safe to call now
}
/** Builds DOM, creates handlers, applies styles, triggers initial load. */
private _initializeComponent(): void {
if (!this.appInstance) { console.error("[TextInput] Cannot initialize component: App instance missing."); return; }
if (this.shadowRoot) { console.warn(`[TextInput ${this.config.label || this.uniqueId}] Already has shadowRoot, skipping DOM build.`); return } // Prevent rebuilding DOM
//? Pass 'this' which now contains the fully populated config
const handlers = {
handleInputChange: createInputChangeHandler(this),
handleModalTrigger: createModalTriggerHandler(this),
};
//~ Build DOM and get element references
this.uiElements = buildTextInputDOM(this.attachShadow({ mode: "open" }), this.config, handlers);
//~ Apply styles
applyTextInputStyles(this.shadowRoot!); // shadowRoot definitely exists now
this._domBuilt = true; // Mark DOM built
// --- Update Static UI & Trigger Initial Load (Deferred slightly) ---
requestAnimationFrame(() => {
updateStaticTextUI(this.uiElements, this.config.label);
// --- Data Validation & Load Trigger ---
if (this.table && this.valueCol && this.dateCol) {
// Config looks okay for data operations
if (!this._initialLoadTriggered) {
this._initialLoadTriggered = true;
loadTextValue(this).catch(err => console.error(`[TextInput ${this.config.label || this.uniqueId}] Unhandled error during initial load:`, err));
}
} else {
console.warn(`[TextInput ${this.config.label || this.uniqueId}] Missing required data configuration attributes (table, valueCol, dateCol). Will use initialValueAttr.`);
this._updateDisplay(this.config.initialValueAttr ?? "");
this._disableInput?.();
}
});
}
/** Central method to update state, UI, and trigger save. */
public _setValue = (newValue: string, triggerSave: boolean = false): void => {
if (this.currentValue !== newValue) {
this.currentValue = newValue;
this._updateDisplay(this.currentValue); // Update UI optimistically
this.dispatchEvent(new CustomEvent('input-change', { /* ... */ }));
// Use 'this' properties for validation
if (triggerSave && this.table && this.valueCol && this.dateCol) {
//& console.log(`[TextInput ${this.config.label || this.uniqueId}] Save triggered.`);
upsertTextValue(this).catch(/* ... */);
}
}
};
//? Listener Setup (called from _readAttributesAndBuildConfig)
private _setupDateChangeListener(): void {
if (this.date === "@date" && !this._isListeningForDateChanges) {
document.addEventListener(DATE_CHANGED_EVENT_NAME, this._handleGlobalDateChange);
this._isListeningForDateChanges = true;
}
}
private _handleGlobalDateChange = (event: Event): void => {
//? Type guard for CustomEvent
if (!(event instanceof CustomEvent)) return;
const newIsoDate = event.detail?.newIsoDate;
//? Double-check this component should react (uses @date)
if (this.date === "@date") {
//& console.log(`[TextInput ${this.uniqueId}] Date property matches "@date", reloading data...`);
this.classList.add('loading');
this._updateDisplay("..."); // Show loading dots
loadTextValue(this)
.catch(err => { /* ... */ })
.finally(() => { this.classList.remove('loading'); });
} else {
//& console.log(`[TextInput ${this.uniqueId}] Ignoring date change event (internal date property is "${this.date}").`);
}
};
//? Public method called by data helpers to update display
public _updateDisplay(newValue: string): void {
this.currentValue = newValue; // Update internal state
updateInputValue(this.uiElements, newValue); //~ Update DOM via helper
this.clearErrorState(); //? Clear errors on successful update
//? Enable/disable logic might be needed based on state
}
//? Public methods for UI state helpers
public showErrorState(message?: string): void { setTextErrorState(this, this.uiElements, message); }
public clearErrorState(): void { clearTextErrorState(this, this.uiElements); }
private _enableInput(): void { if(this.uiElements?.inputElement) this.uiElements.inputElement.disabled = false; this.removeAttribute('aria-disabled'); }
private _disableInput(): void { if(this.uiElements?.inputElement) this.uiElements.inputElement.disabled = true; this.setAttribute('aria-disabled', 'true'); }
}
// --- Custom Element Definition ---
if (!customElements.get("text-input")) { customElements.define("text-input", TextInput); }

View file

@ -0,0 +1,54 @@
import { App } from "obsidian";
//? Type defining the available modal types this component can trigger
export type ModalType = 'time-picker' | 'date-picker' | 'none';
//? Configuration options read from attributes for TextInput
export interface TextInputConfig {
//? UI Config
label?: string;
placeholder?: string;
initialValueAttr?: string;
modalType?: ModalType;
isButton?: boolean;
//? Data Config (Similar to BooleanSwitch/HabitCounter)
table?: string;
date?: string;
valueCol?: string;
dateCol?: string;
}
//? References to the DOM elements for TextInput
export interface TextInputDOMElements {
wrapper: HTMLElement;
labelElement: HTMLLabelElement | null;
inputElement: HTMLInputElement;
modalTriggerElement: HTMLElement;
}
//? Properties needed by event handlers for TextInput
export interface TextInputEventProps extends TextInputConfig {
app: App;
uiElements: TextInputDOMElements;
currentValue: string;
setValue: (newValue: string, triggerSave?: boolean) => void;
}
//? Types for the Data Service
export interface TextRecord {
value: string | null; // Value can be text or null
}
//? Base arguments for identifying an entry in DB
export interface TextDataArgs {
table: string;
date: string; // Date to identify the row
valueCol: string; // Column to fetch/update
dateCol: string; // Column containing the date key
}
//? Arguments for upserting the text value
export interface UpsertTextArgs extends TextDataArgs {
newValue: string | null; // Allow saving null/empty string
}

View file

@ -0,0 +1,61 @@
import { TextInputDataService } from "../../services/TextInputDataService";
import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate";
/** Interface describing the necessary parts of the TextInput component instance for loading. */
interface LoadTextComponentInstance {
textDataService: TextInputDataService | null; // Use the correct service
table: string;
date: string; // The initial date string ('@date' or 'YYYY-MM-DD')
valueCol: string;
dateCol: string;
_updateDisplay: (value: string) => void; // Method to update component UI/state
showErrorState: (message: string) => void; // Method to show error UI
config: { initialValueAttr?: string }; // Access to initial value from attribute if DB fails/missing
}
/**
* Orchestrates loading the text value using the TextInputDataService.
* @param instance - The TextInput component instance.
*/
export async function loadTextValue(instance: LoadTextComponentInstance): Promise<void> {
if (!instance.textDataService) {
console.error(`[LoadTextValue ${instance.table}] Cannot load data: Missing textDataService.`);
//? Use initial value from attribute as fallback before showing error?
instance._updateDisplay(instance.config.initialValueAttr ?? "");
instance.showErrorState("Load Error - DB");
return;
}
const missingProps: string[] = [];
if (!instance.table) missingProps.push("table");
if (!instance.date) missingProps.push("date");
if (!instance.valueCol) missingProps.push("valueCol");
if (!instance.dateCol) missingProps.push("dateCol");
if (missingProps.length > 0) {
const missingPropsStr = missingProps.join(", ");
console.error(`[LoadTextValue ${instance.table || 'UNKNOWN'}] Cannot load data: Missing required configuration properties: ${missingPropsStr}.`);
//? Use initial value from attribute as fallback before showing error?
instance._updateDisplay(instance.config.initialValueAttr ?? "");
instance.showErrorState("Load Error - Config");
return;
}
const args = {
table: instance.table,
date: calculateEffectiveDate(instance.date), // Use effective date
valueCol: instance.valueCol,
dateCol: instance.dateCol,
};
try {
const value = await instance.textDataService.fetchTextValue(args);
instance._updateDisplay(value); // Update component with fetched value (or empty string if not found)
} catch (error) {
console.error(`[LoadTextValue ${instance.table}] Failed to load data:`, error);
//? Use initial value from attribute as fallback on DB error
instance._updateDisplay(instance.config.initialValueAttr ?? "");
instance.showErrorState("Load Error - DB");
}
}

View file

@ -0,0 +1,49 @@
import { TextInputDataService } from "../../services/TextInputDataService";
import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate";
import { Notice } from "obsidian";
//? Interface describing the necessary parts of the TextInput component instance for upserting.
interface UpsertTextComponentInstance {
textDataService: TextInputDataService | null;
table: string;
date: string; // The initial date string ('@date' or 'YYYY-MM-DD')
currentValue: string; // The current value held by the component
valueCol: string;
dateCol: string;
_updateDisplay: (value: string) => void; // Method to update UI (maybe not needed here if optimistic?)
showErrorState: (message: string) => void;
clearErrorState: () => void;
}
/**
* Orchestrates upserting the text value via the TextInputDataService.
* @param instance - The TextInput component instance.
*/
export async function upsertTextValue(instance: UpsertTextComponentInstance): Promise<void> {
if (!instance.textDataService || !instance.table || !instance.date || !instance.valueCol || !instance.dateCol) {
console.error(`[UpsertTextValue ${instance.table}] Cannot save data: Missing required configuration properties.`);
instance.showErrorState("Save Error - Config");
new Notice("Cannot save text input: Configuration incomplete.");
return;
}
const args = {
table: instance.table,
date: calculateEffectiveDate(instance.date),
newValue: instance.currentValue,
valueCol: instance.valueCol,
dateCol: instance.dateCol,
};
try {
await instance.textDataService.upsertTextValue(args);
instance.clearErrorState();
} catch (error) {
console.error(`[UpsertTextValue ${instance.table}] Failed to save data:`, error);
instance.showErrorState("Save Error - DB");
new Notice(`Error saving value for ${instance.table}`);
//? Should we revert the UI? Depends on desired UX.
//? For now, keep the user's input but show error state.
}
}

View file

@ -0,0 +1,83 @@
import type { TextInputConfig, TextInputDOMElements } from "../TextInput.types";
import type { InputChangeEventHandler } from "../eventHandlers/inputHandlers";
import type { ModalTriggerHandler } from "../eventHandlers/modalTriggerHandlers";
//? Interface for handlers needed by the DOM builder
export interface TextInputDOMHandlers {
handleInputChange: InputChangeEventHandler;
handleModalTrigger: ModalTriggerHandler;
}
/**
* Builds the Shadow DOM structure for the TextInput component.
* @param shadowRoot The ShadowRoot to append elements to.
* @param config Configuration options from attributes.
* @param handlers Event handlers for input change and modal trigger.
* @returns Object containing references to the created UI elements.
*/
export function buildTextInputDOM(
shadowRoot: ShadowRoot,
config: TextInputConfig,
handlers: TextInputDOMHandlers
): TextInputDOMElements {
if (!shadowRoot) throw new Error("Cannot build DOM: ShadowRoot is null.");
//? Determine if a separate button is needed *initially*
//? Button mode overrides this later if needed
const needsSeparateButton = config.modalType && config.modalType !== 'none' && !config.isButton;
// --- Optional Label ---
let labelElement: HTMLLabelElement | null = null;
if (config.label) {
labelElement = document.createElement("label");
labelElement.textContent = config.label;
labelElement.className = "input-label";
//? Associate label with input later via ID
shadowRoot.appendChild(labelElement); // Append label first if present
}
// --- Wrapper for Input and (optional) Button ---
const inputWrapper = document.createElement("div");
inputWrapper.className = "input-wrapper";
const inputElement = document.createElement("input");
inputElement.type = "text";
inputElement.className = "main-input";
inputElement.value = config.initialValueAttr ?? ""; // Use value from attribute initially
inputElement.placeholder = config.placeholder ?? "";
//^ Apply button mode logic
if (config.isButton) {
inputElement.readOnly = true; // Make input non-editable
//? Attach modal trigger directly to input click
inputElement.addEventListener("click", handlers.handleModalTrigger);
//? Add data attribute to host for styling
(shadowRoot.host as HTMLElement).dataset.buttonMode = 'true';
} else {
//? Normal input mode - listen for changes
inputElement.addEventListener("change", handlers.handleInputChange);
}
// ... (associate label if present) ...
inputWrapper.appendChild(inputElement);
let modalTriggerElement: HTMLElement = inputElement; // Default trigger is input
//^ Create separate button only if needed AND not in button mode
if (needsSeparateButton) {
const buttonElement = document.createElement("button");
// ... (create button content/attributes) ...
buttonElement.addEventListener("click", handlers.handleModalTrigger);
inputWrapper.appendChild(buttonElement);
modalTriggerElement = buttonElement; // Button is now the trigger
}
shadowRoot.appendChild(inputWrapper);
return {
wrapper: shadowRoot.host as HTMLElement,
labelElement,
inputElement,
modalTriggerElement // Might be input or button depending on mode
};
}

View file

@ -0,0 +1,49 @@
import type { TextInputDOMElements } from "../TextInput.types";
/** Updates the value displayed in the input element. */
export function updateInputValue(elements: TextInputDOMElements, newValue: string): void {
if (elements.inputElement && elements.inputElement.value !== newValue) {
elements.inputElement.value = newValue;
} else if (!elements.inputElement) {
console.error("[TextInputUI] inputElement not found for value update.");
}
}
/** Updates static UI parts like the label text. */
export function updateStaticTextUI(elements: TextInputDOMElements, label?: string): void {
if (elements.labelElement && label) {
elements.labelElement.textContent = label;
}
//? Could add updates for aria-labels here if needed
}
/** Sets the component to visually indicate an error state and disables input/button. */
export function setTextErrorState(hostElement: HTMLElement, elements: TextInputDOMElements, message: string = "Error"): void {
if (elements.inputElement) {
elements.inputElement.disabled = true; // Disable input on error
elements.inputElement.style.borderColor = "var(--text-error)"; // Example styling
}
if (elements.modalTriggerElement && elements.modalTriggerElement !== elements.inputElement) {
// Disable separate button if it exists
(elements.modalTriggerElement as HTMLButtonElement).disabled = true;
(elements.modalTriggerElement as HTMLButtonElement).style.borderColor = "var(--text-error)";
}
hostElement.classList.add('error');
hostElement.setAttribute('aria-disabled', 'true'); // Indicate disabled state
hostElement.setAttribute('title', message);
}
/** Clears any visual error indication and enables input/button. */
export function clearTextErrorState(hostElement: HTMLElement, elements: TextInputDOMElements): void {
if (elements.inputElement) {
elements.inputElement.disabled = false;
elements.inputElement.style.borderColor = ""; // Reset border
}
if (elements.modalTriggerElement && elements.modalTriggerElement !== elements.inputElement) {
(elements.modalTriggerElement as HTMLButtonElement).disabled = false;
(elements.modalTriggerElement as HTMLButtonElement).style.borderColor = "";
}
hostElement.classList.remove('error');
hostElement.removeAttribute('aria-disabled');
hostElement.removeAttribute('title');
}

View file

@ -0,0 +1,15 @@
import type { TextInput } from "../TextInput";
export type InputChangeEventHandler = (event: Event) => void;
/** Creates the handler for the input element's 'change' event. */
export function createInputChangeHandler(instance: TextInput): InputChangeEventHandler {
return (event: Event) => {
if (event.target instanceof HTMLInputElement) {
const newValue = event.target.value;
//? Access component's method directly
//? Pass true to trigger save on 'change' event
instance._setValue(newValue, true);
}
};
}

View file

@ -0,0 +1,42 @@
import type { TextInput } from "../TextInput";
import { DatePickerModal } from "src/components/DatePickerModal/DatePickerModal";
import { TimePickerModal } from "src/components/TimePickerModal/TimePickerModal";
export type ModalTriggerHandler = (event: MouseEvent) => void;
/** //? Creates the handler for the modal trigger element's 'click' event. */
//^ Accept the component instance 'this' instead of props object
export function createModalTriggerHandler(instance: TextInput): ModalTriggerHandler {
return (event: MouseEvent) => {
const modalType = instance.config.modalType; // Get current modalType from config
const app = instance.appInstance; // Get app instance
const currentValue = instance.currentValue; // Get current value
const label = instance.config.label; // Get label for logging
if (!app) {
console.error(`[ModalTriggerHandler ${label}] Cannot open modal: App instance is missing.`);
return;
}
switch (modalType) {
case 'time-picker':
new TimePickerModal(app, currentValue, (selectedTime: string) => {
//? Use component's method to set value
instance._setValue(selectedTime, true);
}).open();
break;
case 'date-picker':
const initialDate = currentValue || new Date().toISOString().split('T')[0];
new DatePickerModal(app, initialDate, (selectedDate: string) => {
instance._setValue(selectedDate, true);
}).open();
break;
case 'none':
default:
console.warn(`[ModalTriggerHandler ${label}] Clicked trigger but modalType is '${modalType}'.`);
break;
}
};
}

View file

@ -0,0 +1,57 @@
import { App } from "obsidian";
import { TextInput } from "./TextInput";
import { DBService } from "src/DBService";
//? Define element if needed
if (!customElements.get("text-input")) {
customElements.define("text-input", TextInput);
}
/**
* Finds placeholder elements and replaces them with initialized TextInput components.
* @param el The container element to search within.
* @param app The Obsidian App instance.
*/
export const registerTextInput = (el: HTMLElement, app: App, dbService: DBService) => {
const placeholders = el.querySelectorAll("span.text-input-placeholder");
placeholders.forEach((placeholderEl) => {
if (!(placeholderEl instanceof HTMLElement)) return;
//~ Read all attributes
const configAttrs: Record<string, string | undefined> = {
label: placeholderEl.dataset.label,
placeholder: placeholderEl.dataset.placeholder,
initialValue: placeholderEl.dataset.initialValue,
modalType: placeholderEl.dataset.modalType,
isButton: placeholderEl.dataset.isButton, // Read button mode flag
table: placeholderEl.dataset.table,
date: placeholderEl.dataset.date,
valueCol: placeholderEl.dataset.valueCol,
dateCol: placeholderEl.dataset.dateCol,
};
try {
const component = document.createElement("text-input") as TextInput;
//~ Set all attributes on the component element
Object.entries(configAttrs).forEach(([key, value]) => {
if (value !== undefined) {
// Convert camelCase (like isButton) to kebab-case for attributes
const attrName = key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
component.setAttribute(`data-${attrName}`, value);
}
});
// Manually set placeholder if needed as it's not a data-* attribute
if (configAttrs.placeholder) component.setAttribute("placeholder", configAttrs.placeholder);
//^ Inject BOTH dependencies AFTER setting attributes
component.setDependencies(dbService, app);
placeholderEl.replaceWith(component);
} catch (error) {
console.error(`[TextInput Reg] Error processing placeholder:`, error, placeholderEl); // Keep error log
placeholderEl.textContent = "[Error Loading Input]";
}
});
}

View file

@ -0,0 +1,12 @@
import { getTextInputStyles } from "./getTextInputStyles";
/** Creates a style element and appends it to the shadow root. */
export function applyTextInputStyles(shadowRoot: ShadowRoot): void {
if (!shadowRoot) {
console.error("[TextInputStyles] Cannot apply styles: ShadowRoot is null.");
return;
}
const style = document.createElement("style");
style.textContent = getTextInputStyles();
shadowRoot.appendChild(style);
}

View file

@ -0,0 +1,133 @@
/** Returns CSS string for the TextInput component's Shadow DOM. */
export function getTextInputStyles(): string {
return `
:host {
display: inline-flex; /* Or block, depending on desired layout */
flex-direction: column; /* Stack label and input/button */
gap: var(--size-4-1); /* Space between label and input row */
font-family: var(--font-text);
width: 100%; /* Default to full width, can be overridden */
}
.input-wrapper {
display: flex;
align-items: stretch; /* Make input and button same height */
width: 100%;
}
/* Optional Label Styling */
.input-label {
font-size: var(--font-ui-small);
font-weight: var(--font-semibold);
color: var(--text-muted);
padding-left: var(--size-4-2); /* Indent slightly */
margin-bottom: -var(--size-4-1); /* Pull input closer if label exists */
}
/* Main Input Element Styling */
input[type="text"].main-input {
flex-grow: 1; /* Take available space */
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-2) var(--size-4-3);
font-size: var(--font-ui-normal);
background-color: var(--background-secondary);
color: var(--text-normal);
min-width: 50px; /* Prevent collapsing too small */
transition: border-color 0.1s ease-in-out;
}
input[type="text"].main-input:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--interactive-accent-translucent);
}
input[type="text"].main-input::placeholder {
color: var(--text-faint);
opacity: 0.8;
}
input[type="text"].main-input {
/* ... existing input styles ... */
/* //? Default cursor */
cursor: text;
}
/* //^ Styles for Button Mode */
:host([data-button-mode="true"]) input[type="text"].main-input {
cursor: pointer; /* Indicate clickability */
}
:host([data-button-mode="true"]) input[type="text"].main-input:hover {
background-color: var(--background-modifier-hover);
}
/* //? Hide the separate trigger button in button mode */
:host([data-button-mode="true"]) button.modal-trigger {
display: none;
}
/* //? Ensure input border radius is correct in button mode */
:host([data-button-mode="true"]) input[type="text"].main-input {
border-top-right-radius: var(--radius-m);
border-bottom-right-radius: var(--radius-m);
}
/* Modal Trigger Button Styling (if used) */
button.modal-trigger {
flex-shrink: 0; /* Don't shrink the button */
border: 1px solid var(--background-modifier-border);
border-left: none; /* Join with input */
background-color: var(--background-secondary);
color: var(--text-muted);
padding: 0 var(--size-4-3);
cursor: pointer;
border-top-right-radius: var(--radius-m);
border-bottom-right-radius: var(--radius-m);
font-size: var(--font-ui-small);
display: flex;
align-items: center;
justify-content: center;
}
/* Adjust input border radius when button is present */
input[type="text"].main-input + button.modal-trigger {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
button.modal-trigger:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
button.modal-trigger:active {
background-color: var(--background-modifier-active);
}
/* Styling when input itself is the trigger (no separate button) */
:host([data-input-as-trigger="true"]) input[type="text"].main-input {
cursor: pointer; /* Indicate clickability */
/* Optionally change appearance slightly */
/* background-image: url('data:image/svg+xml,...'); // Add dropdown arrow */
}
:host([data-input-as-trigger="true"]) input[type="text"].main-input:hover {
/* background-color: var(--background-modifier-hover); // Subtle hover on input */
}
/* Error State */
:host(.error) input[type="text"].main-input,
:host(.error) button.modal-trigger {
border-color: var(--text-error);
}
:host(.error) input[type="text"].main-input:focus {
box-shadow: 0 0 0 2px var(--text-error-translucent);
}
/* Disabled State */
:host([aria-disabled="true"]) {
opacity: 0.6;
cursor: not-allowed;
}
:host([aria-disabled="true"]) input[type="text"].main-input,
:host([aria-disabled="true"]) button.modal-trigger {
background-color: var(--background-modifier-border-hover);
cursor: not-allowed;
}
`;
}

View file

@ -0,0 +1,35 @@
/**
* Creates the HTMLButtonElement for the timestamp updater.
*
* @param onClick The async function to execute when the button is clicked.
* @returns The created HTMLButtonElement.
*/
export function createTimestampButtonElement(onClick: () => Promise<void>): HTMLButtonElement {
const button = document.createElement('button');
button.setText('Update Timestamp');
button.addClass('timestamp-updater-button');
button.style.backgroundColor = 'var(--interactive-accent)';
button.style.color = 'var(--text-on-accent)';
button.style.border = 'none';
button.style.padding = '6px 12px';
button.style.borderRadius = '4px';
button.style.cursor = 'pointer';
button.style.fontSize = '0.9em';
button.addEventListener('click', async (event) => {
event.stopPropagation();
button.disabled = true;
button.style.opacity = '0.7';
button.style.cursor = 'wait';
try {
await onClick();
} finally {
button.disabled = false;
button.style.opacity = '1';
button.style.cursor = 'pointer';
}
});
return button;
}

View file

@ -0,0 +1,42 @@
import { App, MarkdownPostProcessorContext, Notice, Plugin, TFile } from 'obsidian';
import { updateTimestampInFile } from './timestampFileUpdater';
import { createTimestampButtonElement } from './createTimestampButtonElement';
/**
* Registers the Markdown postprocessor to find <timestamp-updater-button>
* tags and replace them with functional buttons.
*
* @param plugin The Obsidian Plugin instance.
*/
export function registerTimestampUpdaterButton(plugin: Plugin): void {
plugin.registerMarkdownPostProcessor((element, context: MarkdownPostProcessorContext) => {
const { app } = plugin;
const placeholderElements = element.querySelectorAll('.timestamp-updater-placeholder');
if (placeholderElements.length === 0) {
return;
}
placeholderElements.forEach((placeholderEl) => {
const handleClick = async () => {
const activeFile = app.workspace.getActiveFile();
if (!activeFile || !(activeFile instanceof TFile)) {
new Notice('No active file selected.', 3000);
return;
}
const result = await updateTimestampInFile(app, activeFile);
new Notice(result.message, result.success ? 2000 : 5000);
};
const buttonElement = createTimestampButtonElement(handleClick);
try {
placeholderEl.replaceWith(buttonElement);
} catch (e) {
console.error("Error during replaceWith:", e);
}
});
});
}

View file

@ -0,0 +1,88 @@
import { App, Notice, TFile } from 'obsidian';
// Regex patterns are specific to this module's responsibility
const frontmatterRegex = /^---\s*([\s\S]*?)\s*---/;
const updatedAtRegexLine = /^(updatedAt:\s*)(.*)$/m; // Find existing line
const closingDashRegex = /^\s*---\s*$/; // To find the closing dashes
interface UpdateResult {
success: boolean;
message: string;
}
/**
* Reads the file, updates the 'updatedAt' timestamp in the frontmatter manually,
* and saves the file back.
*
* @param app Obsidian App instance
* @param file The TFile to modify
* @returns Promise resolving to an UpdateResult object
*/
export async function updateTimestampInFile(app: App, file: TFile): Promise<UpdateResult> {
try {
const content = await app.vault.read(file);
const match = content.match(frontmatterRegex);
if (!match || !match[0]) {
return { success: false, message: 'No frontmatter found in this file.' };
}
const fullFrontmatterBlock = match[0];
const frontmatterContent = match[1];
const body = content.substring(fullFrontmatterBlock.length);
const newTimestamp = new Date().toISOString();
let existingTimestamp: string | null = null;
let updatedFrontmatterBlock: string;
const updatedAtMatch = frontmatterContent.match(updatedAtRegexLine);
if (updatedAtMatch && updatedAtMatch[2]) {
existingTimestamp = updatedAtMatch[2].trim();
}
if (existingTimestamp === newTimestamp) {
return { success: true, message: `Timestamp already up-to-date (${newTimestamp.split('T')[1].substring(0, 8)}).` };
}
// Manual Update Logic
if (updatedAtMatch) {
// Found existing line: replace the value part
updatedFrontmatterBlock = fullFrontmatterBlock.replace(
updatedAtRegexLine,
`$1${newTimestamp}`
);
} else {
// Didn't find the line: Append it before the closing '---'
const lines = fullFrontmatterBlock.trimEnd().split('\n');
// Find the index of the closing '---' line
const closingDashIndex = lines.findIndex(line => closingDashRegex.test(line));
if (closingDashIndex > 0) { // Ensure closing dashes are found and not the first line
lines.splice(closingDashIndex, 0, `updatedAt: ${newTimestamp}`); // Insert before closing dashes
updatedFrontmatterBlock = lines.join('\n');
// Add back potential trailing newline if original block had one
if (fullFrontmatterBlock.endsWith('\n') && !updatedFrontmatterBlock.endsWith('\n')) {
updatedFrontmatterBlock += '\n';
}
} else {
console.warn(`FileUpdater: Could not find closing '---' to append timestamp in ${file.basename}`);
return { success: false, message: 'Could not reliably update frontmatter (structure error?).' };
}
}
// Reconstruct the full content
const newContent = updatedFrontmatterBlock + body;
// Check if content actually changed before writing (prevents unnecessary file modifications)
if (newContent === content) {
return { success: true, message: `Timestamp already effectively up-to-date.` };
}
await app.vault.modify(file, newContent);
return { success: true, message: `Updated timestamp for ${file.basename}` };
} catch (error: any) {
console.error(`FileUpdater: Error processing file ${file.basename}`, error);
return { success: false, message: `Error updating timestamp: ${error.message}` };
}
}

View file

@ -0,0 +1,19 @@
import { registerHabitCounter } from "./HabitCounter/registerHabitCounter";
import { registerBooleanSwitch } from "./BooleanSwitch/registerBooleanSwitch";
import { registerTextInput } from "./TextInput/registerTextInput";
import { registerTimestampUpdaterButton } from "./TimestampUpdaterButton/registerTimestampUpdaterButton";
import { registerSqlChartRenderer } from "./SqlChart/registerSqlChart";
import { registerSqlRenderer } from "./Sql/registerSql";
import { registerMoodNoteButtonProcessor } from "./MoodNote/moodButtonProcessor";
import { registerAddTextSupport } from "./AddTextButton/AddTextButton";
export {
registerHabitCounter,
registerBooleanSwitch,
registerTextInput,
registerTimestampUpdaterButton,
registerSqlChartRenderer,
registerSqlRenderer,
registerMoodNoteButtonProcessor,
registerAddTextSupport
};

View file

@ -0,0 +1,93 @@
import { DBService } from "../../DBService";
import { BooleanRecord, BooleanSwitchDataArgs, UpsertBooleanSwitchArgs } from "../BooleanSwitch/BooleanSwitch.types";
import { quoteSqlIdentifier } from "./utils/quoteSqlIdentifier";
import { validateBaseBooleanArgs, validateUpsertBooleanArgs } from "./utils/validateBooleanArgs";
export class BooleanSwitchDataService {
private dbService: DBService;
constructor(dbService: DBService) {
if (!dbService) throw new Error("BooleanSwitchDataService requires a valid DBService instance.");
this.dbService = dbService;
}
/** Fetches the current 0/1 value for a specific key/date. Returns 0 if not found. */
async fetchBooleanValue(args: BooleanSwitchDataArgs): Promise<0 | 1> {
try {
validateBaseBooleanArgs(args);
const safeTable = quoteSqlIdentifier(args.table);
const safeKeyIdCol = quoteSqlIdentifier(args.habitIdCol);
const safeValueCol = quoteSqlIdentifier(args.valueCol);
const safeDateCol = quoteSqlIdentifier(args.dateCol);
const sql = `SELECT ${safeValueCol} AS value FROM ${safeTable} WHERE ${safeKeyIdCol} = ? AND ${safeDateCol} = ?`;
const params = [args.habitKey, args.date];
const result = await this.dbService.getQuery<BooleanRecord>(sql, params);
const value = result?.[0]?.value;
//^ Return 1 if value is 1, otherwise default to 0
return value === 1 ? 1 : 0;
} catch (error) {
console.error(`[BoolDataService] Error in fetchBooleanValue for ${args.habitKey}:`, error);
throw error;
}
}
/** Upserts the 0/1 value. Handles optional UUID column logic. */
async upsertBooleanValue(args: UpsertBooleanSwitchArgs): Promise<void> {
try {
validateUpsertBooleanArgs(args);
const safeTable = quoteSqlIdentifier(args.table);
const safeHabitIdCol = quoteSqlIdentifier(args.habitIdCol);
const safeValueCol = quoteSqlIdentifier(args.valueCol);
const safeDateCol = quoteSqlIdentifier(args.dateCol);
const updatedAtCol = quoteSqlIdentifier("updatedAt"); //! 🔒 Hardcoded
const now = new Date().toISOString(); //~ Current timestamp for updatedAt
let existingUuid: string | undefined;
try {
//^ Attempt to fetch UUID. Assumes the column is named 'uuid' if used.
const fetchUuidSql = `SELECT uuid FROM ${safeTable} WHERE ${safeHabitIdCol} = ? AND ${safeDateCol} = ?`;
const uuidResult = await this.dbService.getQuery<{ uuid: string }>(fetchUuidSql, [args.habitKey, args.date]);
existingUuid = uuidResult?.[0]?.uuid;
} catch (error) {
if (error instanceof Error && /no such column|does not exist/i.test(error.message) && error.message.includes('uuid')) {
console.log(`[BoolDataService] Info: Optional 'uuid' column not found in table '${args.table}'. Proceeding with standard upsert.`);
existingUuid = undefined; // Ensure it's undefined so the INSERT path is taken
} else {
console.error(`[BoolDataService] Error fetching potential UUID for ${args.habitKey} on ${args.date}:`, error);
throw error; // Re-throw unexpected errors
}
}
if (existingUuid) {
const updateSql = `UPDATE ${safeTable} SET ${safeValueCol} = ?, ${updatedAtCol} = ? WHERE uuid = ?`;
const updateParams = [args.newValue, now, existingUuid];
await this.dbService.runQuery(updateSql, updateParams);
} else {
const insertSql = `
INSERT INTO ${safeTable} (${safeHabitIdCol}, ${safeDateCol}, ${safeValueCol}, ${updatedAtCol})
VALUES (?, ?, ?, ?)
ON CONFLICT(${safeHabitIdCol}, ${safeDateCol}) DO UPDATE
SET ${safeValueCol} = excluded.${safeValueCol},
${updatedAtCol} = excluded.${updatedAtCol};
`;
const insertParams = [args.habitKey, args.date, args.newValue, now];
await this.dbService.runQuery(insertSql, insertParams);
}
} catch (error) {
console.error(`[BoolDataService] Error in upsertBooleanValue for ${args.habitKey}:`, error);
if (error instanceof Error && error.message.includes("ON CONFLICT clause does not match")) {
//^ Provide more specific guidance mentioning the user-provided columns
console.error(`//! DB Constraint Error: Table '${args.table}' needs a UNIQUE index/key on (${args.habitIdCol}, ${args.dateCol}). Run: CREATE UNIQUE INDEX IF NOT EXISTS idx_habit_date ON ${quoteSqlIdentifier(args.table)} (${quoteSqlIdentifier(args.habitIdCol)}, ${quoteSqlIdentifier(args.dateCol)});`);
throw new Error(`Missing UNIQUE constraint on (${args.habitIdCol}, ${args.dateCol}) in table ${args.table}. ${error.message}`);
}
throw error;
}
}
}

Some files were not shown because too many files have changed in this diff Show more