feat: Markdown tables can now refer to named headers and other files

This commit is contained in:
Kacper Kula 2025-03-22 18:36:40 +00:00
parent d83fbb768d
commit 460050d2f8
5 changed files with 379 additions and 75 deletions

View file

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

View file

@ -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`.
Note that inline queries require table definitions elsewhere in the document, as they can't define tables themselves.

View file

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

View file

@ -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<MarkdownTableSyncStrategy> {
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 => {

View file

@ -35,7 +35,7 @@ export const SQLSealLangDefinition = (views: ViewDefinition[], flags: readonly F
${flags.length ? '| ExtraFlags -- extraFlags' : ''}
TableExpression = tableKeyword identifier "=" TableDefinition
TableDefinition = fileOpening NonemptyListOf<listElement, ","> tableDefinitionClosing -- file
| tableOpening alnum+ tableDefinitionClosing -- mdtable
| tableOpening NonemptyListOf<listElement, ","> 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'
}
},