From c0cbe9e64010e5b44b682d6bf839a379118af615 Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez Date: Sun, 21 Jan 2024 11:45:36 +0100 Subject: [PATCH] feat(import): ability to edit forms imported from JSON --- src/core/ResultValue.ts | 8 +-- src/core/formDefinition.ts | 14 ++-- src/core/template/templateParser.ts | 106 +++++++++++----------------- src/main.ts | 6 +- src/std/index.ts | 34 ++++----- src/utils/files.ts | 28 ++++---- src/views/FormImport.svelte | 16 ++--- src/views/FormImport.ts | 69 +++++++++++++++--- src/views/FormImportView.ts | 14 ++-- 9 files changed, 161 insertions(+), 134 deletions(-) diff --git a/src/core/ResultValue.ts b/src/core/ResultValue.ts index 3e3d3218..766f8b68 100644 --- a/src/core/ResultValue.ts +++ b/src/core/ResultValue.ts @@ -1,4 +1,4 @@ -import { E, O, ensureError, pipe } from "@std"; +import { E, 0, ensureError, pipe } from "@std"; import { notifyError } from "src/utils/Log"; function _toBulletList(value: Record | unknown[]) { @@ -123,9 +123,9 @@ export class ResultValue { const unchanged = () => this as ResultValue; return pipe( this.value, - O.fromNullable, - O.map(safeFn), - O.fold(unchanged, (v) => + _O.fromNullable, + _O.map(safeFn), + _O.fold(unchanged, (v) => pipe( v, E.fold( diff --git a/src/core/formDefinition.ts b/src/core/formDefinition.ts index 22723f52..fd43ff4a 100644 --- a/src/core/formDefinition.ts +++ b/src/core/formDefinition.ts @@ -6,7 +6,7 @@ import { FormDefinitionBasicSchema, MigrationError, } from "./formDefinitionSchema"; -import { A, O, pipe } from "@std"; +import { A, 0, pipe } from "@std"; import { Simplify } from "type-fest"; import { InputBasicSchema, @@ -161,14 +161,14 @@ export function duplicateForm(formName: string, forms: (FormDefinition | Migrati forms, A.findFirstMap((f) => { if (f instanceof MigrationError) { - return O.none; + return _O.none; } if (f.name === formName) { - return O.some(f); + return _O.some(f); } - return O.none; + return _O.none; }), - O.map((f) => { + _O.map((f) => { let newName = f.name + "-copy"; let i = 1; while (forms.some((f) => f.name === newName)) { @@ -177,9 +177,9 @@ export function duplicateForm(formName: string, forms: (FormDefinition | Migrati } return { ...f, name: newName }; }), - O.map((f) => { + _O.map((f) => { return [...forms, f]; }), - O.getOrElse(() => forms), + _O.getOrElse(() => forms), ); } diff --git a/src/core/template/templateParser.ts b/src/core/template/templateParser.ts index 922689d9..79889157 100644 --- a/src/core/template/templateParser.ts +++ b/src/core/template/templateParser.ts @@ -1,11 +1,11 @@ -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"; import * as C from "parser-ts/char"; import * as S from "parser-ts/string"; import * as A from "fp-ts/Array"; -import { Either, O, pipe } from "@std"; +import { Either, 0, pipe } from "@std"; import { TemplateText, TemplateVariable, FrontmatterCommand } from "./templateSchema"; import { absurd, identity } from "fp-ts/function"; import { ModalFormData } from "../FormResult"; @@ -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,17 @@ 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]), 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 #} @@ -75,20 +64,18 @@ const commandParser = pipe( P.map((value) => { return pipe( value, - O.fold(() => [], identity), - FrontmatterCommand - ) + _O.fold(() => [], identity), + 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, @@ -101,18 +88,14 @@ const TextOrVariable: TokenParser = pipe( P.alt(() => text), ); -const Template = pipe( - P.many(TextOrVariable), - P.apFirst(P.eof())); +const Template = pipe(P.many(TextOrVariable), 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,18 +107,16 @@ 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( () => [], A.filterMap((token) => { if (token._tag === "variable") { - return O.some(token.value); + return _O.some(token.value); } - return O.none; + return _O.none; }), ), ); @@ -161,7 +142,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); } @@ -195,38 +178,33 @@ function matchToken( * @returns string */ export function parsedTemplateToString(parsedTemplate: ParsedTemplate): string { - return pipe( - parsedTemplate, - A.foldMap(St.Monoid)(tokenToString)); + return pipe(parsedTemplate, 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(command, toFrontmatter, _O.some), + ), + ), A.foldMap(St.Monoid)(String), ); } diff --git a/src/main.ts b/src/main.ts index df9c7bff..2e1f1d9e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -246,7 +246,11 @@ export default class ModalFormPlugin extends Plugin { id: "import-form", name: "Import form", callback: () => { - const importModal = new FormImportModal(this.app); + const importModal = new FormImportModal(this.app, { + createForm: (form) => { + this.activateView(EDIT_FORM_VIEW, form); + }, + }); importModal.open(); }, }); diff --git a/src/std/index.ts b/src/std/index.ts index ed24d79e..64ed146d 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 0 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); diff --git a/src/utils/files.ts b/src/utils/files.ts index 341f73ab..7206899f 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -1,6 +1,6 @@ import * as S from "fp-ts/string"; import { App, CachedMetadata, TAbstractFile, TFile, TFolder, Vault, normalizePath } from "obsidian"; -import { E, Either, O, pipe, A } from "@std"; +import { E, Either, 0, pipe, A } from "@std"; export class FolderDoesNotExistError extends Error { static readonly tag = "FolderDoesNotExistError"; } @@ -88,22 +88,22 @@ function isArrayOfStrings(value: unknown): value is string[] { const splitIfString = (value: unknown) => pipe( value, - O.fromPredicate(S.isString), - O.map((s) => s.split(",")), + _O.fromPredicate(S.isString), + _O.map((s) => s.split(",")), ); export function parseToArrOfStr(str: unknown) { return pipe( str, - O.fromNullable, - O.chain((value) => + _O.fromNullable, + _O.chain((value) => pipe( value, splitIfString, /* prettier-ignore */ - O.alt(() => pipe( + _O.alt(() => pipe( value, - O.fromPredicate(isArrayOfStrings))), + _O.fromPredicate(isArrayOfStrings))), ), ), ); @@ -112,13 +112,13 @@ function extract_tags(cache: CachedMetadata): string[] { /* prettier-ignore */ const bodyTags = pipe( cache.tags, - O.fromNullable, - O.map(A.map((tag) => tag.tag))); + _O.fromNullable, + _O.map(A.map((tag) => tag.tag))); const frontmatterTags = pipe( cache.frontmatter, - O.fromNullable, - O.chain((frontmatter) => parseToArrOfStr(frontmatter.tags)), + _O.fromNullable, + _O.chain((frontmatter) => parseToArrOfStr(frontmatter.tags)), ); /* prettier-ignore */ return pipe( @@ -137,9 +137,9 @@ export function enrich_tfile( frontmatter: metadata?.frontmatter ?? {}, tags: pipe( metadata, - O.fromNullable, - O.map(extract_tags), - O.getOrElse(() => [] as string[]), + _O.fromNullable, + _O.map(extract_tags), + _O.getOrElse(() => [] as string[]), ), }; } diff --git a/src/views/FormImport.svelte b/src/views/FormImport.svelte index 9abcae9c..a2acdf71 100644 --- a/src/views/FormImport.svelte +++ b/src/views/FormImport.svelte @@ -1,9 +1,9 @@
@@ -23,14 +23,14 @@ />
- {#if $errors.length > 0} + {#if ui.errors.length > 0}
    - {#each $errors as error} + {#each ui.errors as error}
  • {error}
  • {/each}
@@ -38,12 +38,8 @@
{/if} -
diff --git a/src/views/FormImport.ts b/src/views/FormImport.ts index c6b5278e..5f25a3aa 100644 --- a/src/views/FormImport.ts +++ b/src/views/FormImport.ts @@ -1,21 +1,70 @@ import * as J from "fp-ts/Json"; -import { ensureError, pipe } from "@std"; +import { O, type Option, ensureError, pipe } from "@std"; import * as E from "fp-ts/Either"; import { InvalidData, MigrationError, migrateToLatest } from "src/core/formDefinitionSchema"; import { Readable, writable } from "svelte/store"; +import { FormDefinition } from "src/core/formDefinition"; + +type State = E.Either>; +type UiState = { + canSubmit: boolean; + errors: string[]; + onSubmit: () => void; + buttonHint: string; +}; export interface FormImportModel { - readonly errors: Readable; + readonly state: Readable; readonly validate: (value: string) => void; + uiState(state: State): UiState; +} + +export interface Matchers { + empty(): T; + ok(form: FormDefinition): T; + error(errors: string[]): T; +} + +export interface FormImportDeps { + createForm(form: FormDefinition): void; } -export function makeFormInputModel(): FormImportModel { - const errors = writable([]); +function matchState(state: State, matchers: Matchers): T { + return pipe( + state, + E.match(matchers.error, (form) => + pipe( + form, + // prettier, shut up + O.match(matchers.empty, matchers.ok), + ), + ), + ); +} + +function noop() {} + +export function makeFormInputModel({ createForm }: FormImportDeps): FormImportModel { + const state = writable(E.of(O.none)); + const setErrors = (errors: string[]) => state.set(E.left(errors)); + const resetState = () => state.set(E.of(O.none)); return { - errors, + state, + uiState(state) { + return matchState(state, { + empty: () => ({ canSubmit: false, errors: [], onSubmit: noop, buttonHint: "" }), + ok: (form) => ({ + canSubmit: true, + errors: [], + onSubmit: () => createForm(form), + buttonHint: "✅", + }), + error: (errors) => ({ canSubmit: false, errors, onSubmit: noop, buttonHint: "❌" }), + }); + }, validate: (value: string) => { if (value.trim() === "") { - errors.set([]); + resetState(); return; } pipe( @@ -26,17 +75,17 @@ export function makeFormInputModel(): FormImportModel { E.match( (error) => { if (error instanceof InvalidData) { - errors.set(error.toArrayOfStrings()); + setErrors(error.toArrayOfStrings()); return; } - errors.set([error.toString()]); + setErrors([error.toString()]); }, (form) => { if (form instanceof MigrationError) { - errors.set(form.toArrayOfStrings()); + setErrors(form.toArrayOfStrings()); return; } - errors.set([]); + resetState(); console.log(form); }, ), diff --git a/src/views/FormImportView.ts b/src/views/FormImportView.ts index 890cc398..bf08b6ef 100644 --- a/src/views/FormImportView.ts +++ b/src/views/FormImportView.ts @@ -1,11 +1,17 @@ import { App, Modal } from "obsidian"; import FormImport from "./FormImport.svelte"; -import { makeFormInputModel } from "./FormImport"; - +import { FormImportDeps, makeFormInputModel } from "./FormImport"; +/** + * This class is just the minimum glue code to bind our core logic + * with the svelte UI and obsidian API modal. + */ export class FormImportModal extends Modal { _component!: FormImport; - constructor(app: App) { + constructor( + app: App, + private deps: FormImportDeps, + ) { super(app); } @@ -17,7 +23,7 @@ export class FormImportModal extends Modal { const { contentEl } = this; this._component = new FormImport({ target: contentEl, - props: { model: makeFormInputModel() }, + props: { model: makeFormInputModel(this.deps) }, }); } }