mirror of
https://github.com/aaronsb/obsidian-mcp-plugin.git
synced 2026-07-22 06:45:14 +00:00
fix(dataview): recover rows for implicit LIST GROUP BY; label whitespace group keys
Two findings from testing 0.11.37 live against Dataview 0.5.68 in the app —
neither reachable through the pre-existing mock fixtures, both real:
1. Implicit `LIST ... GROUP BY` returned bare group keys, no rows. Dataview's
programmatic query() API drops grouped rows when nothing in the query
references `rows` (unlike rendered markdown), so `LIST FROM "x" GROUP BY
file.folder` came back as just the folder names. normalizeListGroupByQuery
injects a default `rows.file.link` output expression for exactly that shape
— a LIST with GROUP BY and no explicit output expression — so proper
{key, rows} groups come back. Queries that already name an output
expression, and non-LIST / non-grouped queries, pass through untouched; the
user-facing echoed query stays the original input.
2. `TASK ... GROUP BY status` groups incomplete tasks under a space-character
key, which rendered as an empty bold header (`**** (n)`). groupKeyLabel now
treats whitespace-only keys the same as empty → `(no group)`.
The test mock is made faithful to the live behavior: a grouped LIST that
references `rows` returns list-pair wrappers, one that doesn't collapses to bare
keys — so the injection is actually observable rather than passing vacuously.
Adds normalization unit tests, an end-to-end implicit-grouped-LIST recovery
test, and a whitespace-key label test. build + lint + test green, 355/355.
This commit is contained in:
parent
88f0ce5efd
commit
1fdcbc3a8b
3 changed files with 116 additions and 3 deletions
|
|
@ -166,7 +166,10 @@ function asGroup(item: DataviewValue): DataviewGroup | null {
|
|||
}
|
||||
|
||||
function groupKeyLabel(key: unknown): string {
|
||||
if (key === null || key === undefined || key === '') {
|
||||
// Empty or whitespace-only keys (e.g. `TASK ... GROUP BY status` puts
|
||||
// incomplete tasks under the space-character status) render as an empty bold
|
||||
// header otherwise. Collapse them to a stable label.
|
||||
if (key === null || key === undefined || (typeof key === 'string' && key.trim() === '')) {
|
||||
return '(no group)';
|
||||
}
|
||||
if (typeof key === 'string') {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,29 @@ function unwrapGroup(el: unknown): { key: unknown; rows: unknown[] } | null {
|
|||
return { key: obj.key, rows: toPlainArray(inner) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dataview's programmatic `query()` API collapses a grouped LIST to bare group
|
||||
* keys when nothing in the query references `rows` — the grouped items are
|
||||
* dropped from the payload entirely (unlike rendered markdown, which shows
|
||||
* them). So `LIST FROM "x" GROUP BY file.folder` comes back as just the folder
|
||||
* names, with no files. Inject a default `rows.file.link` output expression for
|
||||
* exactly that shape — a LIST with GROUP BY and no explicit output expression —
|
||||
* so the API returns proper `{ key, rows }` groups instead. Queries that already
|
||||
* name an output expression, and non-LIST / non-grouped queries, pass through
|
||||
* unchanged.
|
||||
*/
|
||||
export function normalizeListGroupByQuery(query: string): string {
|
||||
const trimmed = query.trimStart();
|
||||
if (!/^LIST\b/i.test(trimmed)) return query;
|
||||
if (!/\bGROUP\s+BY\b/i.test(trimmed)) return query;
|
||||
// There's no explicit output expression when a clause keyword (or the end of
|
||||
// the string) immediately follows the LIST keyword.
|
||||
const noOutputExpr = /^LIST\s+(FROM|WHERE|SORT|GROUP|FLATTEN|LIMIT)\b/i.test(trimmed)
|
||||
|| /^LIST\s*$/i.test(trimmed);
|
||||
if (!noOutputExpr) return query;
|
||||
return trimmed.replace(/^LIST\b/i, 'LIST rows.file.link');
|
||||
}
|
||||
|
||||
|
||||
/** Dataview link value */
|
||||
interface DataviewLink {
|
||||
|
|
@ -224,7 +247,11 @@ export class DataviewTool {
|
|||
// Execute DQL query. Dataview returns {successful: false, error}
|
||||
// for syntax/runtime errors rather than throwing — propagate that
|
||||
// status to the outer envelope so the formatter can render it.
|
||||
const result: DataviewQueryResult = await dataviewAPI.query(query);
|
||||
// Recover grouped rows for an implicit `LIST ... GROUP BY` — see
|
||||
// normalizeListGroupByQuery. The user-facing `query` echoed below stays
|
||||
// the original input; only the executed query is augmented.
|
||||
const effectiveQuery = normalizeListGroupByQuery(query);
|
||||
const result: DataviewQueryResult = await dataviewAPI.query(effectiveQuery);
|
||||
const innerSuccess = result.successful !== false;
|
||||
return {
|
||||
success: innerSuccess,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { DataviewTool, isDataviewToolAvailable } from '../src/tools/dataview-tool';
|
||||
import { DataviewTool, isDataviewToolAvailable, normalizeListGroupByQuery } from '../src/tools/dataview-tool';
|
||||
import { PluginDetector } from '../src/utils/plugin-detector';
|
||||
import { formatResponse } from '../src/formatters';
|
||||
|
||||
|
|
@ -66,6 +66,19 @@ class MockDataviewAPI {
|
|||
}
|
||||
|
||||
if (query.includes('GROUP BY')) {
|
||||
// Faithful to real Dataview: a grouped LIST that references `rows` returns
|
||||
// list-pair group wrappers; one that does NOT collapses to bare group keys
|
||||
// (the rows are dropped from the query() payload). This is what makes the
|
||||
// normalizeListGroupByQuery injection observable (#220 live follow-up).
|
||||
if (!query.includes('rows')) {
|
||||
return {
|
||||
successful: true,
|
||||
value: {
|
||||
type: 'list',
|
||||
values: ['groupA', 'groupB']
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
successful: true,
|
||||
value: {
|
||||
|
|
@ -611,6 +624,76 @@ describe('Dataview Integration', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Live follow-up to #220 (found running 0.11.37 against Dataview 0.5.68):
|
||||
// Dataview's query() API drops grouped rows for an implicit `LIST ... GROUP BY`
|
||||
// (no `rows` reference), returning bare group keys. normalizeListGroupByQuery
|
||||
// injects a default `rows.file.link` output so proper groups come back.
|
||||
describe('implicit LIST ... GROUP BY row recovery', () => {
|
||||
test('injects rows.file.link only for a grouped LIST with no output expression', () => {
|
||||
// Implicit grouped LIST → augmented
|
||||
expect(normalizeListGroupByQuery('LIST FROM "x" GROUP BY file.folder'))
|
||||
.toBe('LIST rows.file.link FROM "x" GROUP BY file.folder');
|
||||
expect(normalizeListGroupByQuery('LIST GROUP BY status'))
|
||||
.toBe('LIST rows.file.link GROUP BY status');
|
||||
expect(normalizeListGroupByQuery('list from "x" group by y'))
|
||||
.toBe('LIST rows.file.link from "x" group by y');
|
||||
|
||||
// Already has an output expression → unchanged
|
||||
expect(normalizeListGroupByQuery('LIST rows.file.name FROM "x" GROUP BY file.folder'))
|
||||
.toBe('LIST rows.file.name FROM "x" GROUP BY file.folder');
|
||||
// Not grouped → unchanged
|
||||
expect(normalizeListGroupByQuery('LIST FROM "x"'))
|
||||
.toBe('LIST FROM "x"');
|
||||
// Not a LIST → unchanged
|
||||
expect(normalizeListGroupByQuery('TABLE file.mtime FROM "x" GROUP BY file.folder'))
|
||||
.toBe('TABLE file.mtime FROM "x" GROUP BY file.folder');
|
||||
expect(normalizeListGroupByQuery('TASK FROM "x" GROUP BY status'))
|
||||
.toBe('TASK FROM "x" GROUP BY status');
|
||||
});
|
||||
|
||||
test('an implicit grouped LIST renders grouped rows end-to-end, not bare keys', async () => {
|
||||
const app = new MockApp(true, true);
|
||||
const api = new MockObsidianAPI(app);
|
||||
const tool = new DataviewTool(api as any);
|
||||
|
||||
// No `rows` in the user query; the faithful mock would return bare keys
|
||||
// without the injection. With it, proper {key, rows} groups come back.
|
||||
const result = await tool.executeQuery('LIST FROM #tag GROUP BY group');
|
||||
const output = formatResponse('dataview', 'query', result, false);
|
||||
|
||||
expect(output).toContain('Dataview: LIST');
|
||||
expect(output).toContain('groupA');
|
||||
expect(output).toContain('Note 1'); // grouped row recovered
|
||||
expect(output).toContain('Note 2');
|
||||
expect(output).not.toContain('$widget');
|
||||
});
|
||||
});
|
||||
|
||||
// Live follow-up: `TASK ... GROUP BY status` groups incomplete tasks under a
|
||||
// space-character key, which rendered as an empty bold header (`**** (n)`).
|
||||
describe('whitespace group key label', () => {
|
||||
test('a whitespace-only group key renders as (no group), not empty bold', () => {
|
||||
const result = {
|
||||
success: true,
|
||||
query: 'TASK FROM "x" GROUP BY status',
|
||||
format: 'dql',
|
||||
result: {
|
||||
type: 'task',
|
||||
values: [
|
||||
{ key: ' ', rows: [{ text: 'incomplete task', completed: false, path: 'Note1.md' }] }
|
||||
]
|
||||
},
|
||||
type: 'task'
|
||||
};
|
||||
|
||||
const output = formatResponse('dataview', 'query', result, false);
|
||||
|
||||
expect(output).toContain('(no group)');
|
||||
expect(output).toContain('incomplete task');
|
||||
expect(output).not.toContain('**** '); // no empty bold header
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Availability Detection', () => {
|
||||
test('should detect when Dataview tool is not available', () => {
|
||||
const app = new MockApp(false);
|
||||
|
|
|
|||
Loading…
Reference in a new issue