From cd39785d68fb78444db1d230f55c71907d218b6a Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez Date: Fri, 3 Nov 2023 11:38:45 +0100 Subject: [PATCH 1/2] feat(inputs): dataview can be used as source for multi-select fixes #50 --- .prettierrc | 6 ++- src/FormModal.ts | 8 +++- src/core/formDefinitionSchema.ts | 7 +++- src/exampleModalDefinition.ts | 6 +++ src/std/index.ts | 9 +++-- src/suggesters/SafeDataviewQuery.ts | 56 +++++++++++++++++++++++++++ src/suggesters/suggestFromDataview.ts | 23 +++-------- 7 files changed, 90 insertions(+), 25 deletions(-) create mode 100644 src/suggesters/SafeDataviewQuery.ts diff --git a/.prettierrc b/.prettierrc index 07d11491..a0e6323b 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,9 @@ { "plugins": [ "prettier-plugin-svelte" - ] + ], + "arrowParens": "always", + "editorconfig": true, + "svelteAllowShorthand": true, + "trailingComma": "all" } \ No newline at end of file diff --git a/src/FormModal.ts b/src/FormModal.ts index 43a3f056..71e6e41e 100644 --- a/src/FormModal.ts +++ b/src/FormModal.ts @@ -7,6 +7,7 @@ import type { FormDefinition, FormOptions } from "./core/formDefinition"; import { FileSuggest } from "./suggesters/suggestFile"; import { DataviewSuggest } from "./suggesters/suggestFromDataview"; import { SvelteComponent } from "svelte"; +import { executeSandboxedDvQuery, sandboxedDvQuery } from "./suggesters/SafeDataviewQuery"; export type SubmitFn = (formResult: FormResult) => void; @@ -131,9 +132,12 @@ export class FormModal extends Modal { case 'multiselect': { this.formResult[definition.name] = this.formResult[definition.name] || [] - const options = fieldInput.source == 'fixed' + const source = fieldInput.source; + const options = source == 'fixed' ? fieldInput.multi_select_options - : get_tfiles_from_folder(fieldInput.folder, this.app).map((file) => file.basename); + : source == 'notes' + ? get_tfiles_from_folder(fieldInput.folder, this.app).map((file) => file.basename) + : executeSandboxedDvQuery(sandboxedDvQuery(fieldInput.query), this.app) this.svelteComponents.push(new MultiSelect({ target: fieldBase.controlEl, props: { diff --git a/src/core/formDefinitionSchema.ts b/src/core/formDefinitionSchema.ts index 81e1ff8a..e521db8f 100644 --- a/src/core/formDefinitionSchema.ts +++ b/src/core/formDefinitionSchema.ts @@ -31,7 +31,12 @@ const MultiSelectNotesSchema = object({ folder: nonEmptyString('multi select source folder') }); const MultiSelectFixedSchema = object({ type: literal("multiselect"), source: literal("fixed"), multi_select_options: array(string()) }); -export const MultiselectSchema = union([MultiSelectNotesSchema, MultiSelectFixedSchema]); +const MultiSelectQuerySchema = object({ + type: literal("multiselect"), + source: literal("dataview"), + query: nonEmptyString('dataview query') +}); +export const MultiselectSchema = union([MultiSelectNotesSchema, MultiSelectFixedSchema, MultiSelectQuerySchema]); export const InputTypeSchema = union([ InputBasicSchema, InputNoteFromFolderSchema, diff --git a/src/exampleModalDefinition.ts b/src/exampleModalDefinition.ts index 70c810ee..11a9a994 100644 --- a/src/exampleModalDefinition.ts +++ b/src/exampleModalDefinition.ts @@ -52,6 +52,12 @@ export const exampleModalDefinition: FormDefinition = { description: "Allows to pick many notes from a fixed list", input: { type: "multiselect", source: "fixed", multi_select_options: ['Android', 'iOS', 'Windows', 'MacOS', 'Linux', 'Solaris', 'MS2'] }, }, + { + name: "multi_select_dataview", + label: "Multi select dataview", + description: "Allows to pick several values from a dv query", + input: { type: "multiselect", source: "dataview", query: 'dv.pages("#person").map(p => p.file.name)' }, + }, { name: "best_fried", label: "Best friend", diff --git a/src/std/index.ts b/src/std/index.ts index 132d4c0e..c04cbb78 100644 --- a/src/std/index.ts +++ b/src/std/index.ts @@ -1,12 +1,13 @@ -import { pipe as p } from "fp-ts/function"; +import { pipe as p, flow as f } from "fp-ts/function"; import { partitionMap, findFirst, findFirstMap, partition, map as mapArr, filter } from "fp-ts/Array"; import { map as mapO, getOrElse as getOrElseOpt, some, none } from 'fp-ts/Option' -import { isLeft, isRight, tryCatchK, map, getOrElse, right, left, mapLeft, Either, bimap } from "fp-ts/Either"; +import { isLeft, isRight, tryCatchK, map, getOrElse, right, left, mapLeft, Either, bimap, tryCatch, flatMap } from "fp-ts/Either"; import { BaseSchema, Output, ValiError, parse as parseV } from "valibot"; import { Semigroup, concatAll } from "fp-ts/Semigroup"; import { NonEmptyArray } from "fp-ts/NonEmptyArray"; export type { NonEmptyArray } from 'fp-ts/NonEmptyArray' - +export type { Either, Left, Right } from 'fp-ts/Either' +export const flow = f; export const pipe = p export const A = { partitionMap, @@ -23,10 +24,12 @@ export const E = { left, right, tryCatchK, + tryCatch, getOrElse, map, mapLeft, bimap, + flatMap, } export const O = { diff --git a/src/suggesters/SafeDataviewQuery.ts b/src/suggesters/SafeDataviewQuery.ts new file mode 100644 index 00000000..220a860b --- /dev/null +++ b/src/suggesters/SafeDataviewQuery.ts @@ -0,0 +1,56 @@ +import { E, Either, flow } from "@std"; +import { pipe } from "fp-ts/lib/function"; +import { App } from "obsidian"; +import { ModalFormError } from "src/utils/Error"; +import { log_error } from "src/utils/Log"; + +type DataviewQuery = (dv: unknown, pages: unknown) => unknown; +export type SafeDataviewQuery = (dv: unknown, pages: unknown) => Either; +/** + * From a string representing a dataview query, it returns the safest possible + * function that can be used to evaluate the query. + * The function is sandboxed and it will return an Either. + * If you want a convenient way to execute the query, use executeSandboxedDvQuery. + * @param query string representing a dataview query that will be evaluated in a sandboxed environment + * @returns SafeDataviewQuery + */ +export function sandboxedDvQuery(query: string): SafeDataviewQuery { + if (!query.startsWith('return')) { + query = 'return ' + query; + } + const run = new Function('dv', 'pages', query) as DataviewQuery; + return flow( + E.tryCatchK(run, () => new ModalFormError('Error evaluating the dataview query')), + E.flatMap((result) => { + if (!Array.isArray(result)) { + return E.left(new ModalFormError('The dataview query did not return an array')); + } + return E.right(result); + }) + ); +} + +/** + * Executes and unwraps the result of a SafeDataviewQuery. + * Use this function if you want a convenient way to execute the query. + * It will log the errors to the UI and return an empty array if the query fails. + * @param query SafeDataviewQuery to execute + * @param app the global obsidian app + * @returns string[] if the query was executed successfully, otherwise an empty array + */ +export function executeSandboxedDvQuery(query: SafeDataviewQuery, app: App): string[] { + const dv = app.plugins.plugins.dataview?.api; + + if (!dv) { + log_error(new ModalFormError("Dataview plugin is not enabled")) + return [] as string[]; + } + const pages = dv.pages; + return pipe( + query(dv, pages), + E.getOrElse((e) => { + log_error(e); + return [] as string[]; + }) + ) +} diff --git a/src/suggesters/suggestFromDataview.ts b/src/suggesters/suggestFromDataview.ts index a60e809a..a8d2b751 100644 --- a/src/suggesters/suggestFromDataview.ts +++ b/src/suggesters/suggestFromDataview.ts @@ -1,6 +1,5 @@ import { AbstractInputSuggest, App } from "obsidian"; -import { ModalFormError, tryCatch } from "src/utils/Error"; -import { log_error } from "src/utils/Log"; +import { SafeDataviewQuery, executeSandboxedDvQuery, sandboxedDvQuery } from "./SafeDataviewQuery"; /** * Offers suggestions based on a dataview query. @@ -8,7 +7,7 @@ import { log_error } from "src/utils/Log"; * For now, we are not very strict with the checks and just throw errors */ export class DataviewSuggest extends AbstractInputSuggest { - sandboxedQuery: (dv: any, pages: any) => string[] + sandboxedQuery: SafeDataviewQuery constructor( public inputEl: HTMLInputElement, @@ -16,24 +15,12 @@ export class DataviewSuggest extends AbstractInputSuggest { public app: App, ) { super(app, inputEl); - this.sandboxedQuery = tryCatch( - () => eval(`(function sandboxedQuery(dv, pages) { return ${dvQuery} })`), - "Invalid dataview query" - ) + this.sandboxedQuery = sandboxedDvQuery(dvQuery) } getSuggestions(inputStr: string): string[] { - const dv = this.app.plugins.plugins.dataview?.api - if (!dv) { - log_error(new ModalFormError("Dataview plugin is not enabled")) - return []; - } - const result = this.sandboxedQuery(dv, dv.pages) - if (!Array.isArray(result)) { - log_error(new ModalFormError("The dataview query did not return an array")) - return []; - } - return result.filter(r => r.toLowerCase().includes(inputStr.toLowerCase())) + const result = executeSandboxedDvQuery(this.sandboxedQuery, this.app) + return result.filter((r) => r.toLowerCase().includes(inputStr.toLowerCase())) } renderSuggestion(option: string, el: HTMLElement): void { From 75f71f1438afeab75dbeef179e41521244896797 Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez Date: Fri, 3 Nov 2023 12:40:51 +0100 Subject: [PATCH 2/2] feat(editor): create dataview inputs --- package-lock.json | 28 +++++++--- package.json | 1 + src/core/formDefinition.ts | 10 +++- src/views/FormBuilder.svelte | 11 ++-- .../components/InputBuilderSelect.svelte | 11 +++- .../components/inputBuilderDataview.svelte | 53 +++++++++++-------- 6 files changed, 76 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index e985825c..56994228 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "obsidian-modal-form", - "version": "1.22.1", + "version": "1.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-modal-form", - "version": "1.22.1", + "version": "1.23.0", "license": "MIT", "dependencies": { "fp-ts": "^2.16.1", "fuse.js": "^6.6.2", + "type-fest": "^4.6.0", "valibot": "^0.19.0" }, "devDependencies": { @@ -3437,6 +3438,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -5754,13 +5768,11 @@ } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "peer": true, + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.6.0.tgz", + "integrity": "sha512-rLjWJzQFOq4xw7MgJrCZ6T1jIOvvYElXT12r+y0CC6u67hegDHaxcPqb2fZHOGlqxugGQPNB1EnTezjBetkwkw==", "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index a5e33b76..b033facc 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "dependencies": { "fp-ts": "^2.16.1", "fuse.js": "^6.6.2", + "type-fest": "^4.6.0", "valibot": "^0.19.0" } } diff --git a/src/core/formDefinition.ts b/src/core/formDefinition.ts index d3547345..00adefca 100644 --- a/src/core/formDefinition.ts +++ b/src/core/formDefinition.ts @@ -56,12 +56,20 @@ export type FormOptions = { values?: Record; } +type KeyOfUnion = T extends unknown ? keyof T : never +type PickUnion> = + T extends unknown + ? K & keyof T extends never ? never : Pick + : never + +export type AllSources = PickUnion['source'] + // When an input is in edit state, it is represented by this type. // It has all the possible values, and then you need to narrow it down // to the actual type. export type EditableInput = { type: AllFieldTypes; - source?: "notes" | "fixed"; + source?: AllSources; folder?: string; min?: number; max?: number; diff --git a/src/views/FormBuilder.svelte b/src/views/FormBuilder.svelte index b4290c77..5bd1f603 100644 --- a/src/views/FormBuilder.svelte +++ b/src/views/FormBuilder.svelte @@ -50,8 +50,8 @@ log_error( new ModalFormError( "Unexpected error, no field at that index", - fieldIndex + " leads to undefined" - ) + fieldIndex + " leads to undefined", + ), ); return Date.now() + ""; } @@ -71,8 +71,8 @@ log_error( new ModalFormError( "Unexpected error, no field at that index", - fieldIndex + " leads to undefined" - ) + fieldIndex + " leads to undefined", + ), ); return; } @@ -266,6 +266,7 @@ bind:source={field.input.source} bind:options={field.input.multi_select_options} bind:folder={field.input.folder} + bind:query={field.input.query} notifyChange={onChange} is_multi={true} /> @@ -327,7 +328,7 @@ id={delete_id} on:click={() => { definition.fields = definition.fields.filter( - (_, i) => i !== index + (_, i) => i !== index, ); }} /> diff --git a/src/views/components/InputBuilderSelect.svelte b/src/views/components/InputBuilderSelect.svelte index 3ace3e86..451848ce 100644 --- a/src/views/components/InputBuilderSelect.svelte +++ b/src/views/components/InputBuilderSelect.svelte @@ -8,9 +8,12 @@ import { setIcon } from "obsidian"; import FormRow from "./FormRow.svelte"; import InputFolder from "./InputFolder.svelte"; + import { AllSources } from "src/core/formDefinition"; + import InputBuilderDataview from "./inputBuilderDataview.svelte"; export let index: number; - export let source: string = "fixed"; + export let source: AllSources = "fixed"; + export let query: string = ""; export let folder: string | undefined; export let options: option[] = []; export let notifyChange: () => void; @@ -35,6 +38,10 @@ {#if source === "fixed"} @@ -125,6 +132,8 @@ {:else if source === "notes"} +{:else if source === "dataview"} + {/if}