diff --git a/src/formatters/dataview.ts b/src/formatters/dataview.ts index 23eaf9d..c45f275 100644 --- a/src/formatters/dataview.ts +++ b/src/formatters/dataview.ts @@ -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') { diff --git a/src/tools/dataview-tool.ts b/src/tools/dataview-tool.ts index 1284ad5..4a831de 100644 --- a/src/tools/dataview-tool.ts +++ b/src/tools/dataview-tool.ts @@ -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, diff --git a/tests/dataview-integration.test.ts b/tests/dataview-integration.test.ts index e2ae36c..83b7f76 100644 --- a/tests/dataview-integration.test.ts +++ b/tests/dataview-integration.test.ts @@ -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);