From 6317dade9eba1658a5a804fe452a99b68358c882 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Wed, 24 Jun 2026 10:04:09 +0500 Subject: [PATCH] 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) --- src/app/(student)/tools/bases/BasesForm.tsx | 93 ++++++++++++++++ src/app/(student)/tools/bases/page.tsx | 15 +++ .../(student)/tools/dataview/DataviewForm.tsx | 103 ++++++++++++++++++ src/app/(student)/tools/dataview/page.tsx | 15 +++ src/components/tools/ToolCard.tsx | 4 +- src/components/tools/ToolboxPromoCard.tsx | 2 +- src/lib/tools/__tests__/bases.test.ts | 58 ++++++++++ src/lib/tools/__tests__/dataview.test.ts | 53 +++++++++ src/lib/tools/_shared/syntax-versions.json | 12 ++ src/lib/tools/_shared/types.ts | 6 +- src/lib/tools/bases.ts | 76 +++++++++++++ src/lib/tools/dataview.ts | 71 ++++++++++++ 12 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 src/app/(student)/tools/bases/BasesForm.tsx create mode 100644 src/app/(student)/tools/bases/page.tsx create mode 100644 src/app/(student)/tools/dataview/DataviewForm.tsx create mode 100644 src/app/(student)/tools/dataview/page.tsx create mode 100644 src/lib/tools/__tests__/bases.test.ts create mode 100644 src/lib/tools/__tests__/dataview.test.ts create mode 100644 src/lib/tools/bases.ts create mode 100644 src/lib/tools/dataview.ts diff --git a/src/app/(student)/tools/bases/BasesForm.tsx b/src/app/(student)/tools/bases/BasesForm.tsx new file mode 100644 index 0000000..54c491a --- /dev/null +++ b/src/app/(student)/tools/bases/BasesForm.tsx @@ -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 = { + none: "", + folder: "Projects", + tag: "#проект", + property: 'status == "done"', +}; + +export function BasesForm() { + const [viewType, setViewType] = useState("table"); + const [viewName, setViewName] = useState("Мои заметки"); + const [propsText, setPropsText] = useState("file.name, status"); + const [filterScope, setFilterScope] = useState("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 ( +
+
+ + + + +
+ + {filterScope !== "none" && ( + setFilterValue(e.target.value)} /> + )} +
+ +
+ + +
+ + +
+
+ +
+
+ ); +} diff --git a/src/app/(student)/tools/bases/page.tsx b/src/app/(student)/tools/bases/page.tsx new file mode 100644 index 0000000..7bd033e --- /dev/null +++ b/src/app/(student)/tools/bases/page.tsx @@ -0,0 +1,15 @@ +import { BasesForm } from "./BasesForm"; + +export const metadata = { title: "Генератор Bases — Obsidian Toolbox" }; + +export default function BasesToolPage() { + return ( +
+

Генератор Bases

+

Базы Obsidian: представление-таблица с фильтрами и сортировкой (.base-файл).

+
+ +
+
+ ); +} diff --git a/src/app/(student)/tools/dataview/DataviewForm.tsx b/src/app/(student)/tools/dataview/DataviewForm.tsx new file mode 100644 index 0000000..3593fcf --- /dev/null +++ b/src/app/(student)/tools/dataview/DataviewForm.tsx @@ -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("TABLE"); + const [withoutId, setWithoutId] = useState(false); + const [columns, setColumns] = useState([ + { 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) { + setColumns((prev) => prev.map((c, idx) => (idx === i ? { ...c, ...patch } : c))); + } + + return ( +
+
+ + + {(queryType === "TABLE" || queryType === "LIST") && ( + + )} + + {showColumns && ( +
+ {colLabel} + {columns.map((c, i) => ( +
+ updateCol(i, { expr: e.target.value })} /> + {queryType === "TABLE" && ( + updateCol(i, { alias: e.target.value })} /> + )} + +
+ ))} + {queryType === "TABLE" && ( + + )} +
+ )} + + + +
+ + +
+ +
+
+ +
+
+ ); +} diff --git a/src/app/(student)/tools/dataview/page.tsx b/src/app/(student)/tools/dataview/page.tsx new file mode 100644 index 0000000..c826efc --- /dev/null +++ b/src/app/(student)/tools/dataview/page.tsx @@ -0,0 +1,15 @@ +import { DataviewForm } from "./DataviewForm"; + +export const metadata = { title: "Генератор Dataview — Obsidian Toolbox" }; + +export default function DataviewToolPage() { + return ( +
+

Генератор Dataview

+

Запросы DQL: таблицы, списки, задачи и календарь по заметкам.

+
+ +
+
+ ); +} diff --git a/src/components/tools/ToolCard.tsx b/src/components/tools/ToolCard.tsx index 149f87c..247c390 100644 --- a/src/components/tools/ToolCard.tsx +++ b/src/components/tools/ToolCard.tsx @@ -1,5 +1,5 @@ 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 * as Icons`) — иначе весь набор lucide попадает в бандл. @@ -8,6 +8,8 @@ const ICONS: Record = { FileCode2, Palette, SlidersHorizontal, + Table2, + Database, }; export function ToolCard({ tool }: { tool: ToolMeta }) { diff --git a/src/components/tools/ToolboxPromoCard.tsx b/src/components/tools/ToolboxPromoCard.tsx index 32275b1..9e1dc1c 100644 --- a/src/components/tools/ToolboxPromoCard.tsx +++ b/src/components/tools/ToolboxPromoCard.tsx @@ -15,7 +15,7 @@ export function ToolboxPromoCard() { Инструменты Obsidian

- Генераторы синтаксиса: callout-CSS, YAML-frontmatter, темы, Style Settings. + Генераторы синтаксиса: callout, frontmatter, темы, Style Settings, Dataview, Bases.

diff --git a/src/lib/tools/__tests__/bases.test.ts b/src/lib/tools/__tests__/bases.test.ts new file mode 100644 index 0000000..9ac70f9 --- /dev/null +++ b/src/lib/tools/__tests__/bases.test.ts @@ -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: "Книги: всё"'); + }); +}); diff --git a/src/lib/tools/__tests__/dataview.test.ts b/src/lib/tools/__tests__/dataview.test.ts new file mode 100644 index 0000000..b6f9dc7 --- /dev/null +++ b/src/lib/tools/__tests__/dataview.test.ts @@ -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"); + }); +}); diff --git a/src/lib/tools/_shared/syntax-versions.json b/src/lib/tools/_shared/syntax-versions.json index 3d23a9b..c28fc7b 100644 --- a/src/lib/tools/_shared/syntax-versions.json +++ b/src/lib/tools/_shared/syntax-versions.json @@ -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"] } } diff --git a/src/lib/tools/_shared/types.ts b/src/lib/tools/_shared/types.ts index 558ec21..07be91b 100644 --- a/src/lib/tools/_shared/types.ts +++ b/src/lib/tools/_shared/types.ts @@ -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" }, ]; diff --git a/src/lib/tools/bases.ts b/src/lib/tools/bases.ts new file mode 100644 index 0000000..922498b --- /dev/null +++ b/src/lib/tools/bases.ts @@ -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"); +} diff --git a/src/lib/tools/dataview.ts b/src/lib/tools/dataview.ts new file mode 100644 index 0000000..e412d1d --- /dev/null +++ b/src/lib/tools/dataview.ts @@ -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```"; +}