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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<string, LucideIcon> = {
|
||||
FileCode2,
|
||||
Palette,
|
||||
SlidersHorizontal,
|
||||
Table2,
|
||||
Database,
|
||||
};
|
||||
|
||||
export function ToolCard({ tool }: { tool: ToolMeta }) {
|
||||
|
||||
@@ -15,7 +15,7 @@ export function ToolboxPromoCard() {
|
||||
Инструменты Obsidian
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: "var(--foreground)" }}>
|
||||
Генераторы синтаксиса: callout-CSS, YAML-frontmatter, темы, Style Settings.
|
||||
Генераторы синтаксиса: callout, frontmatter, темы, Style Settings, Dataview, Bases.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm font-bold whitespace-nowrap" style={{ color: "var(--foreground)" }}>
|
||||
|
||||
@@ -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