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