diff --git a/.gitignore b/.gitignore index 0215ff30..58babce5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ data.json meta.json site/ docs/index.md +docs/changelog.md diff --git a/build-docs.sh b/build-docs.sh index d2c2d994..a2e20225 100755 --- a/build-docs.sh +++ b/build-docs.sh @@ -3,5 +3,6 @@ # we need to copy the README.md file to the docs folder and remove # the docs/ prefix from the links at build time. cp README.md docs/index.md +cp CHANGELOG.md docs/changelog.md sed -i '' -e 's/docs\///g' docs/index.md mkdocs build diff --git a/mkdocs.yml b/mkdocs.yml index 23fce00a..daec4f86 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,6 +7,7 @@ nav: - ResultValue: ResultValue.md - Advanced: advanced-examples.md - Manging results: managing-results.md + - Change log: changelog.md theme: name: material diff --git a/src/API.ts b/src/API.ts index 95bceb7d..3a7db195 100644 --- a/src/API.ts +++ b/src/API.ts @@ -1,4 +1,4 @@ -import { App } from "obsidian"; +import { App, parseFrontMatterAliases } from "obsidian"; import { type FormDefinition, type FormOptions } from "./core/formDefinition"; import { MigrationError } from "./core/formDefinitionSchema"; @@ -9,6 +9,8 @@ import { ModalFormError } from "./utils/ModalFormError"; import { FormModal } from "./FormModal"; import { log_error, log_notice } from "./utils/Log"; import * as std from "@std"; +import { enrich_tfile, resolve_tfile } from "./utils/files"; +import { E, flow } from "@std"; type pickOption = { pick: string[] }; type omitOption = { omit: string[] }; @@ -22,7 +24,28 @@ function isOmitOption(opts: limitOptions): opts is omitOption { } export class API { + /** + * What this plugin considers its standard library + * Because it is bundled with the plugin anyway, I think + * it makes sense to expose it to the user + */ std = std; + util = { + getAliases: flow( + (name: string) => resolve_tfile(name, this.app), + E.map((f) => this.app.metadataCache.getCache(f.path)), + E.chainW(E.fromNullable(new Error("No cache found"))), + E.map((tf) => parseFrontMatterAliases(tf.frontmatter)), + E.match( + () => [], + (aliases) => aliases, + ), + ), + getFile: std.flow( + resolve_tfile, + E.map((f) => enrich_tfile(f, this.app)), + ), + }; /** * Constructor for the API class * @param {App} app - The application instance diff --git a/src/core/ResultValue.ts b/src/core/ResultValue.ts index dde1cec4..3e3d3218 100644 --- a/src/core/ResultValue.ts +++ b/src/core/ResultValue.ts @@ -9,6 +9,7 @@ function _toBulletList(value: Record | unknown[]) { .map(([key, value]) => `- ${key}: ${value}`) .join("\n"); } + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -37,7 +38,7 @@ export class ResultValue { protected value: T, protected name: string, private notify: Reporter = notify, - ) { } + ) {} static from(value: U, name: string, notify = notifyError) { return new ResultValue(value, name, notify); } @@ -159,7 +160,9 @@ export class ResultValue { * If the value is an array, it will return an array with all the strings uppercased. */ get upper() { - return this.map((v) => deepMap(v, (it) => typeof it === "string" ? it.toLocaleUpperCase() : it)); + return this.map((v) => + deepMap(v, (it) => (typeof it === "string" ? it.toLocaleUpperCase() : it)), + ); } /** * getter that returns all the string values lowercased. @@ -168,13 +171,14 @@ export class ResultValue { * @returns FormValue */ get lower() { - return this.map((v) => deepMap(v, (it) => typeof it === "string" ? it.toLocaleLowerCase() : it)); + return this.map((v) => + deepMap(v, (it) => (typeof it === "string" ? it.toLocaleLowerCase() : it)), + ); } /** * getter that returns all the string values trimmed. * */ get trimmed() { - return this.map((v) => deepMap(v, (it) => typeof it === "string" ? it.trim() : it)); + return this.map((v) => deepMap(v, (it) => (typeof it === "string" ? it.trim() : it))); } - } diff --git a/src/core/findInputDefinitionSchema.ts b/src/core/findInputDefinitionSchema.ts index f7b973df..db8ccb68 100644 --- a/src/core/findInputDefinitionSchema.ts +++ b/src/core/findInputDefinitionSchema.ts @@ -5,12 +5,10 @@ import { FieldMinimal, FieldMinimalSchema } from "./formDefinitionSchema"; import { AllFieldTypes } from "./formDefinition"; import { InputTypeToParserMap } from "./InputDefinitionSchema"; -function stringifyIssues(error: ValiError): NonEmptyArray { +export function stringifyIssues(error: ValiError): NonEmptyArray { return error.issues.map( (issue) => - `${issue.path?.map((i) => i.key)}: ${issue.message} got ${ - issue.input - }`, + `${issue.path?.map((i) => i.key).join(".")}: ${issue.message} got ${issue.input}`, ) as NonEmptyArray; } export class InvalidInputTypeError { @@ -24,9 +22,7 @@ export class InvalidInputTypeError { return `InvalidInputTypeError: ${this.getFieldErrors()[0]}`; } getFieldErrors(): NonEmptyArray { - return [ - `"input.type" is invalid, got: ${JSON.stringify(this.inputType)}`, - ]; + return [`"input.type" is invalid, got: ${JSON.stringify(this.inputType)}`]; } } export class InvalidInputError { @@ -59,6 +55,9 @@ export class InvalidFieldError { toString(): string { return `InvalidFieldError: ${stringifyIssues(this.error).join(", ")}`; } + toArrayOfStrings(): string[] { + return this.getFieldErrors(); + } getFieldErrors(): string[] { return stringifyIssues(this.error); } @@ -82,32 +81,28 @@ function isValidInputType(input: unknown): input is AllFieldTypes { */ export function findInputDefinitionSchema( fieldDefinition: unknown, -): E.Either< - InvalidFieldError | InvalidInputTypeError, - [FieldMinimal, ParsingFn] -> { +): E.Either]> { return pipe( parse(FieldMinimalSchema, fieldDefinition), E.mapLeft(InvalidFieldError.of(fieldDefinition)), E.chainW((field) => { const type = field.input.type; - if (isValidInputType(type)) - return E.right([field, InputTypeToParserMap[type]]); + if (isValidInputType(type)) return E.right([field, InputTypeToParserMap[type]]); else return E.left(new InvalidInputTypeError(field, type)); }), ); } /** - * Given an array of fields that have failed to parse, + * Given an array of fields where some of them (or all) have failed to parse, * this function tries to find the corresponding input schema * and then parses the input with that schema to get the specific errors. - * The result is an array of field errors. + * The result is a Separated of fields and field errors. * This is needed because valibot doesn't provide a way to get the specific error of union types */ export function findFieldErrors(fields: unknown[]) { return pipe( fields, - A.map((fieldUnparsed) => { + A.partitionMap((fieldUnparsed) => { return pipe( findInputDefinitionSchema(fieldUnparsed), E.chainW(([field, parser]) => @@ -121,7 +116,6 @@ export function findFieldErrors(fields: unknown[]) { ), ); }), - // A.partition(E.isLeft), - // Separated.right, + // Separated.left, ); } diff --git a/src/core/formDefinition.ts b/src/core/formDefinition.ts index 5f1c30c3..22723f52 100644 --- a/src/core/formDefinition.ts +++ b/src/core/formDefinition.ts @@ -147,12 +147,12 @@ export function isValidFormDefinition(input: unknown): input is FormDefinition { if (!is(FormDefinitionBasicSchema, input)) { return false; } - console.log("basic is valid"); + // console.log("basic is valid"); const fieldsAreValid = is(FieldListSchema, input.fields); if (!fieldsAreValid) { return false; } - console.log("fields are valid"); + // console.log("fields are valid"); return true; } diff --git a/src/core/formDefinitionSchema.test.ts b/src/core/formDefinitionSchema.test.ts index b652114c..8d799fc3 100644 --- a/src/core/formDefinitionSchema.test.ts +++ b/src/core/formDefinitionSchema.test.ts @@ -1,23 +1,33 @@ -import { A, E, pipe } from "@std"; +import { A, pipe } from "@std"; import { findFieldErrors } from "./findInputDefinitionSchema"; +import { separated } from "fp-ts"; describe("findFieldErrors", () => { it("should return an empty array of detailed errors or unchanged fields if they are correct", () => { const fields = [ - { name: 'fieldName', description: 'field description', input: { type: 'text' } }, - { name: 'fieldName', input: { type: 'text' } }, - { name: 'fieldName', description: '', input: { type: '' } }, + { name: "fieldName", description: "field description", input: { type: "text" } }, + { name: "fieldName", input: { type: "text" } }, + { name: "fieldName", description: "", input: { type: "" } }, {}, ]; - const errors = pipe( + const result = pipe( + // multi line prettier findFieldErrors(fields), - A.map(E.mapLeft((e) => e.toString())) - ) - expect(errors).toHaveLength(4); - expect(errors[0]).toEqual(E.right({ name: 'fieldName', description: 'field description', input: { type: 'text' } })); - expect(errors[1]).toEqual(E.left('InvalidFieldError: description: Invalid type got undefined')); - expect(errors[2]).toEqual(E.left('InvalidInputTypeError: "input.type" is invalid, got: ""')); - expect(errors[3]).toEqual(E.left('InvalidFieldError: name: field name should be a string got undefined, description: Invalid type got undefined, input: Invalid type got undefined')); + separated.mapLeft(A.map((e) => e.toString())), + ); + expect(result.left).toHaveLength(3); + expect(result.right).toHaveLength(1); + expect(result.right[0]).toEqual({ + name: "fieldName", + description: "field description", + input: { type: "text" }, + }); + expect(result.left[0]).toEqual( + "InvalidFieldError: description: Invalid type got undefined", + ); + expect(result.left[1]).toEqual('InvalidInputTypeError: "input.type" is invalid, got: ""'); + expect(result.left[2]).toEqual( + "InvalidFieldError: name: field name should be a string got undefined, description: Invalid type got undefined, input: Invalid type got undefined", + ); }); - }); diff --git a/src/core/formDefinitionSchema.ts b/src/core/formDefinitionSchema.ts index 68ca74ad..72c1a542 100644 --- a/src/core/formDefinitionSchema.ts +++ b/src/core/formDefinitionSchema.ts @@ -15,7 +15,7 @@ import { boolean, } from "valibot"; import { FormDefinition } from "./formDefinition"; -import { findFieldErrors } from "./findInputDefinitionSchema"; +import { findFieldErrors, stringifyIssues } from "./findInputDefinitionSchema"; import { ParsedTemplateSchema } from "./template/templateSchema"; import { InputTypeSchema, nonEmptyString } from "./InputDefinitionSchema"; @@ -36,10 +36,7 @@ export const FieldDefinitionSchema = object({ * Only for error reporting purposes */ export const FieldMinimalSchema = passthrough( - merge([ - FieldDefinitionSchema, - object({ input: passthrough(object({ type: string() })) }), - ]), + merge([FieldDefinitionSchema, object({ input: passthrough(object({ type: string() })) })]), ); export type FieldMinimal = Output; @@ -98,6 +95,9 @@ export class MigrationError { ${this.error.message} ${this.error.issues.map((issue) => issue.message).join(", ")}`; } + toArrayOfStrings(): string[] { + return stringifyIssues(this.error); + } // This allows to store the error in the settings, along with the rest of the forms and // have save all the data in one go transparently. // This is required so we don't lose the form, even if it is invalid @@ -118,25 +118,24 @@ export class InvalidData { readonly error: ValiError, ) {} toString(): string { - return `InvalidData: ${this.error.issues - .map((issue) => issue.message) - .join(", ")}`; + return `InvalidData: ${stringifyIssues(this.error).join(", ")}`; + } + toArrayOfStrings(): string[] { + return stringifyIssues(this.error); } } //=========== Migration logic -function fromV0toV1( - data: FormDefinitionBasic, -): MigrationError | FormDefinitionV1 { +function fromV0toV1(data: FormDefinitionBasic): MigrationError | FormDefinitionV1 { return pipe( parse(FormDefinitionV1Schema, { ...data, version: "1" }), E.getOrElseW((error) => new MigrationError(data, error)), ); } + /** * * Parses the form definition and migrates it to the latest version in one operation. */ - export function migrateToLatest( data: unknown, ): E.Either { @@ -145,7 +144,7 @@ export function migrateToLatest( parse(FormDefinitionLatestSchema, data, { abortEarly: true }), E.orElse(() => pipe( - parse(FormDefinitionBasicSchema, data), + parse(FormDefinitionBasicSchema, data, { abortEarly: false }), E.mapLeft((error) => new InvalidData(data, error)), E.map(fromV0toV1), ), diff --git a/src/core/template/templateParser.ts b/src/core/template/templateParser.ts index 922689d9..2a601981 100644 --- a/src/core/template/templateParser.ts +++ b/src/core/template/templateParser.ts @@ -1,4 +1,4 @@ -import * as R from 'fp-ts/Record'; +import * as R from "fp-ts/Record"; import * as E from "fp-ts/Either"; import * as St from "fp-ts/string"; import * as P from "parser-ts/Parser"; @@ -20,10 +20,7 @@ function TemplateVariable(value: string): TemplateVariable { return { _tag: "variable", value }; } -function FrontmatterCommand( - pick: string[] = [], - omit: string[] = [], -): FrontmatterCommand { +function FrontmatterCommand(pick: string[] = [], omit: string[] = []): FrontmatterCommand { return { _tag: "frontmatter-command", pick, omit }; } @@ -37,10 +34,7 @@ const EofStr = pipe( ); // === Variable Parser === const open = S.fold([S.string("{{"), S.spaces]); -const close = P.expected( - S.fold([S.spaces, S.string("}}")]), - 'closing variable tag: "}}"', -); +const close = P.expected(S.fold([S.spaces, S.string("}}")]), 'closing variable tag: "}}"'); const identifier = S.many1(C.alphanum); const templateIdentifier: TokenParser = pipe( identifier, @@ -50,22 +44,21 @@ const templateIdentifier: TokenParser = pipe( // === Command Parser === const commandOpen = S.fold([S.string("{#"), S.spaces]); -const commandClose = P.expected( - S.fold([S.spaces, S.string("#}")]), - 'a closing command tag: "#}"', -); -const sepByComma = P.sepBy(S.fold([C.char(','), S.spaces]), identifier); -const commandOptionParser = (option: string) => pipe( - S.fold([S.string(option), S.spaces]), - P.apSecond(sepByComma), -) +const commandClose = P.expected(S.fold([S.spaces, S.string("#}")]), 'a closing command tag: "#}"'); +const sepByComma = P.sepBy(S.fold([C.char(","), S.spaces]), identifier); +const commandOptionParser = (option: string) => + pipe( + S.fold([S.string(option), S.spaces]), + // dam prettier + P.apSecond(sepByComma), + ); const frontmatterCommandParser = pipe( S.fold([S.string("frontmatter"), S.spaces]), P.apSecond(P.optional(commandOptionParser("pick:"))), //P.apFirst(S.spaces), // P.chain(commandOptionParser("pick:")), -) +); // the frontmatter command looks like this: // {# frontmatter pick: name, age, omit: id #} @@ -76,19 +69,17 @@ const commandParser = pipe( return pipe( value, O.fold(() => [], identity), - FrontmatterCommand - ) + FrontmatterCommand, + ); }), -) +); export const OpenOrEof = pipe( open, P.alt(() => commandOpen), - P.alt(() => EofStr)); -export const anythingUntilOpenOrEOF = P.many1Till( - P.item(), - P.lookAhead(OpenOrEof), + P.alt(() => EofStr), ); +export const anythingUntilOpenOrEOF = P.many1Till(P.item(), P.lookAhead(OpenOrEof)); const text: TokenParser = pipe( anythingUntilOpenOrEOF, @@ -103,16 +94,16 @@ const TextOrVariable: TokenParser = pipe( const Template = pipe( P.many(TextOrVariable), - P.apFirst(P.eof())); + // dam prettier + P.apFirst(P.eof()), +); /** * Given a template string, parse it into an array of tokens. * Templates look like this: * "Hello {{name}}! You are {{age}} years old." * @param template a template string to convert into an array of tokens or an error */ -export function parseTemplate( - template: string, -): Either { +export function parseTemplate(template: string): Either { return pipe( Template, S.run(template), @@ -124,9 +115,7 @@ export function parseTemplate( // return S.run(template)(P.many(Template)) } -export function templateVariables( - parsedTemplate: ReturnType, -): string[] { +export function templateVariables(parsedTemplate: ReturnType): string[] { return pipe( parsedTemplate, E.fold( @@ -161,7 +150,9 @@ function tokenToString(token: Token): string { case "variable": return `{{${token.value}}}`; case "frontmatter-command": - return `{{# frontmatter pick: ${token.pick.join(", ")}, omit: ${token.omit.join(", ")} #}}`; + return `{{# frontmatter pick: ${token.pick.join(", ")}, omit: ${token.omit.join( + ", ", + )} #}}`; default: return absurd(tag); } @@ -196,37 +187,42 @@ function matchToken( */ export function parsedTemplateToString(parsedTemplate: ParsedTemplate): string { return pipe( + // prettier shut up parsedTemplate, - A.foldMap(St.Monoid)(tokenToString)); + A.foldMap(St.Monoid)(tokenToString), + ); } function asFrontmatterString(data: Record) { - return ({ pick, omit }: { pick: string[], omit: string[] }): string => pipe( - data, - R.filterMapWithIndex((key, value) => { - if (pick.length === 0) return O.some(value); - return pick.includes(key) ? O.some(value) : O.none; - }), - R.filterMapWithIndex((key, value) => !omit.includes(key) ? O.some(value) : O.none), - stringifyYaml, - ) + return ({ pick, omit }: { pick: string[]; omit: string[] }): string => + pipe( + data, + R.filterMapWithIndex((key, value) => { + if (pick.length === 0) return O.some(value); + return pick.includes(key) ? O.some(value) : O.none; + }), + R.filterMapWithIndex((key, value) => (!omit.includes(key) ? O.some(value) : O.none)), + stringifyYaml, + ); } -export function executeTemplate( - parsedTemplate: ParsedTemplate, - formData: ModalFormData, -) { +export function executeTemplate(parsedTemplate: ParsedTemplate, formData: ModalFormData) { const toFrontmatter = asFrontmatterString(formData); // Build it upfront rater than on every call return pipe( parsedTemplate, - A.filterMap(matchToken( - O.some, - (key) => O.fromNullable(formData[key]), - (command) => pipe( - command, - toFrontmatter, - O.some) - )), + A.filterMap( + matchToken( + O.some, + (key) => O.fromNullable(formData[key]), + (command) => + pipe( + //prettier + command, + toFrontmatter, + O.some, + ), + ), + ), A.foldMap(St.Monoid)(String), ); } diff --git a/src/main.ts b/src/main.ts index e9f38a4c..8adee5b9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,6 +26,7 @@ import { executeTemplate } from "./core/template/templateParser"; import { NewNoteModal } from "./suggesters/NewNoteModal"; import { file_exists } from "./utils/files"; import { FormPickerModal } from "./suggesters/FormPickerModal"; +import { FormImportModal } from "./views/FormImportView"; type ViewType = typeof EDIT_FORM_VIEW | typeof MANAGE_FORMS_VIEW; @@ -241,6 +242,20 @@ export default class ModalFormPlugin extends Plugin { }, }); + this.addCommand({ + id: "import-form", + name: "Import form", + callback: () => { + const importModal = new FormImportModal(this.app, { + createForm: (form) => { + importModal.close(); + this.activateView(EDIT_FORM_VIEW, form); + }, + }); + importModal.open(); + }, + }); + // This adds a settings tab so the user can configure various aspects of the plugin this.addSettingTab(new ModalFormSettingTab(this.app, this)); } diff --git a/src/std/index.ts b/src/std/index.ts index 817a012d..1dae9cfe 100644 --- a/src/std/index.ts +++ b/src/std/index.ts @@ -10,17 +10,7 @@ import { filterMap, flatten, } from "fp-ts/Array"; -import { - map as mapO, - getOrElse as getOrElseOpt, - some, - none, - fromNullable as fromNullableOpt, - fold as ofold, - chain as ochain, - alt as OAlt, - fromPredicate, -} from "fp-ts/Option"; +import * as _O from "fp-ts/Option"; import { isLeft, isRight, @@ -40,6 +30,7 @@ import { chainW, match, } from "fp-ts/Either"; +export type Option = _O.Option; import { BaseSchema, Output, ValiError, parse as parseV } from "valibot"; import { Semigroup, concatAll } from "fp-ts/Semigroup"; import { NonEmptyArray, concatAll as concatAllNea } from "fp-ts/NonEmptyArray"; @@ -86,15 +77,18 @@ export const E = { }; export const O = { - map: mapO, - getOrElse: getOrElseOpt, - some, - none, - fold: ofold, - fromNullable: fromNullableOpt, - chain: ochain, - fromPredicate: fromPredicate, - alt: OAlt, + map: _O.map, + getOrElse: _O.getOrElse, + some: _O.some, + none: _O.none, + fold: _O.fold, + fromNullable: _O.fromNullable, + chain: _O.chain, + fromPredicate: _O.fromPredicate, + isNone: _O.isNone, + isSome: _O.isSome, + alt: _O.alt, + match: _O.match, }; export const parse = tryCatchK(parseV, (e: unknown) => e as ValiError); @@ -109,9 +103,13 @@ type ParseOpts = Parameters[2]; * and returns the result of parsing the input with the schema and options. */ export function parseC(schema: S, options?: ParseOpts) { - return (input: unknown) => parse(schema, input, options); + return (input: unknown, moreOptions?: ParseOpts) => + parse(schema, input, { ...options, ...moreOptions }); } -export type ParsingFn = (input: unknown) => Either>; +export type ParsingFn = ( + input: unknown, + options?: ParseOpts, +) => Either>; /** * Concatenates two parsing functions that return Either into one. * If the first function returns a Right, the second function is not called. diff --git a/src/views/FormBuilder.svelte b/src/views/FormBuilder.svelte index 45e220c3..9441cd71 100644 --- a/src/views/FormBuilder.svelte +++ b/src/views/FormBuilder.svelte @@ -15,8 +15,7 @@ import FormRow from "./components/FormRow.svelte"; import Toggle from "./components/Toggle.svelte"; import TemplateEditor from "./components/TemplateEditor.svelte"; - import { pipe } from "fp-ts/lib/function"; - import { A } from "@std"; + import { A, pipe } from "@std"; import Tabs from "./components/Tabs.svelte"; import { ParsedTemplate, parsedTemplateToString } from "src/core/template/templateParser"; import InputBuilderDocumentBlock from "./components/InputBuilderDocumentBlock.svelte"; diff --git a/src/views/FormImport.svelte b/src/views/FormImport.svelte new file mode 100644 index 00000000..a2acdf71 --- /dev/null +++ b/src/views/FormImport.svelte @@ -0,0 +1,69 @@ + + +
+

+ Import a form by pasting the JSON definition into the box below. You can export a form from + the Form Builder. Any errors in the JSON will be displayed below. You will only be able to + import the form if there are no errors. +

+
+