Add Dataview and Bases generators (Toolbox Phase 2)

Two more inside-LMS generators under /tools, completing the 6-tool set:
- Dataview: DQL builder (TABLE/LIST/TASK/CALENDAR, FROM/WHERE/SORT/LIMIT),
  output wrapped in a dataview code block
- Bases: .base YAML builder (views, filters and:, order, sort, groupBy, limit);
  filter expressions emitted as single-quoted YAML scalars for safety

Syntax verified against official Obsidian docs (research workflow). Audit fix:
Bases sort entries use 'column:' (not 'property:') per documented working .base
files; 'property:' is kept only for groupBy. 14 new tests (47 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 10:04:09 +05:00
parent 1aa99b9def
commit 6317dade9e
12 changed files with 505 additions and 3 deletions
@@ -0,0 +1,93 @@
"use client";
import { useMemo, useState } from "react";
import { buildBases, type BaseViewType, type BaseFilterScope } from "@/lib/tools/bases";
import { CodeOutput } from "@/components/tools/CodeOutput";
const VIEW_TYPES: BaseViewType[] = ["table", "cards", "list", "map"];
const SCOPES: { id: BaseFilterScope; label: string }[] = [
{ id: "none", label: "без фильтра" },
{ id: "folder", label: "по папке" },
{ id: "tag", label: "по тегу" },
{ id: "property", label: "по свойству (выражение)" },
];
const field = "w-full p-2 text-sm";
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
const SCOPE_PLACEHOLDER: Record<BaseFilterScope, string> = {
none: "",
folder: "Projects",
tag: "#проект",
property: 'status == "done"',
};
export function BasesForm() {
const [viewType, setViewType] = useState<BaseViewType>("table");
const [viewName, setViewName] = useState("Мои заметки");
const [propsText, setPropsText] = useState("file.name, status");
const [filterScope, setFilterScope] = useState<BaseFilterScope>("folder");
const [filterValue, setFilterValue] = useState("Projects");
const [sortBy, setSortBy] = useState("file.name");
const [sortDir, setSortDir] = useState<"ASC" | "DESC">("ASC");
const [groupBy, setGroupBy] = useState("");
const [limit, setLimit] = useState("");
const yaml = useMemo(
() => buildBases({
viewType, viewName,
properties: propsText.split(",").map((s) => s.trim()).filter(Boolean),
filterScope, filterValue,
sortBy, sortDir, groupBy,
limit: limit ? Number(limit) : undefined,
}),
[viewType, viewName, propsText, filterScope, filterValue, sortBy, sortDir, groupBy, limit],
);
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="flex flex-col gap-3">
<label className="text-sm">Тип представления
<select className={field} style={fieldStyle} value={viewType} onChange={(e) => setViewType(e.target.value as BaseViewType)}>
{VIEW_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
<label className="text-sm">Имя представления
<input className={field} style={fieldStyle} value={viewName} onChange={(e) => setViewName(e.target.value)} />
</label>
<label className="text-sm">Свойства / колонки (через запятую)
<input className={field} style={fieldStyle} placeholder="file.name, status, …" value={propsText} onChange={(e) => setPropsText(e.target.value)} />
</label>
<div className="flex items-end gap-2">
<label className="text-sm flex-1">Фильтр
<select className={field} style={fieldStyle} value={filterScope} onChange={(e) => setFilterScope(e.target.value as BaseFilterScope)}>
{SCOPES.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</label>
{filterScope !== "none" && (
<input className={field} style={fieldStyle} placeholder={SCOPE_PLACEHOLDER[filterScope]} value={filterValue} onChange={(e) => setFilterValue(e.target.value)} />
)}
</div>
<div className="flex items-end gap-2">
<label className="text-sm flex-1">Сортировка (свойство)
<input className={field} style={fieldStyle} placeholder="не сортировать" value={sortBy} onChange={(e) => setSortBy(e.target.value)} />
</label>
<select className={field} style={{ ...fieldStyle, width: "auto" }} value={sortDir} onChange={(e) => setSortDir(e.target.value as "ASC" | "DESC")}>
<option value="ASC">ASC</option>
<option value="DESC">DESC</option>
</select>
</div>
<label className="text-sm">Группировка (свойство)
<input className={field} style={fieldStyle} placeholder="без группировки" value={groupBy} onChange={(e) => setGroupBy(e.target.value)} />
</label>
<label className="text-sm">Лимит
<input type="number" min={0} className={field} style={fieldStyle} placeholder="без лимита" value={limit} onChange={(e) => setLimit(e.target.value)} />
</label>
</div>
<div className="flex flex-col gap-4">
<CodeOutput code={yaml} tool="bases" label="Содержимое .base-файла" />
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { BasesForm } from "./BasesForm";
export const metadata = { title: "Генератор Bases — Obsidian Toolbox" };
export default function BasesToolPage() {
return (
<main className="mx-auto w-full max-w-5xl px-6 py-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Генератор Bases</h1>
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Базы Obsidian: представление-таблица с фильтрами и сортировкой (.base-файл).</p>
<div className="mt-6 card-aubade p-4">
<BasesForm />
</div>
</main>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { buildDataview, type DataviewColumn, type DataviewQueryType } from "@/lib/tools/dataview";
import { CodeOutput } from "@/components/tools/CodeOutput";
const QUERY_TYPES: DataviewQueryType[] = ["TABLE", "LIST", "TASK", "CALENDAR"];
const field = "w-full p-2 text-sm";
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
export function DataviewForm() {
const [queryType, setQueryType] = useState<DataviewQueryType>("TABLE");
const [withoutId, setWithoutId] = useState(false);
const [columns, setColumns] = useState<DataviewColumn[]>([
{ expr: "file.name", alias: "Заметка" },
{ expr: "file.mtime", alias: "Изменено" },
]);
const [from, setFrom] = useState("#project");
const [where, setWhere] = useState("");
const [sortField, setSortField] = useState("file.mtime");
const [sortDir, setSortDir] = useState<"ASC" | "DESC">("DESC");
const [limit, setLimit] = useState("");
const query = useMemo(
() => buildDataview({
queryType, withoutId, columns,
from, where, sortField, sortDir,
limit: limit ? Number(limit) : undefined,
}),
[queryType, withoutId, columns, from, where, sortField, sortDir, limit],
);
const showColumns = queryType !== "TASK";
const colLabel = queryType === "CALENDAR" ? "поле даты" : queryType === "LIST" ? "поле (берётся первое)" : "колонки";
function updateCol(i: number, patch: Partial<DataviewColumn>) {
setColumns((prev) => prev.map((c, idx) => (idx === i ? { ...c, ...patch } : c)));
}
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="flex flex-col gap-3">
<label className="text-sm">Тип запроса
<select className={field} style={fieldStyle} value={queryType} onChange={(e) => setQueryType(e.target.value as DataviewQueryType)}>
{QUERY_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
{(queryType === "TABLE" || queryType === "LIST") && (
<label className="text-sm flex items-center gap-2">
<input type="checkbox" checked={withoutId} onChange={(e) => setWithoutId(e.target.checked)} />
WITHOUT ID (скрыть колонку-ссылку)
</label>
)}
{showColumns && (
<div className="flex flex-col gap-2">
<span className="text-sm">{colLabel}</span>
{columns.map((c, i) => (
<div key={i} className="flex items-center gap-2">
<input className={field} style={fieldStyle} placeholder="выражение (file.name)" value={c.expr} onChange={(e) => updateCol(i, { expr: e.target.value })} />
{queryType === "TABLE" && (
<input className={field} style={fieldStyle} placeholder="алиас" value={c.alias ?? ""} onChange={(e) => updateCol(i, { alias: e.target.value })} />
)}
<button type="button" onClick={() => setColumns((p) => p.filter((_, idx) => idx !== i))} aria-label="Удалить" className="btn-aubade inline-flex items-center justify-center p-2">
<Trash2 size={16} />
</button>
</div>
))}
{queryType === "TABLE" && (
<button type="button" onClick={() => setColumns((p) => [...p, { expr: "" }])} className="btn-aubade inline-flex items-center justify-center gap-2 text-sm">
<Plus size={16} /> Колонка
</button>
)}
</div>
)}
<label className="text-sm">FROM (источник)
<input className={field} style={fieldStyle} placeholder='#tag или "Папка" или [[Заметка]]' value={from} onChange={(e) => setFrom(e.target.value)} />
</label>
<label className="text-sm">WHERE (условие)
<input className={field} style={fieldStyle} placeholder="!completed" value={where} onChange={(e) => setWhere(e.target.value)} />
</label>
<div className="flex items-end gap-2">
<label className="text-sm flex-1">SORT (поле)
<input className={field} style={fieldStyle} value={sortField} onChange={(e) => setSortField(e.target.value)} />
</label>
<select className={field} style={{ ...fieldStyle, width: "auto" }} value={sortDir} onChange={(e) => setSortDir(e.target.value as "ASC" | "DESC")}>
<option value="DESC">DESC</option>
<option value="ASC">ASC</option>
</select>
</div>
<label className="text-sm">LIMIT
<input type="number" min={0} className={field} style={fieldStyle} placeholder="без лимита" value={limit} onChange={(e) => setLimit(e.target.value)} />
</label>
</div>
<div className="flex flex-col gap-4">
<CodeOutput code={query} tool="dataview" label="Запрос Dataview (вставь в заметку)" />
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { DataviewForm } from "./DataviewForm";
export const metadata = { title: "Генератор Dataview — Obsidian Toolbox" };
export default function DataviewToolPage() {
return (
<main className="mx-auto w-full max-w-5xl px-6 py-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Генератор Dataview</h1>
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Запросы DQL: таблицы, списки, задачи и календарь по заметкам.</p>
<div className="mt-6 card-aubade p-4">
<DataviewForm />
</div>
</main>
);
}
+3 -1
View File
@@ -1,5 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Wrench, type LucideIcon } from "lucide-react"; import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, Wrench, type LucideIcon } from "lucide-react";
import type { ToolMeta } from "@/lib/tools/_shared/types"; import type { ToolMeta } from "@/lib/tools/_shared/types";
// Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл. // Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл.
@@ -8,6 +8,8 @@ const ICONS: Record<string, LucideIcon> = {
FileCode2, FileCode2,
Palette, Palette,
SlidersHorizontal, SlidersHorizontal,
Table2,
Database,
}; };
export function ToolCard({ tool }: { tool: ToolMeta }) { export function ToolCard({ tool }: { tool: ToolMeta }) {
+1 -1
View File
@@ -15,7 +15,7 @@ export function ToolboxPromoCard() {
Инструменты Obsidian Инструменты Obsidian
</h2> </h2>
<p className="text-sm" style={{ color: "var(--foreground)" }}> <p className="text-sm" style={{ color: "var(--foreground)" }}>
Генераторы синтаксиса: callout-CSS, YAML-frontmatter, темы, Style Settings. Генераторы синтаксиса: callout, frontmatter, темы, Style Settings, Dataview, Bases.
</p> </p>
</div> </div>
<span className="text-sm font-bold whitespace-nowrap" style={{ color: "var(--foreground)" }}> <span className="text-sm font-bold whitespace-nowrap" style={{ color: "var(--foreground)" }}>
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { buildBases } from "@/lib/tools/bases";
const base = {
viewType: "table" as const,
viewName: "Проекты",
properties: ["file.name", "status"],
filterScope: "none" as const,
filterValue: "",
};
describe("buildBases", () => {
it("базовая таблица: type/name/order", () => {
const y = buildBases(base);
expect(y).toContain("views:");
expect(y).toContain(" - type: table");
expect(y).toContain(" name: Проекты");
expect(y).toContain(" order:");
expect(y).toContain(" - file.name");
expect(y).toContain(" - status");
expect(y).not.toContain("filters:");
});
it("фильтр по папке", () => {
const y = buildBases({ ...base, filterScope: "folder", filterValue: "Projects" });
expect(y).toContain("filters:");
expect(y).toContain(" and:");
expect(y).toContain(` - 'file.inFolder("Projects")'`);
});
it("фильтр по тегу снимает ведущий #", () => {
const y = buildBases({ ...base, filterScope: "tag", filterValue: "#проект" });
expect(y).toContain(`- 'file.hasTag("проект")'`);
});
it("property-фильтр — готовое выражение как есть", () => {
const y = buildBases({ ...base, filterScope: "property", filterValue: 'status == "done"' });
expect(y).toContain(`- 'status == "done"'`);
});
it("сортировка → sort: column/direction (не property)", () => {
const y = buildBases({ ...base, sortBy: "status", sortDir: "DESC" });
expect(y).toContain(" sort:");
expect(y).toContain(" - column: status");
expect(y).toContain(" direction: DESC");
});
it("группировка и лимит", () => {
const y = buildBases({ ...base, groupBy: "status", limit: 50 });
expect(y).toContain(" groupBy:");
expect(y).toContain(" property: status");
expect(y).toContain(" limit: 50");
});
it("имя вида квотится при двоеточии+пробеле", () => {
expect(buildBases({ ...base, viewName: "Книги: всё" })).toContain('name: "Книги: всё"');
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { buildDataview } from "@/lib/tools/dataview";
describe("buildDataview", () => {
it("оборачивает в кодблок dataview", () => {
const q = buildDataview({ queryType: "LIST" });
expect(q.startsWith("```dataview\n")).toBe(true);
expect(q.trimEnd().endsWith("```")).toBe(true);
});
it("TABLE с колонками, алиасом, WITHOUT ID", () => {
const q = buildDataview({
queryType: "TABLE",
withoutId: true,
columns: [{ expr: "file.name", alias: "Имя" }, { expr: "status" }],
});
expect(q).toContain('TABLE WITHOUT ID file.name AS "Имя", status');
});
it("FROM / WHERE / SORT / LIMIT в правильном порядке", () => {
const q = buildDataview({
queryType: "TABLE",
columns: [{ expr: "file.name" }],
from: "#project",
where: "!completed",
sortField: "file.mtime",
sortDir: "DESC",
limit: 20,
});
const body = q.replace(/```dataview\n|\n```/g, "");
expect(body).toBe("TABLE file.name\nFROM #project\nWHERE !completed\nSORT file.mtime DESC\nLIMIT 20");
});
it("TASK без полей", () => {
const q = buildDataview({ queryType: "TASK", from: '"Задачи"' });
expect(q).toContain("TASK\nFROM \"Задачи\"");
});
it("CALENDAR подставляет дефолтное поле даты", () => {
expect(buildDataview({ queryType: "CALENDAR" })).toContain("CALENDAR file.ctime");
});
it("LIST с одним полем", () => {
expect(buildDataview({ queryType: "LIST", columns: [{ expr: "file.mtime" }] }))
.toContain("LIST file.mtime");
});
it("игнорирует пустой limit и пустые колонки", () => {
const q = buildDataview({ queryType: "TABLE", columns: [{ expr: " " }], limit: 0 });
expect(q).toContain("```dataview\nTABLE\n```");
expect(q).not.toContain("LIMIT");
});
});
@@ -6,5 +6,17 @@
"ref": "obsidian-community/obsidian-style-settings", "ref": "obsidian-community/obsidian-style-settings",
"version": "1.x", "version": "1.x",
"types": ["heading", "class-toggle", "class-select", "variable-text", "variable-number", "variable-number-slider", "variable-select", "variable-color", "info-text"] "types": ["heading", "class-toggle", "class-select", "variable-text", "variable-number", "variable-number-slider", "variable-select", "variable-color", "info-text"]
},
"dataview": {
"ref": "blacksmithgu/obsidian-dataview (DQL)",
"version": "0.5.x",
"queryTypes": ["TABLE", "LIST", "TASK", "CALENDAR"],
"dataCommands": ["FROM", "WHERE", "SORT", "GROUP BY", "FLATTEN", "LIMIT"]
},
"bases": {
"ref": "Obsidian Bases (core, .base YAML)",
"version": "obsidian-1.9+ table/cards, 1.10+ list/map",
"sections": ["filters", "formulas", "properties", "summaries", "views"],
"viewTypes": ["table", "cards", "list", "map"]
} }
} }
+5 -1
View File
@@ -1,10 +1,12 @@
export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings"; export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings" | "dataview" | "bases";
export const TOOL_IDS: ToolId[] = [ export const TOOL_IDS: ToolId[] = [
"callout", "callout",
"frontmatter", "frontmatter",
"theme", "theme",
"style-settings", "style-settings",
"dataview",
"bases",
]; ];
export interface ToolMeta { export interface ToolMeta {
@@ -20,4 +22,6 @@ export const TOOLS: ToolMeta[] = [
{ id: "frontmatter", title: "Генератор YAML-frontmatter", description: "Шаблоны свойств заметок: типы, дефолты, теги.", icon: "FileCode2" }, { id: "frontmatter", title: "Генератор YAML-frontmatter", description: "Шаблоны свойств заметок: типы, дефолты, теги.", icon: "FileCode2" },
{ id: "theme", title: "Генератор темы / CSS-переменных", description: "Палитра → набор CSS-переменных темы Obsidian.", icon: "Palette" }, { id: "theme", title: "Генератор темы / CSS-переменных", description: "Палитра → набор CSS-переменных темы Obsidian.", icon: "Palette" },
{ id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" }, { id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" },
{ id: "dataview", title: "Генератор Dataview", description: "Запросы DQL: таблицы, списки и задачи по заметкам.", icon: "Table2" },
{ id: "bases", title: "Генератор Bases", description: "Базы Obsidian (.base): представления с фильтрами.", icon: "Database" },
]; ];
+76
View File
@@ -0,0 +1,76 @@
import { yamlScalar } from "./_shared/yaml";
export type BaseViewType = "table" | "cards" | "list" | "map";
export type BaseFilterScope = "none" | "folder" | "tag" | "property";
export type SortDirection = "ASC" | "DESC";
export interface BasesInput {
viewType: BaseViewType;
viewName: string;
/** свойства/колонки → views[0].order */
properties: string[];
filterScope: BaseFilterScope;
/** значение фильтра: путь папки / тег / готовое выражение (для property) */
filterValue: string;
sortBy?: string;
sortDir?: SortDirection;
groupBy?: string;
limit?: number;
}
// Условие фильтра пишем как YAML-строку (single-quoted) — она содержит свои "…",
// а Bases всё равно получает строковое выражение, кавычки YAML прозрачны.
function singleQuoted(s: string): string {
return "'" + s.replace(/'/g, "''") + "'";
}
function filterCondition(scope: BaseFilterScope, value: string): string | null {
const v = value.trim();
if (scope === "none" || !v) return null;
if (scope === "folder") return `file.inFolder("${v.replace(/"/g, '\\"')}")`;
// file.hasTag принимает имя тега без ведущего '#'
if (scope === "tag") return `file.hasTag("${v.replace(/^#+/, "").replace(/"/g, '\\"')}")`;
// property: пользователь вводит готовое выражение, напр. status == "done"
return v;
}
/** Собирает содержимое .base-файла (YAML) из полей формы. */
export function buildBases(input: BasesInput): string {
const lines: string[] = [];
const cond = filterCondition(input.filterScope, input.filterValue);
if (cond) {
lines.push("filters:");
lines.push(" and:");
lines.push(` - ${singleQuoted(cond)}`);
}
lines.push("views:");
lines.push(` - type: ${input.viewType}`);
lines.push(` name: ${yamlScalar(input.viewName.trim() || "Представление")}`);
const props = input.properties.map((p) => p.trim()).filter(Boolean);
if (props.length) {
lines.push(" order:");
props.forEach((p) => lines.push(` - ${yamlScalar(p)}`));
}
if (input.sortBy?.trim()) {
// В sort ключ записи — column (по рабочим .base); property корректен только в groupBy.
lines.push(" sort:");
lines.push(` - column: ${yamlScalar(input.sortBy.trim())}`);
lines.push(` direction: ${input.sortDir ?? "ASC"}`);
}
if (input.groupBy?.trim()) {
lines.push(" groupBy:");
lines.push(` property: ${yamlScalar(input.groupBy.trim())}`);
lines.push(" direction: ASC");
}
if (input.limit && input.limit > 0) {
lines.push(` limit: ${Math.floor(input.limit)}`);
}
return lines.join("\n");
}
+71
View File
@@ -0,0 +1,71 @@
export type DataviewQueryType = "TABLE" | "LIST" | "TASK" | "CALENDAR";
export interface DataviewColumn {
/** выражение/поле, напр. file.name, status, file.mtime */
expr: string;
/** опц. человекочитаемый заголовок колонки (AS "…") */
alias?: string;
}
export interface DataviewInput {
queryType: DataviewQueryType;
/** WITHOUT ID — скрыть первую колонку-ссылку (TABLE/LIST) */
withoutId?: boolean;
/** колонки/поля (TABLE — все; LIST/CALENDAR — берётся первое) */
columns?: DataviewColumn[];
/** источник FROM как есть: '#tag', '"Папка"', '[[Заметка]]', их комбинации */
from?: string;
/** условие WHERE как есть */
where?: string;
/** поле сортировки */
sortField?: string;
sortDir?: "ASC" | "DESC";
/** LIMIT n */
limit?: number;
}
function renderColumns(cols: DataviewColumn[]): string {
return cols
.filter((c) => c.expr.trim())
.map((c) => {
const expr = c.expr.trim();
const alias = c.alias?.trim();
return alias ? `${expr} AS "${alias.replace(/"/g, '\\"')}"` : expr;
})
.join(", ");
}
/** Собирает запрос Dataview DQL и оборачивает в кодблок dataview. */
export function buildDataview(input: DataviewInput): string {
const lines: string[] = [];
const cols = input.columns ?? [];
const withoutId = input.withoutId ? " WITHOUT ID" : "";
switch (input.queryType) {
case "TABLE": {
const c = renderColumns(cols);
lines.push(`TABLE${withoutId}${c ? " " + c : ""}`);
break;
}
case "LIST": {
const first = cols.find((c) => c.expr.trim())?.expr.trim();
lines.push(`LIST${withoutId}${first ? " " + first : ""}`);
break;
}
case "TASK":
lines.push("TASK");
break;
case "CALENDAR": {
const dateField = cols.find((c) => c.expr.trim())?.expr.trim() || "file.ctime";
lines.push(`CALENDAR ${dateField}`);
break;
}
}
if (input.from?.trim()) lines.push(`FROM ${input.from.trim()}`);
if (input.where?.trim()) lines.push(`WHERE ${input.where.trim()}`);
if (input.sortField?.trim()) lines.push(`SORT ${input.sortField.trim()} ${input.sortDir ?? "DESC"}`);
if (input.limit && input.limit > 0) lines.push(`LIMIT ${Math.floor(input.limit)}`);
return "```dataview\n" + lines.join("\n") + "\n```";
}