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:
@@ -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: "Книги: всё"');
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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```";
|
||||
}
|
||||
Reference in New Issue
Block a user