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,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>
);
}