mirror of
https://github.com/stfrigerio/sqliteDB.git
synced 2026-07-22 12:00:23 +00:00
Compare commits
38 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b67a99dad2 | ||
|
|
21a5565f1f | ||
|
|
2e9f605cfb | ||
|
|
c209aef215 | ||
|
|
c6663b61b0 | ||
|
|
c44f52bce2 | ||
|
|
0b17f4f3c2 | ||
|
|
9ab65793df | ||
|
|
add626d956 | ||
|
|
148c874f02 | ||
|
|
df2f429049 | ||
|
|
0b0fb67f36 | ||
|
|
49af0fb0a2 | ||
|
|
08ab87653d | ||
|
|
96b6832e25 | ||
|
|
84ad5370fb | ||
|
|
6d6759bda1 | ||
|
|
cbf9d4f81d | ||
|
|
bddc9800a8 | ||
|
|
48cb74f09a | ||
|
|
6550a6570e | ||
|
|
d8cc77f863 | ||
|
|
6ca1207333 | ||
|
|
466b239153 | ||
|
|
3de4c0be71 | ||
|
|
fadd4061e1 | ||
|
|
3cb1f783af | ||
|
|
e892ce9d9c | ||
|
|
ad87e0e851 | ||
|
|
9c1020b8d3 | ||
|
|
cba7bda90a | ||
|
|
0d1bf53ea8 | ||
|
|
6ced6f6cca | ||
|
|
944afbd61b | ||
|
|
90f087ffe3 | ||
|
|
a760527acf | ||
|
|
f97f55ff67 | ||
|
|
1700e84f15 |
109 changed files with 6279 additions and 552 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -20,3 +20,5 @@ data.json
|
||||||
|
|
||||||
# Exclude macOS Finder (System Explorer) View States
|
# Exclude macOS Finder (System Explorer) View States
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
.env
|
||||||
148
README.md
148
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# SQLite DB Plugin for Obsidian
|
# SQLite DB Plugin
|
||||||
|
|
||||||
The **SQLite DB Plugin for Obsidian** allows you to interact with SQLite databases directly within your Obsidian vault. You can execute SQL queries, generate charts from your data, inspect table structures, and even export table rows as notes.
|
The **SQLite DB Plugin** allows you to interact with SQLite databases directly within your Obsidian vault. You can execute SQL queries, generate charts from your data, inspect table structures, and even export table rows as notes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -19,7 +19,7 @@ The **SQLite DB Plugin for Obsidian** allows you to interact with SQLite databas
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Configuration
|
||||||
|
|
||||||
1. **Open Obsidian Settings**
|
1. **Open Obsidian Settings**
|
||||||
Navigate to **Community Plugins** and disable **Safe Mode**.
|
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**.
|
Search for **SQLite DB Plugin**, then click **Install**.
|
||||||
3. **Enable the Plugin**
|
3. **Enable the Plugin**
|
||||||
In your Community Plugins list, 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:
|
📁 Local Mode Settings
|
||||||
- **Database Path:** Absolute path to your SQLite database file.
|
Used when Database mode is set to Local. Here you can set your full absolute path to your .db SQLite file
|
||||||
_Example:_ `/home/user/path/to/database.db`
|
|
||||||
|
🌐 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
|
## Usage
|
||||||
|
|
||||||
### SQL Code Blocks
|
### Codeblocks
|
||||||
|
|
||||||
|
#### SQL Code Blocks
|
||||||
|
|
||||||
Create a code block labeled with `sql` to run a SQL query. For example:
|
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.
|
- Order the result by the `due` column in ascending order.
|
||||||
- Limit the number of rows to 10.
|
- 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
|
|
||||||
```
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Query Parameters
|
|
||||||
|
|
||||||
Below is a list of available parameters you can use in your SQL blocks:
|
Below is a list of available parameters you can use in your SQL blocks:
|
||||||
|
|
||||||
| Parameter | Description | Example |
|
| 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` |
|
| `endDate` | Ending date for filtering. | `endDate: 2024-12-31` |
|
||||||
| `orderBy` | Column to order the results by. | `orderBy: due` |
|
| `orderBy` | Column to order the results by. | `orderBy: due` |
|
||||||
| `orderDirection`| Direction of sort (`asc` or `desc`). | `orderDirection: asc` |
|
| `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
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
| Parameter | Description | Example |
|
| 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:
|
Each chart takes an optional chartOptions object that can be used to customize the different chart types:
|
||||||
|
|
||||||
### Line Chart
|
**Chart types**
|
||||||
|
|
||||||
```sql-chart
|
```sql-chart
|
||||||
|
|
||||||
|
|
@ -134,7 +146,6 @@ chartOptions: {
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Bar Chart
|
|
||||||
|
|
||||||
```sql-chart
|
```sql-chart
|
||||||
table: QuantifiableHabits
|
table: QuantifiableHabits
|
||||||
|
|
@ -157,8 +168,6 @@ chartOptions: {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pie Chart
|
|
||||||
|
|
||||||
```sql-chart
|
```sql-chart
|
||||||
|
|
||||||
table: Money
|
table: Money
|
||||||
|
|
@ -175,4 +184,79 @@ chartOptions: {
|
||||||
showLegend: true,
|
showLegend: true,
|
||||||
isDoughnut: 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>
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 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>
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 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 💤"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
|
||||||
BIN
assets/boolean.png
Normal file
BIN
assets/boolean.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7 KiB |
BIN
assets/habits.png
Normal file
BIN
assets/habits.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/text-input.png
Normal file
BIN
assets/text-input.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
159
main.ts
159
main.ts
|
|
@ -1,39 +1,92 @@
|
||||||
import {
|
import {
|
||||||
App,
|
|
||||||
Plugin,
|
Plugin,
|
||||||
PluginSettingTab,
|
|
||||||
Setting,
|
|
||||||
Notice,
|
|
||||||
FileSystemAdapter,
|
FileSystemAdapter,
|
||||||
Editor,
|
Editor,
|
||||||
MarkdownView,
|
MarkdownView,
|
||||||
MarkdownPostProcessorContext
|
MarkdownPostProcessorContext,
|
||||||
|
Notice,
|
||||||
} from "obsidian";
|
} from "obsidian";
|
||||||
|
|
||||||
import { DBService } from "./src/dbService";
|
import { DBService } from "./src/DBService";
|
||||||
import { inspectTableStructure, convertEntriesInNotes } from "./src/commands";
|
import { SQLiteDBSettingTab } from "./src/settingTab";
|
||||||
import { processSqlBlock, processSqlChartBlock } from "./src/codeblocks";
|
import { inspectTableStructure, convertEntriesInNotes, syncDBToJournals, syncJournalsToDB } from "./src/commands";
|
||||||
import { pickTableName } from "./src/helpers";
|
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 { SQLiteDBSettings, DEFAULT_SETTINGS } from "./src/types";
|
||||||
|
import { pluginState } from "src/pluginState";
|
||||||
|
|
||||||
export default class SQLiteDBPlugin extends Plugin {
|
export default class SQLiteDBPlugin extends Plugin {
|
||||||
settings: SQLiteDBSettings;
|
settings: SQLiteDBSettings;
|
||||||
private dbService: DBService;
|
private dbService: DBService;
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
console.log("Loading SQLiteDBPlugin...");
|
// init
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
|
|
||||||
this.dbService = new DBService(this.app);
|
this.dbService = new DBService(this.app);
|
||||||
|
|
||||||
await this.openDatabase();
|
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({
|
this.addCommand({
|
||||||
id: "inspect-table-structure",
|
id: "inspect-table-structure",
|
||||||
name: "Inspect table structure",
|
name: "Inspect table structure",
|
||||||
editorCallback: async (editor: Editor, view: MarkdownView) => {
|
editorCallback: async (editor: Editor, view: MarkdownView) => {
|
||||||
// ensure DB is loaded (if not, load)
|
|
||||||
await this.openDatabase();
|
|
||||||
await inspectTableStructure(this.dbService, editor, this.app);
|
await inspectTableStructure(this.dbService, editor, this.app);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -41,9 +94,7 @@ export default class SQLiteDBPlugin extends Plugin {
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "dump-table-to-notes",
|
id: "dump-table-to-notes",
|
||||||
name: "Dump table to notes",
|
name: "Dump table to notes",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
await this.openDatabase(); // ensure DB is loaded
|
|
||||||
|
|
||||||
// 1) pick a table
|
// 1) pick a table
|
||||||
const chosenTable = await pickTableName(this.dbService, this.app);
|
const chosenTable = await pickTableName(this.dbService, this.app);
|
||||||
if (!chosenTable) {
|
if (!chosenTable) {
|
||||||
|
|
@ -55,39 +106,55 @@ export default class SQLiteDBPlugin extends Plugin {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.registerMarkdownCodeBlockProcessor(
|
this.addCommand({
|
||||||
"sql", // <-- the name of your code block (```sql)
|
id: 'sync-journal-notes-to-db',
|
||||||
async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
|
name: 'Sync Journal Notes -> Database',
|
||||||
await processSqlBlock(this.dbService, source, el);
|
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(
|
this.addCommand({
|
||||||
"sql-chart",
|
id: 'sync-journal-db-to-notes',
|
||||||
async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
|
name: 'Sync Database -> Journal Notes',
|
||||||
await processSqlChartBlock(this.dbService, source, el);
|
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));
|
this.addSettingTab(new SQLiteDBSettingTab(this.app, this));
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload() {
|
onunload() {
|
||||||
console.log("Unloading SQLiteDBPlugin...");
|
removeDateNavigatorStyles();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openDatabase(forceReload = true) {
|
private async openDatabase(forceReload = true) {
|
||||||
const adapter = this.app.vault.adapter;
|
const adapter = this.app.vault.adapter;
|
||||||
let basePath: string;
|
let basePath: string;
|
||||||
|
|
||||||
if (adapter instanceof FileSystemAdapter) {
|
if (adapter instanceof FileSystemAdapter) {
|
||||||
basePath = adapter.getBasePath();
|
basePath = adapter.getBasePath();
|
||||||
} else {
|
} else {
|
||||||
basePath = (adapter as any).getFullPath("");
|
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() {
|
async loadSettings() {
|
||||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
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();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "sqlite-db",
|
"id": "sqlite-db",
|
||||||
"name": "SQLite DB",
|
"name": "SQLite DB",
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"minAppVersion": "0.15.0",
|
"minAppVersion": "0.15.0",
|
||||||
"description": "Interact with local SQLite files in your notes",
|
"description": "Interact with local SQLite files in your notes",
|
||||||
"author": "Stefano Frigerio",
|
"author": "Stefano Frigerio",
|
||||||
|
|
|
||||||
162
package-lock.json
generated
162
package-lock.json
generated
|
|
@ -1,14 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "sqlitedb",
|
"name": "sqlitedb",
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sqlitedb",
|
"name": "sqlitedb",
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"sql.js": "^1.12.0"
|
"sql.js": "^1.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -403,7 +404,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
|
||||||
"integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==",
|
"integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eslint-visitor-keys": "^3.4.3"
|
"eslint-visitor-keys": "^3.4.3"
|
||||||
},
|
},
|
||||||
|
|
@ -422,7 +422,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
|
||||||
"integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
|
"integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
"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",
|
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
|
||||||
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
|
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^6.12.4",
|
"ajv": "^6.12.4",
|
||||||
"debug": "^4.3.2",
|
"debug": "^4.3.2",
|
||||||
|
|
@ -456,7 +454,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
||||||
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
|
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -467,7 +464,6 @@
|
||||||
"integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
|
"integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
|
||||||
"deprecated": "Use @eslint/config-array instead",
|
"deprecated": "Use @eslint/config-array instead",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@humanwhocodes/object-schema": "^2.0.3",
|
"@humanwhocodes/object-schema": "^2.0.3",
|
||||||
"debug": "^4.3.1",
|
"debug": "^4.3.1",
|
||||||
|
|
@ -482,7 +478,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||||
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
|
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.22"
|
"node": ">=12.22"
|
||||||
},
|
},
|
||||||
|
|
@ -496,15 +491,19 @@
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
|
||||||
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
||||||
"deprecated": "Use @eslint/object-schema instead",
|
"deprecated": "Use @eslint/object-schema instead",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": 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": {
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
|
|
@ -640,6 +639,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.29.0.tgz",
|
||||||
"integrity": "sha512-ruKWTv+x0OOxbzIw9nW5oWlUopvP/IQDjB5ZqmTglLIoDTctLlAJpAQFpNPJP/ZI7hTT9sARBosEfaKbcFuECw==",
|
"integrity": "sha512-ruKWTv+x0OOxbzIw9nW5oWlUopvP/IQDjB5ZqmTglLIoDTctLlAJpAQFpNPJP/ZI7hTT9sARBosEfaKbcFuECw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "5.29.0",
|
"@typescript-eslint/scope-manager": "5.29.0",
|
||||||
"@typescript-eslint/types": "5.29.0",
|
"@typescript-eslint/types": "5.29.0",
|
||||||
|
|
@ -790,8 +790,7 @@
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.14.0",
|
"version": "8.14.0",
|
||||||
|
|
@ -811,7 +810,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
|
||||||
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
|
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"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",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
|
|
@ -838,7 +835,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
|
|
@ -848,7 +844,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
},
|
},
|
||||||
|
|
@ -863,8 +858,7 @@
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/array-union": {
|
"node_modules/array-union": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
|
|
@ -879,15 +873,13 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
|
|
@ -922,7 +914,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
|
|
@ -932,7 +923,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.1.0",
|
"ansi-styles": "^4.1.0",
|
||||||
"supports-color": "^7.1.0"
|
"supports-color": "^7.1.0"
|
||||||
|
|
@ -944,12 +934,23 @@
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
},
|
},
|
||||||
|
|
@ -961,22 +962,19 @@
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-key": "^3.1.0",
|
"path-key": "^3.1.0",
|
||||||
"shebang-command": "^2.0.0",
|
"shebang-command": "^2.0.0",
|
||||||
|
|
@ -1007,8 +1005,7 @@
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/dir-glob": {
|
"node_modules/dir-glob": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
|
|
@ -1027,7 +1024,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||||
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
|
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esutils": "^2.0.2"
|
"esutils": "^2.0.2"
|
||||||
},
|
},
|
||||||
|
|
@ -1077,7 +1073,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
|
|
@ -1199,7 +1194,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
||||||
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
|
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esrecurse": "^4.3.0",
|
"esrecurse": "^4.3.0",
|
||||||
"estraverse": "^5.2.0"
|
"estraverse": "^5.2.0"
|
||||||
|
|
@ -1216,7 +1210,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
||||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1226,7 +1219,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
||||||
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
|
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "^8.9.0",
|
"acorn": "^8.9.0",
|
||||||
"acorn-jsx": "^5.3.2",
|
"acorn-jsx": "^5.3.2",
|
||||||
|
|
@ -1244,7 +1236,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
|
||||||
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
|
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"estraverse": "^5.1.0"
|
"estraverse": "^5.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -1257,7 +1248,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
||||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1297,7 +1287,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1306,8 +1295,7 @@
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
|
|
@ -1341,15 +1329,13 @@
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/fast-levenshtein": {
|
"node_modules/fast-levenshtein": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.18.0",
|
"version": "1.18.0",
|
||||||
|
|
@ -1365,7 +1351,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
|
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"flat-cache": "^3.0.4"
|
"flat-cache": "^3.0.4"
|
||||||
},
|
},
|
||||||
|
|
@ -1390,7 +1375,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"locate-path": "^6.0.0",
|
"locate-path": "^6.0.0",
|
||||||
"path-exists": "^4.0.0"
|
"path-exists": "^4.0.0"
|
||||||
|
|
@ -1407,7 +1391,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
||||||
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
|
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"flatted": "^3.2.9",
|
"flatted": "^3.2.9",
|
||||||
"keyv": "^4.5.3",
|
"keyv": "^4.5.3",
|
||||||
|
|
@ -1421,15 +1404,13 @@
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz",
|
||||||
"integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==",
|
"integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/fs.realpath": {
|
"node_modules/fs.realpath": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/functional-red-black-tree": {
|
"node_modules/functional-red-black-tree": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
|
|
@ -1443,7 +1424,6 @@
|
||||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fs.realpath": "^1.0.0",
|
"fs.realpath": "^1.0.0",
|
||||||
"inflight": "^1.0.4",
|
"inflight": "^1.0.4",
|
||||||
|
|
@ -1464,7 +1444,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-glob": "^4.0.3"
|
"is-glob": "^4.0.3"
|
||||||
},
|
},
|
||||||
|
|
@ -1477,7 +1456,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
||||||
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
|
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"type-fest": "^0.20.2"
|
"type-fest": "^0.20.2"
|
||||||
},
|
},
|
||||||
|
|
@ -1512,15 +1490,13 @@
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
|
|
@ -1539,7 +1515,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||||
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
|
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"parent-module": "^1.0.0",
|
"parent-module": "^1.0.0",
|
||||||
"resolve-from": "^4.0.0"
|
"resolve-from": "^4.0.0"
|
||||||
|
|
@ -1556,7 +1531,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
|
|
@ -1567,7 +1541,6 @@
|
||||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
"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.",
|
"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,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"once": "^1.3.0",
|
"once": "^1.3.0",
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
|
|
@ -1577,8 +1550,7 @@
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
|
|
@ -1615,7 +1587,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
||||||
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
|
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
|
|
@ -1624,15 +1595,13 @@
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
},
|
},
|
||||||
|
|
@ -1644,29 +1613,25 @@
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||||
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
|
|
@ -1676,7 +1641,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
|
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prelude-ls": "^1.2.1",
|
"prelude-ls": "^1.2.1",
|
||||||
"type-check": "~0.4.0"
|
"type-check": "~0.4.0"
|
||||||
|
|
@ -1690,7 +1654,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-locate": "^5.0.0"
|
"p-locate": "^5.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -1705,8 +1668,7 @@
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
|
|
@ -1735,7 +1697,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
},
|
},
|
||||||
|
|
@ -1762,8 +1723,7 @@
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/obsidian": {
|
"node_modules/obsidian": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
|
|
@ -1784,7 +1744,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
|
|
@ -1794,7 +1753,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deep-is": "^0.1.3",
|
"deep-is": "^0.1.3",
|
||||||
"fast-levenshtein": "^2.0.6",
|
"fast-levenshtein": "^2.0.6",
|
||||||
|
|
@ -1812,7 +1770,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yocto-queue": "^0.1.0"
|
"yocto-queue": "^0.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -1828,7 +1785,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-limit": "^3.0.2"
|
"p-limit": "^3.0.2"
|
||||||
},
|
},
|
||||||
|
|
@ -1844,7 +1800,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"callsites": "^3.0.0"
|
"callsites": "^3.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -1857,7 +1812,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
|
|
@ -1867,7 +1821,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1877,7 +1830,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
|
|
@ -1908,7 +1860,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
|
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1918,7 +1869,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
|
|
@ -1960,7 +1910,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
|
|
@ -1981,7 +1930,6 @@
|
||||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.1.3"
|
"glob": "^7.1.3"
|
||||||
},
|
},
|
||||||
|
|
@ -2032,7 +1980,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shebang-regex": "^3.0.0"
|
"shebang-regex": "^3.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -2045,7 +1992,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
|
|
@ -2069,7 +2015,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
},
|
},
|
||||||
|
|
@ -2082,7 +2027,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
|
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
},
|
},
|
||||||
|
|
@ -2094,15 +2038,13 @@
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
|
||||||
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
|
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-flag": "^4.0.0"
|
"has-flag": "^4.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -2114,8 +2056,7 @@
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
|
|
@ -2161,7 +2102,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
|
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prelude-ls": "^1.2.1"
|
"prelude-ls": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
|
@ -2174,7 +2114,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
||||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
|
|
@ -2187,6 +2126,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz",
|
||||||
"integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==",
|
"integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -2200,7 +2140,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2209,15 +2148,13 @@
|
||||||
"version": "2.2.8",
|
"version": "2.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"isexe": "^2.0.0"
|
"isexe": "^2.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -2233,7 +2170,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2242,15 +2178,13 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "sqlitedb",
|
"name": "sqlitedb",
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"description": "A plugin that allows to interact with sqlite databases in Obsidian",
|
"description": "A plugin that allows to interact with sqlite databases in Obsidian",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"typescript": "4.7.4"
|
"typescript": "4.7.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"sql.js": "^1.12.0"
|
"sql.js": "^1.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
216
src/DBService.ts
Normal file
216
src/DBService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/codeblocks/dateHeader/DateNavigator.ts
Normal file
85
src/codeblocks/dateHeader/DateNavigator.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/codeblocks/dateHeader/dateNavigator.types.ts
Normal file
25
src/codeblocks/dateHeader/dateNavigator.types.ts
Normal 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;
|
||||||
|
}
|
||||||
59
src/codeblocks/dateHeader/dateNavigatorRenderer.ts
Normal file
59
src/codeblocks/dateHeader/dateNavigatorRenderer.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/codeblocks/dateHeader/dom/buildDateNavigatorDOM.ts
Normal file
87
src/codeblocks/dateHeader/dom/buildDateNavigatorDOM.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { processSqlBlock } from "./processSqlBlock";
|
import { processSqlBlock } from "./processSqlBlock";
|
||||||
import { processSqlChartBlock } from "./processSqlChartBlock";
|
import { processSqlChartBlock } from "./processSqlChartBlock";
|
||||||
|
import { DateNavigatorRenderer } from "./dateHeader/dateNavigatorRenderer";
|
||||||
|
|
||||||
export { processSqlBlock, processSqlChartBlock };
|
export { processSqlBlock, processSqlChartBlock, DateNavigatorRenderer };
|
||||||
|
|
@ -1,8 +1,51 @@
|
||||||
import { SqlParams } from "../types";
|
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[] } {
|
export function buildSqlQuery(params: SqlParams): { query: string; queryParams: any[] } {
|
||||||
const {
|
const {
|
||||||
table,
|
table,
|
||||||
columns,
|
columns,
|
||||||
dateColumn,
|
dateColumn,
|
||||||
startDate,
|
startDate,
|
||||||
|
|
@ -11,17 +54,17 @@ export function buildSqlQuery(params: SqlParams): { query: string; queryParams:
|
||||||
filterValue,
|
filterValue,
|
||||||
orderBy,
|
orderBy,
|
||||||
orderDirection,
|
orderDirection,
|
||||||
limit
|
limit
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const selectCols = columns
|
const selectCols = columns
|
||||||
? columns.split(",").map(c => c.trim()).join(", ")
|
? columns.split(",").map(c => `"${c.trim()}"`).join(", ") // Quote columns here
|
||||||
: "*";
|
: "*";
|
||||||
|
|
||||||
const queryParams: any[] = [];
|
const queryParams: any[] = [];
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
|
|
||||||
// Handle multiple filters
|
// Handle multiple filters (already quotes columns)
|
||||||
if (filterColumn && filterValue) {
|
if (filterColumn && filterValue) {
|
||||||
const filterColumns = Array.isArray(filterColumn) ? filterColumn : [filterColumn];
|
const filterColumns = Array.isArray(filterColumn) ? filterColumn : [filterColumn];
|
||||||
const filterValues = Array.isArray(filterValue) ? filterValue : [filterValue];
|
const filterValues = Array.isArray(filterValue) ? filterValue : [filterValue];
|
||||||
|
|
@ -31,44 +74,45 @@ export function buildSqlQuery(params: SqlParams): { query: string; queryParams:
|
||||||
}
|
}
|
||||||
|
|
||||||
filterColumns.forEach((col, index) => {
|
filterColumns.forEach((col, index) => {
|
||||||
|
// Ensure column name is quoted
|
||||||
conditions.push(`"${col}" = ?`);
|
conditions.push(`"${col}" = ?`);
|
||||||
queryParams.push(filterValues[index]);
|
queryParams.push(filterValues[index]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add date range conditions if provided
|
// Add the new logic:
|
||||||
if (dateColumn) {
|
const dateFilter = buildDateWhereClause(dateColumn, startDate, endDate);
|
||||||
if (startDate) {
|
if (dateFilter) {
|
||||||
conditions.push(`"${dateColumn}" >= ?`);
|
conditions.push(dateFilter.clause); // Add the generated clause (e.g., '"col" >= ? AND "col" < ?')
|
||||||
queryParams.push(startDate);
|
queryParams.push(...dateFilter.params); // Add the parameters (e.g., ['2023-10-27', '2023-10-28'])
|
||||||
}
|
|
||||||
if (endDate) {
|
|
||||||
conditions.push(`"${dateColumn}" <= ?`);
|
|
||||||
queryParams.push(endDate);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the query
|
// Build the query
|
||||||
|
// Quote table name for safety
|
||||||
let query = `SELECT ${selectCols} FROM "${table}"`;
|
let query = `SELECT ${selectCols} FROM "${table}"`;
|
||||||
|
|
||||||
// Add WHERE clause if there are conditions
|
// Add WHERE clause if there are conditions
|
||||||
if (conditions.length > 0) {
|
if (conditions.length > 0) {
|
||||||
query += ` WHERE ${conditions.join(" AND ")}`;
|
query += ` WHERE ${conditions.join(" AND ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add ORDER BY if specified
|
// Add ORDER BY if specified (already quotes column)
|
||||||
if (orderBy) {
|
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
|
// Add LIMIT if specified
|
||||||
if (limit) {
|
if (limit !== undefined && limit !== null) { // Check explicitly for presence
|
||||||
query += ` LIMIT ?`;
|
query += ` LIMIT ?`;
|
||||||
queryParams.push(limit);
|
queryParams.push(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional: Remove trailing semicolon if your execution method doesn't require it
|
||||||
|
// return { query, queryParams };
|
||||||
return {
|
return {
|
||||||
query: query + ";",
|
query: query, // Removed trailing semicolon for broader compatibility
|
||||||
queryParams
|
queryParams
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -45,14 +45,41 @@ export function parseSqlParams(source: string): SqlParams | null {
|
||||||
case 'orderBy':
|
case 'orderBy':
|
||||||
params.orderBy = val;
|
params.orderBy = val;
|
||||||
break;
|
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) {
|
if (!params.table) {
|
||||||
|
// Indicate parsing failure specifically because 'table' is missing
|
||||||
|
// The calling function will handle displaying the error message.
|
||||||
return null;
|
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;
|
return params as SqlParams;
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -1,60 +1,95 @@
|
||||||
|
import { DBService } from "../../../DBService";
|
||||||
import { SqlParams, ValidationError } from "../types";
|
import { SqlParams, ValidationError } from "../types";
|
||||||
|
|
||||||
export async function validateTable(db: any, params: SqlParams) {
|
export async function validateTable(dbService: DBService, params: SqlParams): Promise<ValidationError | null> {
|
||||||
// Get table info
|
const tableName = params.table;
|
||||||
const tableInfo = db.exec(`PRAGMA table_info("${params.table}")`);
|
let availableColumns: string[] = [];
|
||||||
if (!tableInfo || tableInfo.length === 0) {
|
|
||||||
|
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 {
|
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
|
// Validate columns if specified
|
||||||
if (params.columns) {
|
if (params.columns) {
|
||||||
const requestedColumns = params.columns.split(',').map(c => c.trim());
|
const requestedColumns = params.columns.split(',').map(c => c.trim());
|
||||||
for (const col of requestedColumns) {
|
const invalidColumns = requestedColumns.filter(col => !availableColumns.includes(col));
|
||||||
if (!availableColumns.includes(col)) {
|
if (invalidColumns.length > 0) {
|
||||||
return {
|
return {
|
||||||
message: `Column "${col}" does not exist in table "${params.table}".`,
|
message: `The following columns do not exist in table "${tableName}": ${invalidColumns.join(", ")}.`,
|
||||||
availableColumns
|
availableColumns
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate dateColumn if specified
|
// Validate dateColumn if specified
|
||||||
if (params.dateColumn && !availableColumns.includes(params.dateColumn)) {
|
if (params.dateColumn && !availableColumns.includes(params.dateColumn)) {
|
||||||
return {
|
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
|
availableColumns
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate filterColumn if specified
|
// Validate filterColumn if specified
|
||||||
if (params.filterColumn) {
|
if (params.filterColumn) {
|
||||||
const filterColumns = Array.isArray(params.filterColumn)
|
const filterColumns = Array.isArray(params.filterColumn)
|
||||||
? params.filterColumn
|
? params.filterColumn
|
||||||
: [params.filterColumn];
|
: params.filterColumn.split(',').map(c => c.trim()); // Handle comma-separated string too
|
||||||
|
|
||||||
for (const col of filterColumns) {
|
const invalidFilterCols = filterColumns.filter(col => !availableColumns.includes(col));
|
||||||
if (!availableColumns.includes(col)) {
|
if (invalidFilterCols.length > 0) {
|
||||||
return {
|
return {
|
||||||
message: `Filter column "${col}" does not exist in table "${params.table}".`,
|
message: `The following filter columns do not exist in table "${tableName}": ${invalidFilterCols.join(", ")}.`,
|
||||||
availableColumns
|
availableColumns
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate orderBy if specified
|
// Validate orderBy if specified
|
||||||
if (params.orderBy && !availableColumns.includes(params.orderBy)) {
|
if (params.orderBy && !availableColumns.includes(params.orderBy)) {
|
||||||
return {
|
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
|
availableColumns
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All checks passed
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -1,36 +1,31 @@
|
||||||
import { Notice } from "obsidian";
|
import { Notice } from "obsidian";
|
||||||
import { DBService } from "../../dbService";
|
import { DBService } from "../../DBService";
|
||||||
import { parseSqlParams, validateTable, buildSqlQuery, renderResults } from "./helpers";
|
import { parseSqlParams, validateTable, buildSqlQuery, renderResults } from "./helpers";
|
||||||
|
import { renderResultsAsTable } from "./helpers/renderResultsAsTable";
|
||||||
|
|
||||||
export async function processSqlBlock(dbService: DBService, source: string, el: HTMLElement) {
|
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);
|
const params = parseSqlParams(source);
|
||||||
|
|
||||||
if (!params) {
|
if (!params) {
|
||||||
el.createEl("p", { text: "Missing required parameter: table" });
|
el.createEl("p", { text: "Missing required parameter: table" });
|
||||||
el.createEl("p", { text: "Example usage:" });
|
el.createEl("p", { text: "Example usage:" });
|
||||||
el.createEl("pre", {
|
el.createEl("pre", {
|
||||||
text: `table: tasks
|
text: `table: tasks
|
||||||
columns: title, status
|
columns: title, status
|
||||||
filterColumn: status, priority
|
filterColumn: status, priority
|
||||||
filterValue: active, high
|
filterValue: active, high
|
||||||
dateColumn: dueDate
|
dateColumn: dueDate
|
||||||
startDate: 2024-01-01
|
startDate: 2024-01-01
|
||||||
endDate: 2024-12-31
|
endDate: 2024-12-31
|
||||||
limit: 10
|
limit: 10
|
||||||
orderBy: dueDate
|
orderBy: dueDate
|
||||||
orderDirection: asc`
|
orderDirection: asc`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validationError = await validateTable(db, params);
|
const validationError = await validateTable(dbService, params);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
el.createEl("p", { text: validationError.message });
|
el.createEl("p", { text: validationError.message });
|
||||||
if (validationError.availableColumns) {
|
if (validationError.availableColumns) {
|
||||||
|
|
@ -40,41 +35,64 @@ export async function processSqlBlock(dbService: DBService, source: string, el:
|
||||||
}
|
}
|
||||||
|
|
||||||
const { query, queryParams } = buildSqlQuery(params);
|
const { query, queryParams } = buildSqlQuery(params);
|
||||||
const result = db.exec(query, queryParams);
|
|
||||||
|
|
||||||
if (!result || result.length === 0 || result[0].values.length === 0) {
|
let resultsToRender: { columns: string[], values: any[][] } | null = null;
|
||||||
el.createEl("p", {
|
|
||||||
text: `No rows found in table "${params.table}"`,
|
// Handle remote vs local DB
|
||||||
});
|
if (dbService.mode === "remote") {
|
||||||
|
const rows = await dbService.getQuery(query, queryParams);
|
||||||
// Show the applied filters to help debug
|
if (!rows || rows.length === 0) {
|
||||||
if (params.keyColumn && params.value) {
|
// Keep existing no-rows message logic
|
||||||
el.createEl("p", { text: `Filter: ${params.keyColumn} = ${params.value}` });
|
} 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)) {
|
} else {
|
||||||
el.createEl("p", {
|
// Local mode logic
|
||||||
text: `Date range: ${params.dateColumn} from ${params.startDate || 'start'} to ${params.endDate || 'end'}`
|
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) {
|
const result = db.exec(query, queryParams);
|
||||||
el.createEl("p", { text: `Additional filter: ${params.filterColumn} = ${params.filterValue}` });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderResults(result[0], el);
|
// --- Choose Renderer based on displayFormat ---
|
||||||
|
el.empty(); // Clear loading message or previous content
|
||||||
} catch (error) {
|
if (params.displayFormat === 'table') {
|
||||||
el.createEl("p", { text: "An error occurred while processing the SQL block." });
|
renderResultsAsTable(resultsToRender, el);
|
||||||
el.createEl("p", { text: String(error) });
|
} else { // Default to 'list'
|
||||||
// Optionally show the query that caused the error in development
|
renderResults(resultsToRender, el); // Assuming renderResults is the list renderer
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
const { query, queryParams } = buildSqlQuery(params);
|
|
||||||
el.createEl("pre", { text: `Query: ${query}\nParams: ${JSON.stringify(queryParams)}` });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} 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 */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ export interface SqlParams {
|
||||||
orderBy?: string;
|
orderBy?: string;
|
||||||
orderDirection?: string;
|
orderDirection?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
displayFormat?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidationError {
|
export interface ValidationError {
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export function createBarChart(labels: string[], datasets: any[], options: BarCh
|
||||||
legend: {
|
legend: {
|
||||||
display: chartOptions.showLegend,
|
display: chartOptions.showLegend,
|
||||||
position: 'top' as const,
|
position: 'top' as const,
|
||||||
|
align: 'center' as const,
|
||||||
labels: {
|
labels: {
|
||||||
usePointStyle: false,
|
usePointStyle: false,
|
||||||
padding: 15,
|
padding: 15,
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export function createLineChart(labels: string[], datasets: any[], options: Line
|
||||||
legend: {
|
legend: {
|
||||||
display: chartOptions.showLegend,
|
display: chartOptions.showLegend,
|
||||||
position: 'top' as const,
|
position: 'top' as const,
|
||||||
|
align: 'center' as const,
|
||||||
labels: {
|
labels: {
|
||||||
usePointStyle: true,
|
usePointStyle: true,
|
||||||
padding: 15,
|
padding: 15,
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,17 @@ export function createPieChart(labels: string[], datasets: any[], options: PieCh
|
||||||
display: chartOptions.showLegend,
|
display: chartOptions.showLegend,
|
||||||
position: 'right' as const,
|
position: 'right' as const,
|
||||||
align: 'center' 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: {
|
labels: {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
boxWidth: 15,
|
boxWidth: 15,
|
||||||
|
|
@ -71,14 +82,24 @@ export function createPieChart(labels: string[], datasets: any[], options: PieCh
|
||||||
},
|
},
|
||||||
generateLabels: (chart: any) => {
|
generateLabels: (chart: any) => {
|
||||||
const data = chart.data;
|
const data = chart.data;
|
||||||
return data.labels.map((label: string, index: number) => ({
|
if (data.labels.length && data.datasets.length) {
|
||||||
text: `${label}: ${data.datasets[0].data[index]}`,
|
return data.labels.map((label: string, index: number) => {
|
||||||
fillStyle: data.datasets[0].backgroundColor[index],
|
const meta = chart.getDatasetMeta(0);
|
||||||
strokeStyle: data.datasets[0].borderColor[index],
|
const hidden = meta.data[index] ? meta.data[index].hidden : false;
|
||||||
lineWidth: 1,
|
|
||||||
hidden: isNaN(data.datasets[0].data[index]),
|
return {
|
||||||
index
|
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 [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,118 @@
|
||||||
import { ChartConfig } from "./parseChartParams";
|
import { ChartConfig, PieChartConfig } from "./parseChartParams";
|
||||||
|
|
||||||
export function buildSqlQuery(config: ChartConfig) {
|
function isPieConfig(config: ChartConfig): config is PieChartConfig {
|
||||||
let query, params = [];
|
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) {
|
switch (config.chartType) {
|
||||||
case 'pie':
|
case 'pie':
|
||||||
query = `
|
// Assert config is PieChartConfig for type safety (optional but good)
|
||||||
SELECT
|
const pieConfig = config as PieChartConfig;
|
||||||
${config.categoryColumn},
|
|
||||||
SUM(${config.valueColumn}) as value
|
// Handle special duration calculation
|
||||||
FROM "${config.table}"
|
const isTimeDuration =
|
||||||
`;
|
pieConfig.table.toLowerCase() === "time" &&
|
||||||
|
pieConfig.valueColumn === "duration";
|
||||||
if (config.startDate && config.endDate) {
|
|
||||||
query += ` WHERE ${config.dateColumn} BETWEEN ? AND ?`;
|
const valueExpr = isTimeDuration
|
||||||
params.push(config.startDate, config.endDate);
|
// 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
|
||||||
query += ` GROUP BY ${config.categoryColumn}`;
|
|
||||||
query += ` ORDER BY value DESC`;
|
// Quote category column
|
||||||
|
selectClause = `SELECT "${pieConfig.categoryColumn}", ${valueExpr} as value`;
|
||||||
|
groupByClause = `GROUP BY "${pieConfig.categoryColumn}"`;
|
||||||
|
orderByClause = `ORDER BY value DESC`;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'line':
|
case 'line':
|
||||||
case 'bar':
|
case 'bar':
|
||||||
|
// Quote columns
|
||||||
|
const quotedYColumns = config.yColumns.map(col => `"${col}"`).join(', ');
|
||||||
|
const quotedXColumn = `"${config.xColumn}"`;
|
||||||
|
|
||||||
if (config.categoryColumn) {
|
if (config.categoryColumn) {
|
||||||
query = `
|
const quotedCategoryColumn = `"${config.categoryColumn}"`;
|
||||||
SELECT
|
selectClause = `SELECT ${quotedXColumn}, ${quotedCategoryColumn}, ${quotedYColumns}`;
|
||||||
${config.xColumn},
|
// Line/bar charts often don't need explicit GROUP BY if not aggregating in SQL
|
||||||
${config.categoryColumn},
|
// ORDER BY xColumn first, then category for consistent multi-series ordering
|
||||||
${config.yColumns.join(', ')}
|
orderByClause = `ORDER BY ${quotedXColumn}, ${quotedCategoryColumn}`;
|
||||||
FROM "${config.table}"
|
|
||||||
`;
|
|
||||||
} else {
|
} else {
|
||||||
query = `
|
selectClause = `SELECT ${quotedXColumn}, ${quotedYColumns}`;
|
||||||
SELECT
|
orderByClause = `ORDER BY ${quotedXColumn}`;
|
||||||
${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}`;
|
|
||||||
}
|
}
|
||||||
break;
|
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 };
|
||||||
}
|
}
|
||||||
|
|
@ -9,31 +9,47 @@ function isPieConfig(config: ChartConfig): config is PieChartConfig {
|
||||||
return config.chartType === 'pie';
|
return config.chartType === 'pie';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function processChartData(rowObj: any, config: ChartConfig) {
|
function formatSeconds(totalSeconds: number): string {
|
||||||
if (isPieConfig(config)) {
|
const hrs = Math.floor(totalSeconds / 3600);
|
||||||
const labels = rowObj.values.map((row: any[]) => row[0]);
|
const mins = Math.floor((totalSeconds % 3600) / 60);
|
||||||
const datasets = [{
|
const secs = Math.floor(totalSeconds % 60);
|
||||||
data: rowObj.values.map((row: any[]) => row[1]),
|
|
||||||
label: config.valueColumn
|
|
||||||
}];
|
|
||||||
return { labels, datasets };
|
|
||||||
}
|
|
||||||
|
|
||||||
let labels: any[];
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
let datasets: any[];
|
return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function processGroupedData(rowObj: any, config: TimeSeriesChartConfig) {
|
||||||
const groupedData = new Map();
|
const groupedData = new Map();
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,50 @@
|
||||||
import { ChartConfig } from "./parseChartParams";
|
import { ChartConfig } from "./parseChartParams";
|
||||||
|
import { DBService } from "../../../DBService";
|
||||||
|
|
||||||
export async function validateColumns(db: any, config: ChartConfig) {
|
export async function validateColumns(dbService: DBService, config: ChartConfig) {
|
||||||
const tableInfo = db.exec(`PRAGMA table_info("${config.table}");`);
|
const tableName = config.table;
|
||||||
const availableColumns = tableInfo[0].values.map((row: any[]) => row[1]);
|
|
||||||
|
let columns: string[] = [];
|
||||||
|
|
||||||
let requestedColumns = [];
|
try {
|
||||||
|
const result = await dbService.getQuery<{ name: string }>(
|
||||||
if (config.chartType === 'pie') {
|
`PRAGMA table_info("${tableName}")`
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,88 @@
|
||||||
import { Notice } from "obsidian";
|
import { DBService } from "../../DBService";
|
||||||
import { DBService } from "../../dbService";
|
|
||||||
import { parseChartParams, validateColumns, buildSqlQuery, processChartData } from "./helpers";
|
import { parseChartParams, validateColumns, buildSqlQuery, processChartData } from "./helpers";
|
||||||
import { ChartType, createChart } from "./charts";
|
import { ChartType, createChart } from "./charts";
|
||||||
|
import { Notice } from "obsidian";
|
||||||
|
import { renderChart } from "./renderChart";
|
||||||
|
|
||||||
export async function processSqlChartBlock(dbService: DBService, source: string, el: HTMLElement) {
|
export async function processSqlChartBlock(dbService: DBService, source: string, el: HTMLElement) {
|
||||||
const db = dbService.getDB();
|
await renderChartBlock();
|
||||||
if (!db) {
|
|
||||||
new Notice("No DB loaded. Please open the DB first.");
|
|
||||||
el.createEl("p", { text: "Database not loaded." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = parseChartParams(source);
|
async function renderChartBlock() {
|
||||||
if (!config) {
|
const config = parseChartParams(source);
|
||||||
// @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 {
|
if (!config) {
|
||||||
const validationError = await validateColumns(db, config);
|
// @ts-ignore
|
||||||
if (validationError) {
|
const message = config?.chartType === 'pie'
|
||||||
el.createEl("p", { text: validationError.message });
|
? "Required parameters for pie chart are: table, categoryColumn, valueColumn"
|
||||||
if (validationError.availableColumns) {
|
: "Required parameters are: table, xColumn, yColumns";
|
||||||
el.createEl("p", { text: `Available columns are: ${validationError.availableColumns.join(", ")}` });
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
104
src/codeblocks/processSqlChartBlock/renderChart.ts
Normal file
104
src/codeblocks/processSqlChartBlock/renderChart.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { App, Notice, TFile, TFolder, normalizePath, FileSystemAdapter } from "obsidian";
|
import { App, Notice, TFile, TFolder, normalizePath, FileSystemAdapter } from "obsidian";
|
||||||
import { DBService } from "../dbService";
|
import { DBService } from "../DBService";
|
||||||
import { ColumnPickerModal } from "../components/ColumnPickerModal";
|
import { ColumnPickerModal } from "../components/ColumnPickerModal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { inspectTableStructure } from "./inspectTableStructure";
|
import { inspectTableStructure } from "./inspectTableStructure";
|
||||||
import { convertEntriesInNotes } from "./convertEntriesInNotes";
|
import { convertEntriesInNotes } from "./convertEntriesInNotes";
|
||||||
|
import { syncDBToJournals } from "./syncDBToJournals";
|
||||||
|
import { syncJournalsToDB } from "./syncJournalsToDB";
|
||||||
|
|
||||||
export { inspectTableStructure, convertEntriesInNotes };
|
export { inspectTableStructure, convertEntriesInNotes, syncDBToJournals, syncJournalsToDB };
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Notice, Editor } from "obsidian";
|
import { Notice, Editor } from "obsidian";
|
||||||
import { DBService } from "../dbService";
|
import { DBService } from "../DBService";
|
||||||
import { TablePickerModal } from "../components/TablePickerModal";
|
import { TablePickerModal } from "../components/TablePickerModal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -10,40 +10,84 @@ export async function inspectTableStructure(
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
app: any
|
app: any
|
||||||
) {
|
) {
|
||||||
const db = dbService.getDB();
|
// Check if we're in local or remote mode
|
||||||
if (!db) {
|
if (dbService.mode === "local") {
|
||||||
new Notice("No DB loaded. Please open the DB first.");
|
const db = dbService.getDB();
|
||||||
return;
|
if (!db) {
|
||||||
}
|
new Notice("No DB loaded. Please open the DB first.");
|
||||||
|
|
||||||
// 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`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// rowResult[0].columns -> array of column names
|
// 1) get all table names
|
||||||
// rowResult[0].values -> array of row arrays
|
const result = db.exec("SELECT name FROM sqlite_master WHERE type='table';");
|
||||||
const columns = rowResult[0].columns;
|
if (!result || result.length === 0) {
|
||||||
const rows = rowResult[0].values;
|
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`;
|
// 2) show a modal to pick one
|
||||||
output += columns.join(", ");
|
const modal = new TablePickerModal(app, tableNames, (selectedTable) => {
|
||||||
output += "\n\n**First row**\n";
|
// 3) query first 10 rows from that table
|
||||||
output += rows[0].join(", ");
|
const rowResult = db.exec(`SELECT * FROM "${selectedTable}" LIMIT 10;`);
|
||||||
editor.replaceSelection(output + "\n");
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
183
src/commands/syncDBToJournals.ts
Normal file
183
src/commands/syncDBToJournals.ts
Normal 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();
|
||||||
|
}
|
||||||
200
src/commands/syncJournalsToDB.ts
Normal file
200
src/commands/syncJournalsToDB.ts
Normal 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;
|
||||||
|
}
|
||||||
146
src/components/AddTextEntryModal.ts
Normal file
146
src/components/AddTextEntryModal.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/components/DatePickerModal/DatePickerModal.ts
Normal file
101
src/components/DatePickerModal/DatePickerModal.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/components/DatePickerModal/dom/buildCalendarGrid.ts
Normal file
94
src/components/DatePickerModal/dom/buildCalendarGrid.ts
Normal 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;
|
||||||
|
}
|
||||||
25
src/components/DatePickerModal/dom/buildDatePickerFooter.ts
Normal file
25
src/components/DatePickerModal/dom/buildDatePickerFooter.ts
Normal 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;
|
||||||
|
}
|
||||||
38
src/components/DatePickerModal/dom/buildDatePickerHeader.ts
Normal file
38
src/components/DatePickerModal/dom/buildDatePickerHeader.ts
Normal 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;
|
||||||
|
}
|
||||||
20
src/components/DatePickerModal/dom/buildWeekdayHeaders.ts
Normal file
20
src/components/DatePickerModal/dom/buildWeekdayHeaders.ts
Normal 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;
|
||||||
|
}
|
||||||
238
src/components/MoodNoteModal/MoodNoteEntryModal.tsx
Normal file
238
src/components/MoodNoteModal/MoodNoteEntryModal.tsx
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/components/MoodNoteModal/helpers/getContrastYIQ.ts
Normal file
37
src/components/MoodNoteModal/helpers/getContrastYIQ.ts
Normal 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';
|
||||||
|
}
|
||||||
64
src/components/TimePickerModal/TimePickerModal.ts
Normal file
64
src/components/TimePickerModal/TimePickerModal.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/components/TimePickerModal/dom/buildTimePickerDOM.ts
Normal file
66
src/components/TimePickerModal/dom/buildTimePickerDOM.ts
Normal 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));
|
||||||
|
}
|
||||||
60
src/components/TimePickerModal/timePickerUtils.ts
Normal file
60
src/components/TimePickerModal/timePickerUtils.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
184
src/helpers/datePeriodUtils.ts
Normal file
184
src/helpers/datePeriodUtils.ts
Normal 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
47
src/helpers/dateUtils.ts
Normal 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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
import { pickTableName } from "./pickTableName";
|
import { pickTableName } from "./pickTableName";
|
||||||
|
import { replacePlaceholders } from "./replacePlaceholders";
|
||||||
|
|
||||||
export { pickTableName };
|
export { pickTableName, replacePlaceholders };
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { App, Notice } from "obsidian";
|
import { App, Notice } from "obsidian";
|
||||||
import { DBService } from "../dbService";
|
import { DBService } from "../DBService";
|
||||||
import { TablePickerModal } from "../components/TablePickerModal";
|
import { TablePickerModal } from "../components/TablePickerModal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
40
src/helpers/replacePlaceholders.ts
Normal file
40
src/helpers/replacePlaceholders.ts
Normal 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
98
src/pluginState.ts
Normal 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
97
src/settingTab.ts
Normal 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();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/styles/dateNavigationInject.ts
Normal file
79
src/styles/dateNavigationInject.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/styles/datePickerInject.ts
Normal file
163
src/styles/datePickerInject.ts
Normal 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
5
src/styles/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { injectDatePickerStyles } from "./datePickerInject";
|
||||||
|
import { injectDateNavigatorStyles, removeDateNavigatorStyles } from "./dateNavigationInject";
|
||||||
|
import { injectTimePickerStyles } from "./timePickerInject";
|
||||||
|
|
||||||
|
export { injectDatePickerStyles, injectDateNavigatorStyles, removeDateNavigatorStyles, injectTimePickerStyles };
|
||||||
54
src/styles/timePickerInject.ts
Normal file
54
src/styles/timePickerInject.ts
Normal 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();
|
||||||
|
}
|
||||||
12
src/types.ts
12
src/types.ts
|
|
@ -1,7 +1,19 @@
|
||||||
export interface SQLiteDBSettings {
|
export interface SQLiteDBSettings {
|
||||||
dbFilePath: string;
|
dbFilePath: string;
|
||||||
|
mode: "local" | "remote";
|
||||||
|
apiBaseUrl: string;
|
||||||
|
journalFolderPath: string;
|
||||||
|
journalTableName: string;
|
||||||
|
cfAccessClientId: string;
|
||||||
|
cfAccessClientSecret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: SQLiteDBSettings = {
|
export const DEFAULT_SETTINGS: SQLiteDBSettings = {
|
||||||
dbFilePath: "",
|
dbFilePath: "",
|
||||||
|
mode: "local",
|
||||||
|
apiBaseUrl: "",
|
||||||
|
journalFolderPath: "",
|
||||||
|
journalTableName: "",
|
||||||
|
cfAccessClientId: "",
|
||||||
|
cfAccessClientSecret: "",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
108
src/webcomponents/AddTextButton/AddTextButton.ts
Normal file
108
src/webcomponents/AddTextButton/AddTextButton.ts
Normal 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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/webcomponents/BooleanSwitch/BooleanSwitch.ts
Normal file
172
src/webcomponents/BooleanSwitch/BooleanSwitch.ts
Normal 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);
|
||||||
|
}
|
||||||
19
src/webcomponents/BooleanSwitch/BooleanSwitch.types.ts
Normal file
19
src/webcomponents/BooleanSwitch/BooleanSwitch.types.ts
Normal 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)
|
||||||
|
}
|
||||||
40
src/webcomponents/BooleanSwitch/data/loadBooleanValue.ts
Normal file
40
src/webcomponents/BooleanSwitch/data/loadBooleanValue.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/webcomponents/BooleanSwitch/data/upsertBooleanValue.ts
Normal file
47
src/webcomponents/BooleanSwitch/data/upsertBooleanValue.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
46
src/webcomponents/BooleanSwitch/dom/buildBooleanSwitchDOM.ts
Normal file
46
src/webcomponents/BooleanSwitch/dom/buildBooleanSwitchDOM.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
58
src/webcomponents/BooleanSwitch/registerBooleanSwitch.ts
Normal file
58
src/webcomponents/BooleanSwitch/registerBooleanSwitch.ts
Normal 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]";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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); */
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
159
src/webcomponents/HabitCounter/HabitCounter.ts
Normal file
159
src/webcomponents/HabitCounter/HabitCounter.ts
Normal 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);
|
||||||
|
}
|
||||||
20
src/webcomponents/HabitCounter/HabitCounter.types.ts
Normal file
20
src/webcomponents/HabitCounter/HabitCounter.types.ts
Normal 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;
|
||||||
|
}
|
||||||
44
src/webcomponents/HabitCounter/data/loadHabitData.ts
Normal file
44
src/webcomponents/HabitCounter/data/loadHabitData.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/webcomponents/HabitCounter/data/updateHabitData.ts
Normal file
56
src/webcomponents/HabitCounter/data/updateHabitData.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/webcomponents/HabitCounter/dom/buildHabitCounterDOM.ts
Normal file
47
src/webcomponents/HabitCounter/dom/buildHabitCounterDOM.ts
Normal 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 };
|
||||||
|
}
|
||||||
52
src/webcomponents/HabitCounter/dom/uiUpdaters.ts
Normal file
52
src/webcomponents/HabitCounter/dom/uiUpdaters.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
56
src/webcomponents/HabitCounter/registerHabitCounter.ts
Normal file
56
src/webcomponents/HabitCounter/registerHabitCounter.ts
Normal 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]";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
54
src/webcomponents/MoodNote/moodButtonProcessor.ts
Normal file
54
src/webcomponents/MoodNote/moodButtonProcessor.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
49
src/webcomponents/Sql/registerSql.ts
Normal file
49
src/webcomponents/Sql/registerSql.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/webcomponents/SqlChart/registerSqlChart.ts
Normal file
49
src/webcomponents/SqlChart/registerSqlChart.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/webcomponents/TextInput/TextInput.ts
Normal file
212
src/webcomponents/TextInput/TextInput.ts
Normal 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); }
|
||||||
54
src/webcomponents/TextInput/TextInput.types.ts
Normal file
54
src/webcomponents/TextInput/TextInput.types.ts
Normal 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
|
||||||
|
}
|
||||||
61
src/webcomponents/TextInput/data/loadTextValue.ts
Normal file
61
src/webcomponents/TextInput/data/loadTextValue.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/webcomponents/TextInput/data/upsertTextValue.ts
Normal file
49
src/webcomponents/TextInput/data/upsertTextValue.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/webcomponents/TextInput/dom/buildTextInputDOM.ts
Normal file
83
src/webcomponents/TextInput/dom/buildTextInputDOM.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
49
src/webcomponents/TextInput/dom/textInputUiUpdaters.ts
Normal file
49
src/webcomponents/TextInput/dom/textInputUiUpdaters.ts
Normal 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');
|
||||||
|
}
|
||||||
15
src/webcomponents/TextInput/eventHandlers/inputHandlers.ts
Normal file
15
src/webcomponents/TextInput/eventHandlers/inputHandlers.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
57
src/webcomponents/TextInput/registerTextInput.ts
Normal file
57
src/webcomponents/TextInput/registerTextInput.ts
Normal 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]";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
12
src/webcomponents/TextInput/styles/applyTextInputStyles.ts
Normal file
12
src/webcomponents/TextInput/styles/applyTextInputStyles.ts
Normal 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);
|
||||||
|
}
|
||||||
133
src/webcomponents/TextInput/styles/getTextInputStyles.ts
Normal file
133
src/webcomponents/TextInput/styles/getTextInputStyles.ts
Normal 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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/webcomponents/index.ts
Normal file
19
src/webcomponents/index.ts
Normal 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
|
||||||
|
};
|
||||||
93
src/webcomponents/services/BooleanDataService.ts
Normal file
93
src/webcomponents/services/BooleanDataService.ts
Normal 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
Loading…
Reference in a new issue