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
+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",
"version": "1.x",
"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[] = [
"callout",
"frontmatter",
"theme",
"style-settings",
"dataview",
"bases",
];
export interface ToolMeta {
@@ -20,4 +22,6 @@ export const TOOLS: ToolMeta[] = [
{ id: "frontmatter", title: "Генератор YAML-frontmatter", description: "Шаблоны свойств заметок: типы, дефолты, теги.", icon: "FileCode2" },
{ id: "theme", title: "Генератор темы / CSS-переменных", description: "Палитра → набор CSS-переменных темы Obsidian.", icon: "Palette" },
{ 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```";
}