generated from obsidianmd/obsidian-sample-plugin
-
-
Notifications
You must be signed in to change notification settings - Fork 427
/
plugin-api.ts
617 lines (532 loc) · 24.6 KB
/
plugin-api.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
/** The general, externally accessible plugin API (available at `app.plugins.plugins.dataview.api` or as global `DataviewAPI`). */
import { App, Component, MarkdownPostProcessorContext, TFile } from "obsidian";
import { FullIndex } from "data-index/index";
import { matchingSourcePaths } from "data-index/resolver";
import { Sources } from "data-index/source";
import { DataObject, Grouping, Groupings, Link, Literal, Values, Widgets } from "data-model/value";
import { EXPRESSION } from "expression/parse";
import { renderCodeBlock, renderErrorPre, renderValue } from "ui/render";
import { DataArray } from "./data-array";
import { BoundFunctionImpl, DEFAULT_FUNCTIONS, Functions } from "expression/functions";
import { Context } from "expression/context";
import {
defaultLinkHandler,
executeCalendar,
executeInline,
executeList,
executeTable,
executeTask,
IdentifierMeaning,
} from "query/engine";
import { DateTime, Duration } from "luxon";
import * as Luxon from "luxon";
import { compare, CompareOperator, satisfies } from "compare-versions";
import { DataviewSettings, ExportSettings } from "settings";
import { parseFrontmatter } from "data-import/markdown-file";
import { SListItem, SMarkdownPage } from "data-model/serialized/markdown";
import { createFixedTaskView, createTaskView, nestGroups } from "ui/views/task-view";
import { createFixedListView, createListView } from "ui/views/list-view";
import { createFixedTableView, createTableView } from "ui/views/table-view";
import { Result } from "api/result";
import { parseQuery } from "query/parse";
import { tryOrPropagate } from "util/normalize";
import { Query } from "query/query";
import { DataviewCalendarRenderer } from "ui/views/calendar-view";
import { DataviewJSRenderer } from "ui/views/js-view";
import { markdownList, markdownTable, markdownTaskList } from "ui/export/markdown";
/** Asynchronous API calls related to file / system IO. */
export class DataviewIOApi {
public constructor(public api: DataviewApi) {}
/** Load the contents of a CSV asynchronously, returning a data array of rows (or undefined if it does not exist). */
public async csv(path: Link | string, originFile?: string): Promise<DataArray<DataObject> | undefined> {
if (!Values.isLink(path) && !Values.isString(path)) {
throw Error(`dv.io.csv only handles string or link paths; was provided type '${typeof path}'.`);
}
let data = await this.api.index.csv.get(this.normalize(path, originFile));
if (data.successful) return DataArray.from(data.value, this.api.settings);
else throw Error(`Could not find CSV for path '${path}' (relative to origin '${originFile ?? "/"}')`);
}
/** Asynchronously load the contents of any link or path in an Obsidian vault. */
public async load(path: Link | string, originFile?: string): Promise<string | undefined> {
if (!Values.isLink(path) && !Values.isString(path)) {
throw Error(`dv.io.load only handles string or link paths; was provided type '${typeof path}'.`);
}
let existingFile = this.api.index.vault.getAbstractFileByPath(this.normalize(path, originFile));
if (!existingFile || !(existingFile instanceof TFile)) return undefined;
return this.api.index.vault.cachedRead(existingFile);
}
/** Normalize a link or path relative to an optional origin file. Returns a textual fully-qualified-path. */
public normalize(path: Link | string, originFile?: string): string {
let realPath;
if (Values.isLink(path)) realPath = path.path;
else realPath = path;
return this.api.index.prefix.resolveRelative(realPath, originFile);
}
}
/** Global API for accessing the Dataview API, executing dataview queries, and */
export class DataviewApi {
/** Evaluation context which expressions can be evaluated in. */
public evaluationContext: Context;
/** IO API which supports asynchronous loading of data directly. */
public io: DataviewIOApi;
/** Dataview functions which can be called from DataviewJS. */
public func: Record<string, BoundFunctionImpl>;
/** Value utility functions for comparisons and type-checking. */
public value = Values;
/** Widget utility functions for creating built-in widgets. */
public widget = Widgets;
/** Re-exporting of luxon for people who can't easily require it. Sorry! */
public luxon = Luxon;
public constructor(
public app: App,
public index: FullIndex,
public settings: DataviewSettings,
private verNum: string
) {
this.evaluationContext = new Context(defaultLinkHandler(index, ""), settings);
this.func = Functions.bindAll(DEFAULT_FUNCTIONS, this.evaluationContext);
this.io = new DataviewIOApi(this);
}
/** Utilities to check the current Dataview version and compare it to SemVer version ranges. */
public version: {
current: string;
compare: (op: CompareOperator, ver: string) => boolean;
satisfies: (range: string) => boolean;
} = (() => {
const self = this;
return {
get current() {
return self.verNum;
},
compare: (op: CompareOperator, ver: string) => compare(this.verNum, ver, op),
satisfies: (range: string) => satisfies(this.verNum, range),
};
})();
/////////////////////////////
// Index + Data Collection //
/////////////////////////////
/** Return an array of paths (as strings) corresponding to pages which match the query. */
public pagePaths(query?: string, originFile?: string): DataArray<string> {
let source;
try {
if (!query || query.trim() === "") source = Sources.folder("");
else source = EXPRESSION.source.tryParse(query);
} catch (ex) {
throw new Error(`Failed to parse query in 'pagePaths': ${ex}`);
}
return matchingSourcePaths(source, this.index, originFile)
.map(s => DataArray.from(s, this.settings))
.orElseThrow();
}
/** Map a page path to the actual data contained within that page. */
public page(path: string | Link, originFile?: string): Record<string, Literal> | undefined {
if (!(typeof path === "string") && !Values.isLink(path)) {
throw Error("dv.page only handles string and link paths; was provided type '" + typeof path + "'");
}
let rawPath = path instanceof Link ? path.path : path;
let normPath = this.app.metadataCache.getFirstLinkpathDest(rawPath, originFile ?? "");
if (!normPath) return undefined;
let pageObject = this.index.pages.get(normPath.path);
if (!pageObject) return undefined;
return this._addDataArrays(pageObject.serialize(this.index));
}
/** Return an array of page objects corresponding to pages which match the source query. */
public pages(query?: string, originFile?: string): DataArray<Record<string, Literal>> {
return this.pagePaths(query, originFile).flatMap(p => {
let res = this.page(p, originFile);
return res ? [res] : [];
});
}
/** Remaps important metadata to add data arrays. */
private _addDataArrays(pageObject: SMarkdownPage): SMarkdownPage {
// Remap the "file" metadata entries to be data arrays.
for (let [key, value] of Object.entries(pageObject.file)) {
if (Array.isArray(value)) (pageObject.file as any)[key] = DataArray.wrap<any>(value, this.settings);
}
return pageObject;
}
/////////////
// Utility //
/////////////
/**
* Convert an input element or array into a Dataview data-array. If the input is already a data array,
* it is returned unchanged.
*/
public array(raw: unknown): DataArray<any> {
if (DataArray.isDataArray(raw)) return raw;
if (Array.isArray(raw)) return DataArray.wrap(raw, this.settings);
return DataArray.wrap([raw], this.settings);
}
/** Return true if the given value is a javascript array OR a dataview data array. */
public isArray(raw: unknown): raw is DataArray<any> | Array<any> {
return DataArray.isDataArray(raw) || Array.isArray(raw);
}
/** Return true if the given value is a dataview data array; this returns FALSE for plain JS arrays. */
public isDataArray(raw: unknown): raw is DataArray<any> {
return DataArray.isDataArray(raw);
}
/** Create a dataview file link to the given path. */
public fileLink(path: string, embed: boolean = false, display?: string) {
return Link.file(path, embed, display);
}
/** Create a dataview section link to the given path. */
public sectionLink(path: string, section: string, embed: boolean = false, display?: string): Link {
return Link.header(path, section, embed, display);
}
/** Create a dataview block link to the given path. */
public blockLink(path: string, blockId: string, embed: boolean = false, display?: string): Link {
return Link.block(path, blockId, embed, display);
}
/** Attempt to extract a date from a string, link or date. */
public date(pathlike: string | Link | DateTime): DateTime | null {
return this.func.date(pathlike) as DateTime | null;
}
/** Attempt to extract a duration from a string or duration. */
public duration(str: string | Duration): Duration | null {
return this.func.dur(str) as Duration | null;
}
/** Parse a raw textual value into a complex Dataview type, if possible. */
public parse(value: string): Literal {
let raw = EXPRESSION.inlineField.parse(value);
if (raw.status) return raw.value;
else return value;
}
/** Convert a basic JS type into a Dataview type by parsing dates, links, durations, and so on. */
public literal(value: any): Literal {
return parseFrontmatter(value);
}
/** Deep clone the given literal, returning a new literal which is independent of the original. */
public clone(value: Literal): Literal {
return Values.deepCopy(value);
}
/**
* Compare two arbitrary JavaScript values using Dataview's default comparison rules. Returns a negative value if
* a < b, 0 if a = b, and a positive value if a > b.
*/
public compare(a: any, b: any): number {
return Values.compareValue(a, b, this.evaluationContext.linkHandler.normalize);
}
/** Return true if the two given JavaScript values are equal using Dataview's default comparison rules. */
public equal(a: any, b: any): boolean {
return this.compare(a, b) == 0;
}
///////////////////////////////
// Dataview Query Evaluation //
///////////////////////////////
/**
* Execute an arbitrary Dataview query, returning a query result which:
*
* 1. Indicates the type of query,
* 2. Includes the raw AST of the parsed query.
* 3. Includes the output in the form relevant to that query type.
*
* List queries will return a list of objects ({ id, value }); table queries return a header array
* and a 2D array of values; and task arrays return a Grouping<Task> type which allows for recursive
* task nesting.
*/
public async query(
source: string | Query,
originFile?: string,
settings?: QueryApiSettings
): Promise<Result<QueryResult, string>> {
const query = typeof source === "string" ? parseQuery(source) : Result.success<Query, string>(source);
if (!query.successful) return query.cast();
const header = query.value.header;
switch (header.type) {
case "calendar":
const cres = await executeCalendar(query.value, this.index, originFile ?? "", this.settings);
if (!cres.successful) return cres.cast();
return Result.success({ type: "calendar", values: cres.value.data });
case "task":
const tasks = await executeTask(query.value, originFile ?? "", this.index, this.settings);
if (!tasks.successful) return tasks.cast();
return Result.success({ type: "task", values: tasks.value.tasks });
case "list":
if (settings?.forceId !== undefined) header.showId = settings.forceId;
const lres = await executeList(query.value, this.index, originFile ?? "", this.settings);
if (!lres.successful) return lres.cast();
// TODO: WITHOUT ID probably shouldn't exist, or should be moved to the engine itself.
// For now, until I fix it up in an upcoming refactor, we re-implement the behavior here.
return Result.success({
type: "list",
values: lres.value.data,
primaryMeaning: lres.value.primaryMeaning,
});
case "table":
if (settings?.forceId !== undefined) header.showId = settings.forceId;
const tres = await executeTable(query.value, this.index, originFile ?? "", this.settings);
if (!tres.successful) return tres.cast();
return Result.success({
type: "table",
values: tres.value.data,
headers: tres.value.names,
idMeaning: tres.value.idMeaning,
});
}
}
/** Error-throwing version of {@link query}. */
public async tryQuery(source: string, originFile?: string, settings?: QueryApiSettings): Promise<QueryResult> {
return (await this.query(source, originFile, settings)).orElseThrow();
}
/** Execute an arbitrary dataview query, returning the results in well-formatted markdown. */
public async queryMarkdown(
source: string | Query,
originFile?: string,
settings?: Partial<QueryApiSettings & ExportSettings>
): Promise<Result<string, string>> {
const result = await this.query(source, originFile, settings);
if (!result.successful) return result.cast();
switch (result.value.type) {
case "list":
return Result.success(this.markdownList(result.value.values, settings));
case "table":
return Result.success(this.markdownTable(result.value.headers, result.value.values, settings));
case "task":
return Result.success(this.markdownTaskList(result.value.values, settings));
case "calendar":
return Result.failure("Cannot render calendar queries to markdown.");
}
}
/** Error-throwing version of {@link queryMarkdown}. */
public async tryQueryMarkdown(
source: string | Query,
originFile?: string,
settings?: Partial<QueryApiSettings & ExportSettings>
): Promise<string> {
return (await this.queryMarkdown(source, originFile, settings)).orElseThrow();
}
/**
* Evaluate a dataview expression (like '2 + 2' or 'link("hello")'), returning the evaluated result.
* This takes an optional second argument which provides definitions for variables, such as:
*
* ```
* dv.evaluate("x + 6", { x: 2 }) = 8
* dv.evaluate('link(target)', { target: "Okay" }) = [[Okay]]
* ```
*
* This method returns a Result type instead of throwing an error; you can check the result of the
* execution via `result.successful` and obtain `result.value` or `result.error` accordingly. If
* you'd rather this method throw on an error, use `dv.tryEvaluate`.
*/
public evaluate(expression: string, context?: DataObject, originFile?: string): Result<Literal, string> {
let field = EXPRESSION.field.parse(expression);
if (!field.status) return Result.failure(`Failed to parse expression "${expression}"`);
let evaluationContext = originFile
? new Context(defaultLinkHandler(this.index, originFile), this.settings)
: this.evaluationContext;
return evaluationContext.evaluate(field.value, context);
}
/** Error-throwing version of `dv.evaluate`. */
public tryEvaluate(expression: string, context?: DataObject, originFile?: string): Literal {
return this.evaluate(expression, context, originFile).orElseThrow();
}
/** Evaluate an expression in the context of the given file. */
public evaluateInline(expression: string, origin: string): Result<Literal, string> {
let field = EXPRESSION.field.parse(expression);
if (!field.status) return Result.failure(`Failed to parse expression "${expression}"`);
return executeInline(field.value, origin, this.index, this.settings);
}
///////////////
// Rendering //
///////////////
/**
* Execute the given query, rendering results into the given container using the components lifecycle.
* Your component should be a *real* component which calls onload() on it's child components at some point,
* or a MarkdownPostProcessorContext!
*
* Note that views made in this way are live updating and will automatically clean themselves up when
* the component is unloaded or the container is removed.
*/
public async execute(
source: string,
container: HTMLElement,
component: Component | MarkdownPostProcessorContext,
filePath: string
) {
if (isDataviewDisabled(filePath)) {
renderCodeBlock(container, source);
return;
}
let maybeQuery = tryOrPropagate(() => parseQuery(source));
// In case of parse error, just render the error.
if (!maybeQuery.successful) {
renderErrorPre(container, "Dataview: " + maybeQuery.error);
return;
}
let query = maybeQuery.value;
let init = { app: this.app, settings: this.settings, index: this.index, container };
let childComponent;
switch (query.header.type) {
case "task":
childComponent = createTaskView(init, query as Query, filePath);
component.addChild(childComponent);
break;
case "list":
childComponent = createListView(init, query as Query, filePath);
component.addChild(childComponent);
break;
case "table":
childComponent = createTableView(init, query as Query, filePath);
component.addChild(childComponent);
break;
case "calendar":
childComponent = new DataviewCalendarRenderer(
query as Query,
container,
this.index,
filePath,
this.settings,
this.app
);
component.addChild(childComponent);
break;
}
childComponent.load();
}
/**
* Execute the given DataviewJS query, rendering results into the given container using the components lifecycle.
* See {@link execute} for general rendering semantics.
*/
public async executeJs(
code: string,
container: HTMLElement,
component: Component | MarkdownPostProcessorContext,
filePath: string
) {
if (isDataviewDisabled(filePath)) {
renderCodeBlock(container, code, "javascript");
return;
}
const renderer = new DataviewJSRenderer(this, code, container, filePath);
renderer.load();
component.addChild(renderer);
}
/** Render a dataview list of the given values. */
public async list(
values: any[] | DataArray<any> | undefined,
container: HTMLElement,
component: Component,
filePath: string
) {
if (!values) return;
if (values !== undefined && values !== null && !Array.isArray(values) && !DataArray.isDataArray(values))
values = Array.from(values);
// Append a child div, since React will keep re-rendering otherwise.
let subcontainer = container.createEl("div");
component.addChild(
createFixedListView(
{ app: this.app, settings: this.settings, index: this.index, container: subcontainer },
values as Literal[],
filePath
)
);
}
/** Render a dataview table with the given headers, and the 2D array of values. */
public async table(
headers: string[],
values: any[][] | DataArray<any> | undefined,
container: HTMLElement,
component: Component,
filePath: string
) {
if (!headers) headers = [];
if (!values) values = [];
if (!Array.isArray(headers) && !DataArray.isDataArray(headers)) headers = Array.from(headers);
// Append a child div, since React will keep re-rendering otherwise.
let subcontainer = container.createEl("div");
component.addChild(
createFixedTableView(
{ app: this.app, settings: this.settings, index: this.index, container: subcontainer },
headers,
values as Literal[][],
filePath
)
);
}
/** Render a dataview task view with the given tasks. */
public async taskList(
tasks: Grouping<SListItem>,
groupByFile: boolean = true,
container: HTMLElement,
component: Component,
filePath: string = ""
) {
let groupedTasks =
!Groupings.isGrouping(tasks) && groupByFile ? this.array(tasks).groupBy(t => Link.file(t.path)) : tasks;
// Append a child div, since React will override several task lists otherwise.
let taskContainer = container.createEl("div");
component.addChild(
createFixedTaskView(
{ app: this.app, settings: this.settings, index: this.index, container: taskContainer },
groupedTasks as Grouping<SListItem>,
filePath
)
);
}
/** Render an arbitrary value into a container. */
public async renderValue(
value: any,
container: HTMLElement,
component: Component,
filePath: string,
inline: boolean = false
) {
return renderValue(this.app, value as Literal, container, filePath, component, this.settings, inline);
}
/////////////////
// Data Export //
/////////////////
/** Render data to a markdown table. */
public markdownTable(
headers: string[] | undefined,
values: any[][] | DataArray<any> | undefined,
settings?: Partial<ExportSettings>
): string {
if (!headers) headers = [];
if (!values) values = [];
const combined = Object.assign({}, this.settings, settings);
return markdownTable(headers, values as any[][], combined);
}
/** Render data to a markdown list. */
public markdownList(values: any[] | DataArray<any> | undefined, settings?: Partial<ExportSettings>): string {
if (!values) values = [];
const combined = Object.assign({}, this.settings, settings);
return markdownList(values as any[], combined);
}
/** Render tasks or list items to a markdown task list. */
public markdownTaskList(values: Grouping<SListItem>, settings?: Partial<ExportSettings>): string {
if (!values) values = [];
const sparse = nestGroups(values);
const combined = Object.assign({}, this.settings, settings);
return markdownTaskList(sparse as any[], combined);
}
}
/** The result of executing a table query. */
export type TableResult = { type: "table"; headers: string[]; values: Literal[][]; idMeaning: IdentifierMeaning };
/** The result of executing a list query. */
export type ListResult = { type: "list"; values: Literal[]; primaryMeaning: IdentifierMeaning };
/** The result of executing a task query. */
export type TaskResult = { type: "task"; values: Grouping<SListItem> };
/** The result of executing a calendar query. */
export type CalendarResult = {
type: "calendar";
values: {
date: DateTime;
link: Link;
value?: Literal[];
}[];
};
/** The result of executing a query of some sort. */
export type QueryResult = TableResult | ListResult | TaskResult | CalendarResult;
/** Settings when querying the dataview API. */
export type QueryApiSettings = {
/** If present, then this forces queries to include/exclude the implicit id field (such as with `WITHOUT ID`). */
forceId?: boolean;
};
/** Determines if source-path has a `?no-dataview` annotation that disables dataview. */
export function isDataviewDisabled(sourcePath: string): boolean {
if (!sourcePath) return false;
let questionLocation = sourcePath.lastIndexOf("?");
if (questionLocation == -1) return false;
return sourcePath.substring(questionLocation).contains("no-dataview");
}