diff --git a/CHANGELOG.md b/CHANGELOG.md index f533afb..e0b81de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -193,7 +193,7 @@ Turning off verbose mode # 0.9.0 (2024-11-03) We now use proper grid library to render data. This allow for many great features like pagination, sorting and more visally pleasing UI out of the box. -Reworked internal communication to use Signals. +Rewritten internal communication to use Signals. # 0.8.0 (2024-09-05) Now you can embed links and images (both local and external). Introduced `a` and `img` custom SQL functions. @@ -224,4 +224,12 @@ Also improved the way files are being observed and updated which should lead to - Added custom class to sqlseal tables and ability to scroll vertically when the data is overflowing horizontally. # 0.1.0 (2024-06-23) -- Initial release. Allows to create tables based on CSV files in your vault and query them using SQL. \ No newline at end of file +- Initial release. Allows to create tables based on CSV files in your vault and query them using SQL. + +## Unreleased + +### Added +- Added ability to reference markdown tables by header name: `table(Header Name)` +- Added ability to reference tables from other files using: `table(file:path/to/file.md, 0)` +- Added support for relative file paths: `table(file:./relative/path.md, Header Name)` +- Added automatic .md extension when referencing files without extension \ No newline at end of file diff --git a/docs/data-sources/markdown-tables.md b/docs/data-sources/markdown-tables.md index cf1604b..8ee489b 100644 --- a/docs/data-sources/markdown-tables.md +++ b/docs/data-sources/markdown-tables.md @@ -1,52 +1,211 @@ -# Querying Markdown Tables +# Markdown Tables Reference Guide + > [!NOTE] Compatibility > Introduced in version 0.16.0. Make sure you are using up to date version. +> Header-based references and cross-file references introduced in version 0.30.0. -You can refer to the tables in your current file by using `table(tableIndex)` syntax. This can allow you to quickly summarise data you might have in your document. +This guide provides detailed documentation on using SQL Seal to query markdown tables within your notes. For a basic introduction, see the [Querying Markdown Tables](/query-markdown-tables.md) page. -Indexing starts from 0 so the 1st table should be refered to as `table(0)`, 2nd table `table(1)` and so on. +## Reference Methods -## Example +SQL Seal offers several ways to reference tables in your markdown files: -Let's take a note with the following table: +- **By numeric index**: `table(tableIndex)` +- **By header name**: `table(headerName)` +- **By header name and index**: `table(headerName, tableIndex)` +- **From other files**: `table(file:path/to/file.md, ...)` -| Date | Name | Amount | -| ---------- | ------------- | ------ | -| 2025-01-01 | Grocery | 20.40 | -| 2025-01-01 | Cinema Ticket | 8.20 | -| 2025-01-02 | Grocery | 5.0 | -| 2025-01-02 | Game on Steam | 20.0 | -| 2025-01-03 | Grocery | 15.98 | +## Referencing Tables by Index + +Indexing starts from 0, so the first table in your note is referenced as `table(0)`, the second as `table(1)`, and so on. + +```sqlseal +TABLE expenses = table(0) + +SELECT * FROM expenses +``` + +This approach is simple but can be fragile if you add or remove tables from your document, as the indices will shift. + +## Referencing Tables by Header + +Instead of using numeric indices, you can reference tables by the header they appear under, making your queries more resilient to document changes. + +### Basic Header Reference + +If you have a header followed by a table: + +```markdown +# Monthly Expenses +| Date | Category | Amount | +| ---- | -------- | ------ | +| 2025-01-01 | Grocery | 20.40 | +``` + +You can reference this table using: + +```sqlseal +TABLE expenses = table(Monthly Expenses) +``` + +This finds the first table that appears after the "Monthly Expenses" header. The reference is case-insensitive, so `table(monthly expenses)` works too. + +### Multiple Tables Under the Same Header + +If multiple tables exist under the same header, you can specify which one to use with a second parameter: + +```markdown +# Financial Data + +## Revenue by Category +| Category | Amount | +| -------- | ------ | +| Product A| 5000 | +| Service B| 7000 | + +## Expense Breakdown +| Category | Amount | +| -------- | ------ | +| Salaries | 6000 | +| Rent | 2000 | +``` + +You can reference these tables using either: + +```sqlseal +-- By subheader +TABLE revenue = table(Revenue by Category) +TABLE expenses = table(Expense Breakdown) + +-- Or by parent header and index +TABLE revenue = table(Financial Data, 0) -- First table under Financial Data +TABLE expenses = table(Financial Data, 1) -- Second table under Financial Data +``` + +## Referencing Tables from Other Files + +You can reference tables from any markdown file in your vault using the `file:` prefix. + +### Basic File Reference + +To reference a table in another file by index: + +```sqlseal +TABLE expenses = table(file:Finance/expenses.md, 0) +``` + +This accesses the first table in the `Finance/expenses.md` file. + +### File Reference with Header + +You can combine file references with header references: + +```sqlseal +TABLE summary = table(file:Finance/annual-report.md, Revenue Summary) +``` + +This finds the table under the "Revenue Summary" header in the `Finance/annual-report.md` file. + +You can also include an index if needed: + +```sqlseal +TABLE revenue = table(file:Finance/annual-report.md, Financial Data, 0) +TABLE expenses = table(file:Finance/annual-report.md, Financial Data, 1) +``` + +### File Path Options + +When referencing other files, you have these path options: + +1. **Absolute Paths** (from vault root): + ```sqlseal + TABLE data = table(file:Finance/reports/q1.md, 0) + ``` + +2. **Relative Paths** (relative to current file): + ```sqlseal + -- Same folder, reports subfolder + TABLE data = table(file:./reports/q1.md, 0) + + -- Parent folder + TABLE summary = table(file:../summary.md, 0) + ``` + +3. **Optional Extension**: + The `.md` extension is optional and will be added automatically: + ```sqlseal + TABLE data = table(file:Finance/reports/q1, 0) -- Will look for q1.md + ``` + +## Practical Examples + +### Example 1: Joining Data from Different Files + +```sqlseal +TABLE sales = table(file:Data/sales.md, Monthly Sales) +TABLE targets = table(file:Plans/targets.md, Monthly Targets) + +SELECT + s.Month, + s.Revenue, + t.Target, + ROUND((s.Revenue / t.Target) * 100, 1) as Achievement +FROM + sales s +JOIN + targets t ON s.Month = t.Month +ORDER BY + s.Month +``` + +### Example 2: Analyzing Data Under Different Headers + +```sqlseal +TABLE q1 = table(Q1 Results) +TABLE q2 = table(Q2 Results) +TABLE q3 = table(Q3 Results) +TABLE q4 = table(Q4 Results) + +SELECT + 'Q1' as Quarter, SUM(Revenue) as Revenue FROM q1 +UNION ALL +SELECT + 'Q2' as Quarter, SUM(Revenue) as Revenue FROM q2 +UNION ALL +SELECT + 'Q3' as Quarter, SUM(Revenue) as Revenue FROM q3 +UNION ALL +SELECT + 'Q4' as Quarter, SUM(Revenue) as Revenue FROM q4 +ORDER BY + Quarter +``` + +### Example 3: Expense Summary -We can refer to this data and summarise spendings each day by doing the following: ```sqlseal TABLE expenses = table(0) HTML -SELECT date, ROUND(SUM(Amount), 2) as Spent -FROM expenses -GROUP BY date -ORDER BY date +SELECT + strftime('%Y-%m', Date) as Month, + Category, + ROUND(SUM(Amount), 2) as Total +FROM + expenses +GROUP BY + Month, Category +ORDER BY + Month, Total DESC ``` -Result: +## Inline Queries -| date | Spent | -| ---------- | ----- | -| 2025-01-01 | 28.6 | -| 2025-01-02 | 25 | -| 2025-01-03 | 15.98 | +You can use inline queries to embed values directly in your text: -## Inline query -You can also use this data to perform inline query, i.e. you can make sentence reading "I've spent in total X" and make X being a query. The only limitation here is that you need to have another query in the file that defines the table for the markdown table (because inline queries do not allow for defining new tables). - -```sqlseal -TABLE expenses = table(0) +``` +Total revenue: `S> SELECT SUM(Revenue) FROM sales`. +Average sale: `S> SELECT ROUND(AVG(Amount), 2) FROM transactions`. ``` -And then anywhere else in your code: - -I've spent total of `S> SELECT ROUND(SUM(amount), 2) FROM expenses`. - -Result: -I've spent in total `69.58`. \ No newline at end of file +Note that inline queries require table definitions elsewhere in the document, as they can't define tables themselves. diff --git a/docs/query-markdown-tables.md b/docs/query-markdown-tables.md index cf1604b..c097109 100644 --- a/docs/query-markdown-tables.md +++ b/docs/query-markdown-tables.md @@ -1,24 +1,45 @@ # Querying Markdown Tables > [!NOTE] Compatibility > Introduced in version 0.16.0. Make sure you are using up to date version. +> +> Header-based references and cross-file references introduced in version 0.30.0. -You can refer to the tables in your current file by using `table(tableIndex)` syntax. This can allow you to quickly summarise data you might have in your document. +SQL Seal allows you to query tables directly from your markdown notes, making it easy to analyze and summarize data without importing external files. -Indexing starts from 0 so the 1st table should be refered to as `table(0)`, 2nd table `table(1)` and so on. +## Basic Usage + +You can reference tables in your markdown files using the `table()` function: + +```sqlseal +TABLE expenses = table(0) + +SELECT date, SUM(Amount) as Total +FROM expenses +GROUP BY date +``` + +This example references the first table in your current note (index 0) and assigns it the name "expenses" for use in your SQL query. + +## Reference Methods + +SQL Seal offers several ways to reference markdown tables: + +- By index: `table(0)` - Reference tables by their position in the document +- By header name: `table(Monthly Budget)` - Find tables under specific headers +- From other files: `table(file:Finance/budget.md, 0)` - Query tables from across your vault ## Example -Let's take a note with the following table: +Let's take a note with a simple expense table: | Date | Name | Amount | | ---------- | ------------- | ------ | | 2025-01-01 | Grocery | 20.40 | | 2025-01-01 | Cinema Ticket | 8.20 | | 2025-01-02 | Grocery | 5.0 | -| 2025-01-02 | Game on Steam | 20.0 | -| 2025-01-03 | Grocery | 15.98 | -We can refer to this data and summarise spendings each day by doing the following: +We can analyze this data with a query: + ```sqlseal TABLE expenses = table(0) @@ -26,7 +47,6 @@ HTML SELECT date, ROUND(SUM(Amount), 2) as Spent FROM expenses GROUP BY date -ORDER BY date ``` Result: @@ -34,19 +54,24 @@ Result: | date | Spent | | ---------- | ----- | | 2025-01-01 | 28.6 | -| 2025-01-02 | 25 | -| 2025-01-03 | 15.98 | +| 2025-01-02 | 5.0 | -## Inline query -You can also use this data to perform inline query, i.e. you can make sentence reading "I've spent in total X" and make X being a query. The only limitation here is that you need to have another query in the file that defines the table for the markdown table (because inline queries do not allow for defining new tables). +## Inline Queries -```sqlseal -TABLE expenses = table(0) +You can also use inline queries to embed single values in your text: + +``` +I've spent total of `S> SELECT ROUND(SUM(amount), 2) FROM expenses`. ``` -And then anywhere else in your code: - -I've spent total of `S> SELECT ROUND(SUM(amount), 2) FROM expenses`. - Result: -I've spent in total `69.58`. \ No newline at end of file +I've spent total of `69.58`. + +## Learn More + +For complete documentation including advanced features like: +- Detailed header-based references +- Cross-file table queries +- Relative file paths + +See the [Markdown Tables Reference Guide](/data-sources/markdown-tables.md). diff --git a/src/datamodel/syncStrategy/MarkdownTableSyncStrategy.ts b/src/datamodel/syncStrategy/MarkdownTableSyncStrategy.ts index a2375a3..871e854 100644 --- a/src/datamodel/syncStrategy/MarkdownTableSyncStrategy.ts +++ b/src/datamodel/syncStrategy/MarkdownTableSyncStrategy.ts @@ -1,42 +1,106 @@ import { FilepathHasher } from "../../utils/hasher"; import { ISyncStrategy } from "./abstractSyncStrategy"; import type { App } from "obsidian"; -import { Component, MarkdownRenderer } from "obsidian"; +import { Component, MarkdownRenderer, normalizePath } from "obsidian"; import { ParserTableDefinition } from "./types"; +import { dirname, join } from "path"; +import { sanitise } from "../../utils/sanitiseColumn"; const DEFAULT_FILE_HASH = '' +// Prefix used to identify file path in arguments +const FILE_PREFIX = 'file:'; export class MarkdownTableSyncStrategy extends ISyncStrategy { static async fromParser(def: ParserTableDefinition, app: App): Promise { - const index = def.arguments[0] - const hash = await FilepathHasher.sha256(`${def.sourceFile}`) // FILENAME is in this case - const tableName = `mdtable_${hash}_${index}` + const arguments = def.arguments + + // Check if the first argument specifies a file + let sourceFile = def.sourceFile; + let argsToUse = [...arguments]; + + if (arguments[0]?.startsWith(FILE_PREFIX)) { + // Extract file path + let filePath = arguments[0].substring(FILE_PREFIX.length); + + // Process relative paths + if (filePath.startsWith('./') || filePath.startsWith('../')) { + // Get the directory of the current file + const currentDir = dirname(def.sourceFile); + + // Join the current directory with the relative path and normalize it + filePath = normalizePath(join(currentDir, filePath)); + } + + // Add .md extension if no extension is present + // A more reliable way to check for file extension + const hasExtension = filePath.lastIndexOf('.') > filePath.lastIndexOf('/'); + if (!hasExtension) { + filePath += '.md'; + } + + sourceFile = filePath; + + // Remove the file path from the arguments + argsToUse = arguments.slice(1); + + // If no arguments are left, default to index 0 + if (argsToUse.length === 0) { + argsToUse = ['0']; + } + } + + const headerOrIndex = argsToUse[0]; + const secondParam = argsToUse.length > 1 ? argsToUse[1] : undefined; + const hash = await FilepathHasher.sha256(`${sourceFile}`); + + // Create a safe tableName using the existing sanitise function + const safeHeaderOrIndex = sanitise(headerOrIndex); + const safeSecondParam = secondParam ? sanitise(secondParam) : ''; + const tableName = `mdtable_${hash}_${safeHeaderOrIndex}${safeSecondParam ? `_${safeSecondParam}` : ''}`; + return new MarkdownTableSyncStrategy({ - arguments: def.arguments, + arguments: argsToUse, file_hash: DEFAULT_FILE_HASH, refresh_id: tableName, - source_file: def.sourceFile, + source_file: sourceFile, table_name: tableName, type: def.type - }, app) + }, app); } async returnData() { - - const tableIndex = parseInt(this.def.arguments[0], 10) - const file = this.app.vault.getFileByPath(this.def.source_file)! + const file = this.app.vault.getFileByPath(this.def.source_file)!; + if (!file) { + return { data: [], columns: [] }; + } + const content = await this.app.vault.read(file); // Create a temporary div to render the markdown const tempDiv = document.createElement('div'); // Use Obsidian's MarkdownRenderer to parse the content - const component = new Component() - - await MarkdownRenderer.render(this.app, content, tempDiv, file.path, component) + const component = new Component(); + await MarkdownRenderer.render(this.app, content, tempDiv, file.path, component); + const headerOrIndex = this.def.arguments[0]; + const secondParam = this.def.arguments.length > 1 ? this.def.arguments[1] : undefined; + + // Check if first argument is a number (index) or a header name + const isIndex = !isNaN(parseInt(headerOrIndex, 10)); + + if (isIndex) { + // Original behavior - handle by table index + return this.getTableByIndex(parseInt(headerOrIndex, 10), tempDiv); + } else { + // New behavior - handle by header name + return this.getTableByHeader(headerOrIndex, secondParam, tempDiv); + } + } + + private getTableByIndex(tableIndex: number, tempDiv: HTMLElement) { // Find all tables in the rendered content const tables = tempDiv.querySelectorAll('table'); @@ -45,13 +109,62 @@ export class MarkdownTableSyncStrategy extends ISyncStrategy { return { data: [], columns: [] }; } - const targetTable = tables[tableIndex]; - + return this.extractTableData(tables[tableIndex]); + } + + private getTableByHeader(headerName: string, tableIndex: string | undefined, tempDiv: HTMLElement) { + // Find the header element with the given name + const headers = Array.from(tempDiv.querySelectorAll('h1, h2, h3, h4, h5, h6')); + + for (let i = 0; i < headers.length; i++) { + const header = headers[i]; + + // Check if this header matches the requested name + if (header.textContent?.trim().toLowerCase() === headerName.toLowerCase()) { + // Find all tables that follow this header and before the next header + const tablesUnderHeader: HTMLTableElement[] = []; + + let currentElement = header.nextElementSibling; + while (currentElement && !currentElement.matches('h1, h2, h3, h4, h5, h6')) { + if (currentElement instanceof HTMLTableElement || currentElement.querySelector('table')) { + const tables = currentElement.matches('table') + ? [currentElement as HTMLTableElement] + : Array.from(currentElement.querySelectorAll('table')); + + tablesUnderHeader.push(...(tables as HTMLTableElement[])); + } + currentElement = currentElement.nextElementSibling; + } + + // No tables found under this header + if (tablesUnderHeader.length === 0) { + return { data: [], columns: [] }; + } + + // If a specific table index is provided, use it + if (tableIndex !== undefined) { + const idx = parseInt(tableIndex, 10); + if (isNaN(idx) || idx >= tablesUnderHeader.length) { + return { data: [], columns: [] }; + } + return this.extractTableData(tablesUnderHeader[idx]); + } + + // Otherwise, return the first table + return this.extractTableData(tablesUnderHeader[0]); + } + } + + // Header not found + return { data: [], columns: [] }; + } + + private extractTableData(table: HTMLTableElement) { // Extract headers - const headers = Array.from(targetTable.querySelectorAll('th')).map(th => th.textContent?.trim() || '').filter(x => !!x); + const headers = Array.from(table.querySelectorAll('th')).map(th => th.textContent?.trim() || '').filter(x => !!x); // Extract rows - const rows = Array.from(targetTable.querySelectorAll('tr')).slice(1); // Skip header row + const rows = Array.from(table.querySelectorAll('tr')).slice(1); // Skip header row // Parse data const data = rows.map(row => { diff --git a/src/grammar/parser.ts b/src/grammar/parser.ts index 228fd54..478137a 100644 --- a/src/grammar/parser.ts +++ b/src/grammar/parser.ts @@ -35,7 +35,7 @@ export const SQLSealLangDefinition = (views: ViewDefinition[], flags: readonly F ${flags.length ? '| ExtraFlags -- extraFlags' : ''} TableExpression = tableKeyword identifier "=" TableDefinition TableDefinition = fileOpening NonemptyListOf tableDefinitionClosing -- file - | tableOpening alnum+ tableDefinitionClosing -- mdtable + | tableOpening NonemptyListOf tableDefinitionClosing -- mdtable identifier = (alnum | "_")+ filename = (alnum | "." | "-" | space | "_" | "/" | "\\" | "$" | "[" | "]" | "\"")+ fileOpening = caseInsensitive<"file("> @@ -113,10 +113,9 @@ const generateSemantic = (grammar: ohm.Grammar) => { type: 'file' } }, - TableDefinition_mdtable: (_file, tableIndex, _close) => { + TableDefinition_mdtable: (_file, args, _close) => { return { - - arguments: [tableIndex.sourceString], + arguments: args.asIteration().children.map((c: ohm.Node) => c.toObject().trim()), type: 'table' } },