From b4e6c963ad7359ca094b5fc92658e50976bbd18a Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez Date: Tue, 24 Oct 2023 17:18:39 +0200 Subject: [PATCH] feat: migrate between form format versions fixes #92 --- fixtures/data-v0.json | 330 +++++++++++++++++++++++++++++++++++++ package-lock.json | 10 +- package.json | 3 +- src/core/formDefinition.ts | 36 +++- src/core/settings.ts | 39 ++++- src/main.ts | 37 +++-- 6 files changed, 424 insertions(+), 31 deletions(-) create mode 100644 fixtures/data-v0.json diff --git a/fixtures/data-v0.json b/fixtures/data-v0.json new file mode 100644 index 00000000..40a613dd --- /dev/null +++ b/fixtures/data-v0.json @@ -0,0 +1,330 @@ +{ + "editorPosition": "mainView", + "formDefinitions": [ + { + "title": "Kill bugs softly", + "name": "kill-bugs", + "fields": [ + { + "name": "num", + "label": "Number of people", + "description": "Be nice", + "input": { + "type": "number", + "source": "notes", + "options": [ + { + "label": "", + "value": "" + }, + { + "label": "", + "value": "" + } + ], + "folder": "Books" + } + }, + { + "name": "lol", + "label": "Lol", + "description": "Super lol", + "input": { + "type": "select", + "source": "fixed", + "options": [ + { + "label": "First", + "value": "1" + }, + { + "label": "Second", + "value": "2" + } + ], + "folder": "People" + } + } + ] + }, + { + "title": "Is example", + "name": "example-form", + "fields": [ + { + "name": "second", + "label": "Second", + "description": "Slide it", + "input": { + "type": "slider", + "min": 1, + "max": 12 + } + }, + { + "name": "dataview", + "label": "persons", + "description": "This is persons dv", + "input": { + "type": "dataview", + "query": "dv.pages('#person').map(p=>p.file.name)" + } + }, + { + "name": "toggle", + "label": "Toggle", + "description": "Yes or no", + "input": { + "type": "toggle" + } + }, + { + "name": "multi_books", + "label": "Books read", + "description": "they are nice", + "input": { + "type": "multiselect", + "source": "notes", + "folder": "Books", + "min": 1, + "max": 11 + } + }, + { + "name": "select", + "label": "Select", + "description": "Pick a value", + "input": { + "type": "select", + "source": "fixed", + "options": [ + { + "label": "First", + "value": "first" + }, + { + "label": "Second", + "value": "second" + } + ] + } + }, + { + "name": "multi_2", + "label": "Multi select fixed", + "description": "Pick some values", + "input": { + "type": "multiselect", + "source": "fixed", + "options": [ + "static one", + "static two", + "" + ], + "multi_select_options": [ + "first", + "second", + "LOL" + ] + } + }, + { + "name": "text_area", + "label": "", + "description": "Multi line", + "input": { + "type": "textarea" + } + }, + { + "name": "text_area_1", + "label": "", + "description": "Multi line", + "input": { + "type": "textarea" + } + } + ] + }, + { + "title": "New form", + "name": "Suggesters", + "fields": [ + { + "name": "automata", + "label": "Automata type", + "description": "LOL", + "input": { + "type": "select", + "source": "fixed", + "multi_select_options": [ + "" + ], + "options": [ + { + "value": "robot", + "label": "🤖 Robot" + }, + { + "value": "huan", + "label": "Human" + } + ] + } + }, + { + "name": "select_notes", + "label": "Select notes", + "description": "Les notes", + "input": { + "type": "select", + "source": "notes", + "multi_select_options": [ + "" + ], + "options": [ + { + "value": "robot", + "label": "🤖 Robot" + }, + { + "value": "huan", + "label": "Human" + } + ], + "folder": "Books" + } + }, + { + "name": "note", + "label": "Suggest note", + "description": "pick a note", + "input": { + "type": "note", + "folder": "People" + } + } + ] + }, + { + "title": "New form", + "name": "Suggesters-copy", + "fields": [ + { + "name": "automata", + "label": "Automata type", + "description": "LOL", + "input": { + "type": "select", + "source": "fixed", + "multi_select_options": [ + "" + ], + "options": [ + { + "value": "robot", + "label": "🤖 Robot" + }, + { + "value": "huan", + "label": "Human" + } + ] + } + }, + { + "name": "select_notes", + "label": "Select notes", + "description": "Les notes", + "input": { + "type": "select", + "source": "notes", + "multi_select_options": [ + "" + ], + "options": [ + { + "value": "robot", + "label": "🤖 Robot" + }, + { + "value": "huan", + "label": "Human" + } + ], + "folder": "Books" + } + }, + { + "name": "note", + "label": "Suggest note", + "description": "pick a note", + "input": { + "type": "note", + "folder": "People" + } + } + ] + }, + { + "title": "New form", + "name": "Suggesters renamed", + "fields": [ + { + "name": "automata", + "label": "Automata type", + "description": "LOL", + "input": { + "type": "select", + "source": "fixed", + "multi_select_options": [ + "" + ], + "options": [ + { + "value": "robot", + "label": "🤖 Robot" + }, + { + "value": "huan", + "label": "Human" + } + ] + } + }, + { + "name": "select_notes", + "label": "Select notes", + "description": "Les notes", + "input": { + "type": "select", + "source": "notes", + "multi_select_options": [ + "" + ], + "options": [ + { + "value": "robot", + "label": "🤖 Robot" + }, + { + "value": "huan", + "label": "Human" + } + ], + "folder": "Books" + } + }, + { + "name": "note", + "label": "Suggest note", + "description": "pick a note", + "input": { + "type": "note", + "folder": "People" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4adfb9cd..6b4edb09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "obsidian-modal-form", - "version": "1.17.0", + "version": "1.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-modal-form", - "version": "1.17.0", + "version": "1.20.0", "license": "MIT", "dependencies": { + "fp-ts": "^2.16.1", "fuse.js": "^6.6.2", "valibot": "^0.19.0" }, @@ -3305,6 +3306,11 @@ "dev": true, "peer": true }, + "node_modules/fp-ts": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.1.tgz", + "integrity": "sha512-by7U5W8dkIzcvDofUcO42yl9JbnHTEDBrzu3pt5fKT+Z4Oy85I21K80EYJYdjQGC2qum4Vo55Ag57iiIK4FYuA==" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", diff --git a/package.json b/package.json index e813d7e3..8f872f5d 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "typescript": "^5.2.2" }, "dependencies": { + "fp-ts": "^2.16.1", "fuse.js": "^6.6.2", "valibot": "^0.19.0" } -} \ No newline at end of file +} diff --git a/src/core/formDefinition.ts b/src/core/formDefinition.ts index da5fa795..d000a04b 100644 --- a/src/core/formDefinition.ts +++ b/src/core/formDefinition.ts @@ -1,4 +1,5 @@ -import { object, number, literal, type Output, is, array, string, union, optional, safeParse, minLength, toTrimmed, merge, any, SafeParseResult, Issues } from "valibot"; +import * as E from "fp-ts/Either"; +import { object, number, literal, type Output, is, array, string, union, optional, safeParse, minLength, toTrimmed, merge, any, Issues } from "valibot"; /** * Here are the core logic around the main domain of the plugin, * which is the form definition. @@ -82,15 +83,24 @@ const FormDefinitionV1Schema = merge([FormDefinitionBasicSchema, object({ fields: FieldListSchema, })]); +// This is the latest schema. +// Make sure to update this when you add a new version. +const FormDefinitionLatestSchema = FormDefinitionV1Schema; + type FormDefinitionV1 = Output; class MigrationError { + static readonly _tag = "MigrationError" as const; constructor(readonly issues: Issues) { } + toString(): string { + return `MigrationError: ${this.issues.map(issue => issue.message).join(', ')}` + } } -function fromV0toV1(data: unknown): FormDefinitionV1 | MigrationError { +//=========== Migration logic +function fromV0toV1(data: unknown): E.Either { const v0 = safeParse(FormDefinitionBasicSchema, data) if (!v0.success) { - return new MigrationError(v0.issues) + return E.left(new MigrationError(v0.issues)) } const unparsedV1 = { ...v0.output, @@ -98,18 +108,26 @@ function fromV0toV1(data: unknown): FormDefinitionV1 | MigrationError { } const v1 = safeParse(FormDefinitionV1Schema, unparsedV1) if (!v1.success) { - return new MigrationError(v1.issues) + return E.left(new MigrationError(v1.issues)) } - return v1.output + return E.right(v1.output) } -export function migrateToLatest(data: unknown): FormDefinition | MigrationError { - if (is(FormDefinitionV1Schema, data)) { - return data; +/** + * + * Parses the form definition and migrates it to the latest version in one operation. + */ +export function migrateToLatest(data: unknown): E.Either { + if (is(FormDefinitionLatestSchema, data)) { + return E.right(data); } return fromV0toV1(data); } +export function formNeedsMigration(data: unknown): boolean { + return !is(FormDefinitionLatestSchema, data); +} + //=========== Types derived from schemas type selectFromNotes = Output; type inputSlider = Output; @@ -153,7 +171,7 @@ export type FieldDefinition = Output; /** * FormDefinition is an already valid form, ready to be used in the form modal. */ -export type FormDefinition = Output; +export type FormDefinition = Output; export type FormOptions = { values?: Record; diff --git a/src/core/settings.ts b/src/core/settings.ts index 406b6868..a849f948 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -1,18 +1,39 @@ +import { Output, array, enumType, is, object, optional, safeParse, unknown } from "valibot"; import type { FormDefinition } from "./formDefinition"; -export const openPositions = { - left: 'Left', - right: 'Right', - mainView: 'Main View', - // modal: 'Modal', +const OpenPositionSchema = enumType(['left', 'right', 'mainView']); +export type OpenPosition = Output; + +export const openPositions: Record = { + left: 'Left', + right: 'Right', + mainView: 'Main View', + // modal: 'Modal', } as const; -export type OpenPosition = keyof typeof openPositions; export function isValidOpenPosition(position: string): position is OpenPosition { - return position in openPositions; + return is(OpenPositionSchema, position); +} + +const ModalFormSettingsSchema = object({ + editorPosition: optional(OpenPositionSchema, 'right'), + formDefinitions: array(unknown()), +}); + +const DEFAULT_SETTINGS: ModalFormSettings = { + editorPosition: 'right', + formDefinitions: [], +}; +export function parseSettings(maybeSettings: unknown) { + if (maybeSettings === null) return { + success: true, + issues: null, + output: DEFAULT_SETTINGS, + }; + return safeParse(ModalFormSettingsSchema, { ...DEFAULT_SETTINGS, ...maybeSettings }); } export interface ModalFormSettings { - editorPosition: OpenPosition; - formDefinitions: FormDefinition[]; + editorPosition: OpenPosition; + formDefinitions: FormDefinition[]; } diff --git a/src/main.ts b/src/main.ts index 880f42fc..473967a7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,18 +6,14 @@ import { API } from "src/API"; import { EDIT_FORM_VIEW, EditFormView } from "src/views/EditFormView"; import { MANAGE_FORMS_VIEW, ManageFormsView } from "src/views/ManageFormsView"; import { ModalFormError } from "src/utils/Error"; -import type { FormDefinition } from "src/core/formDefinition"; -import type { ModalFormSettings, OpenPosition } from "src/core/settings"; - -// Remember to rename these classes and interfaces! +import { formNeedsMigration, type FormDefinition, migrateToLatest } from "src/core/formDefinition"; +import { parseSettings, type ModalFormSettings, type OpenPosition } from "src/core/settings"; +import { log_error, log_notice } from "./utils/Log"; +import { pipe } from "fp-ts/lib/function"; +import * as A from "fp-ts/Array" type ViewType = typeof EDIT_FORM_VIEW | typeof MANAGE_FORMS_VIEW; -const DEFAULT_SETTINGS: ModalFormSettings = { - editorPosition: "right", - formDefinitions: [], -}; - // Define functions and properties you want to make available to other plugins, or templater templates, etc interface PublicAPI { exampleForm(): Promise; @@ -125,7 +121,28 @@ export default class ModalFormPlugin extends Plugin { } async getSettings(): Promise { - return Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + const data = await this.loadData(); + const settingsParsed = parseSettings(data); + if (!settingsParsed.success) { + const error = new ModalFormError('Settings are not valid, check the errors', JSON.stringify(settingsParsed.issues)) + log_error(error) + throw error; + } + const settings = settingsParsed.output; + const migrationIsNeeded = settings.formDefinitions.some(formNeedsMigration); + // Migrate to latest also validates and parses the form definitions, so we always execute it + const formDefinitions = pipe(settings.formDefinitions, A.partitionMap(migrateToLatest)) + if (formDefinitions.left.length > 0) { + log_notice('Some forms could not be parsed', + `We tried to perform an automatic migration, but we failed, please take a look at the following errors: + ${formDefinitions.left.join('\n')}` + ) + } + if (migrationIsNeeded) { + await this.saveSettings(); + console.info('Settings were migrated to the latest version') + } + return { ...settings, formDefinitions: formDefinitions.right } } private async saveSettings() {