import { parse as parseYaml } from 'yaml'; import { FoamWorkspace } from '../model/workspace'; import { FoamGraph } from '../model/graph'; import { QueryDescriptor, QueryFilter, executeQuery, ALL_QUERY_FIELDS, SourceReader, requiresSource, SelectEntry, normalizeSelectEntry, } from '.'; import { MarkdownRenderer, QueryRender, ToHref } from './html'; import { RenderContext } from './render-context'; import { escapeHtml, renderResults } from './html'; import { URI } from '../model/uri'; const DQL_PLACEHOLDER = `
Use \`\`\`foam-query blocks to write a query to list notes. For example:
\`\`\`foam-query filter: "#recipe" sort: title ASC \`\`\`
\`\`\`foam-query filter: "#recipe" select: [title, tags, backlink-count] format: table \`\`\`
Filter examples:
filter: "#tag" # notes tagged with #tag
filter: "[[note-id]]" # notes linked to or from note (same id as in wikilinks)
filter: "*" # all notes
filter: "/path/regex/" # notes whose path matches a regex
filter:
tag: recipe # object form — same as "#recipe"
filter:
and:
- "#recipe"
- "#published" # notes matching all conditions
filter:
or:
- "#recipe"
- "#cooking" # notes matching any condition
filter:
not: "#draft" # notes not matching a condition
\`\`\`foam-query filter: "#recipe" select: [title, properties.status, properties.date] format: table \`\`\`
Read the full documentation here
filter must be a string like "#tag" or a mapping — use * to match all notes`;
}
break;
case 'select': {
const available = ALL_QUERY_FIELDS.map(f => `${f}`).join(
', '
);
if (!Array.isArray(value)) {
return `Field select must be a list of fields. Available: ${available}`;
}
if (value.length === 0) {
return `Field select requires at least one field. Available: ${available}`;
}
break;
}
case 'sort':
if (typeof value !== 'string') {
return `Field sort must be a string like title ASC`;
}
break;
case 'limit':
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
return `Field limit must be a positive integer`;
}
break;
case 'offset':
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
return `Field offset must be a non-negative integer`;
}
break;
case 'format':
if (!VALID_FORMATS.has(value as string)) {
return `Field format must be one of: list, table, count`;
}
break;
}
return null;
}
function renderWarnings(warnings: string[]): string {
if (warnings.length === 0) return '';
const items = warnings.map(w => `${w}
`).join(''); return `${CURRENT_SENTINEL} but no note is currently active — no results will be shown`
);
resolved[key] = undefined;
}
}
}
if (resolved.and) {
resolved.and = resolved.and.map(f =>
resolveCurrentSentinel(f, currentResource, warnings)
);
}
if (resolved.or) {
resolved.or = resolved.or.map(f =>
resolveCurrentSentinel(f, currentResource, warnings)
);
}
if (resolved.not) {
resolved.not = resolveCurrentSentinel(resolved.not, currentResource, warnings);
}
return resolved;
}
export interface RenderDqlQueryOptions {
workspace: FoamWorkspace;
graph: FoamGraph;
trusted: boolean;
toHref: ToHref;
currentResource?: URI | null;
readSource?: SourceReader;
renderMarkdown?: MarkdownRenderer;
context?: RenderContext;
}
export function renderDqlQuery(
content: string,
opts: RenderDqlQueryOptions
): QueryRender {
const {
workspace,
graph,
trusted,
toHref,
currentResource,
readSource,
renderMarkdown,
context,
} = opts;
const bailout = (html: string): QueryRender => ({ html, shape: 'unknown' });
if (content.trim() === '') {
return bailout(DQL_PLACEHOLDER);
}
const warnings: string[] = [];
let parsed: Record${escapeHtml(key)} — ignored`);
delete parsed[key];
} else if (parsed[key] === null || parsed[key] === '') {
if (key === 'filter') {
filterIsEmpty = true;
warnings.push(
`Field filter requires a value — use * to match all notes`
);
} else {
warnings.push(
`Missing value for field ${escapeHtml(key)} — ignored`
);
}
delete parsed[key];
} else {
const warning = validateFieldValue(key, parsed[key]);
if (warning) {
if (key === 'filter') filterIsEmpty = true;
warnings.push(warning);
delete parsed[key];
}
}
}
if (Object.keys(parsed).length === 0 || filterIsEmpty) {
return bailout(DQL_PLACEHOLDER + renderWarnings(warnings));
}
// Strip and warn on unknown `select` entries.
if (Array.isArray(parsed.select)) {
const validFields = ALL_QUERY_FIELDS.join(', ');
const valid: SelectEntry[] = [];
for (const entry of parsed.select as unknown[]) {
let field: string | undefined;
let label: string | undefined;
let link: boolean | undefined;
if (typeof entry === 'string') {
field = entry;
} else if (
entry !== null &&
typeof entry === 'object' &&
!Array.isArray(entry) &&
typeof (entry as { field?: unknown }).field === 'string'
) {
field = (entry as { field: string }).field;
const rawLabel = (entry as { label?: unknown }).label;
if (typeof rawLabel === 'string') label = rawLabel;
const rawLink = (entry as { link?: unknown }).link;
if (typeof rawLink === 'boolean') link = rawLink;
}
if (
field !== undefined &&
(ALL_QUERY_FIELDS.includes(field) ||
/^properties\..+$/.test(field) ||
requiresSource(field))
) {
const input =
label !== undefined || link !== undefined
? { field, label, link }
: field;
valid.push(normalizeSelectEntry(input));
} else {
const shown = field ?? JSON.stringify(entry);
warnings.push(
`Unknown select field ${escapeHtml(
shown
)} — available: ${validFields}, body, content, section[Label], or properties.fieldname`
);
}
}
if (valid.length === 0) {
delete parsed.select; // fall back to default
} else {
(parsed as Record