diff --git a/.eslintrc b/.eslintrc index 213217fe..dcd13393 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,12 @@ "root": true, "parser": "@typescript-eslint/parser", "env": { "node": true }, - "plugins": ["@typescript-eslint", "fp-ts", "@stylistic"], + "plugins": [ + "@typescript-eslint", + "fp-ts", + "@stylistic", + "newline-function-call" + ], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", @@ -20,6 +25,7 @@ "@typescript-eslint/no-empty-function": "off", "arrow-parens": ["error", "always"], "fp-ts/no-lib-imports": "error", - "@stylistic/function-call-argument-newline": ["error", "consistent"] + "@stylistic/function-call-argument-newline": ["error", "consistent"], + "newline-function-call/function-call-argument-newline": ["error"] } } diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index a5ed15a4..663d081c 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -12,6 +12,6 @@ jobs: with: cache: "npm" - - run: npm install + - run: npm ci - name: Build and Test run: npm run build && npm run test diff --git a/README.md b/README.md index e27561f8..052b23ac 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ https://github.com/danielo515/obsidian-modal-form/assets/2270425/542974aa-c58b-4 - Define forms using a simple JSON format - Create and manage a collection of forms, each identified by a unique name - User interface for creating new forms +- Create new notes directly from the form using templates + - Template editor has a nice UI for creating templates - Many input types - number - date @@ -33,6 +35,8 @@ https://github.com/danielo515/obsidian-modal-form/assets/2270425/542974aa-c58b-4 - list of notes from a folder ![example form](media/example.png) +![templates](media/templates-v1.gif) + ## Why this plugin? Obsidian is a great tool for taking notes, but it is also a nice for managing data. diff --git a/media/templates-v1.gif b/media/templates-v1.gif new file mode 100644 index 00000000..5740b61b Binary files /dev/null and b/media/templates-v1.gif differ diff --git a/package-lock.json b/package-lock.json index de210b34..fa39813a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "obsidian-modal-form", - "version": "1.27.1", + "version": "1.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-modal-form", - "version": "1.27.1", + "version": "1.29.0", "license": "MIT", "dependencies": { "fp-ts": "^2.16.1", "fuse.js": "^6.6.2", + "parser-ts": "^0.7.0", "type-fest": "^4.6.0", "valibot": "^0.19.0" }, @@ -26,6 +27,7 @@ "esbuild": "0.17.3", "esbuild-svelte": "^0.8.0", "eslint-plugin-fp-ts": "^0.3.2", + "eslint-plugin-newline-function-call": "^1.0.0", "jest": "^29.7.0", "obsidian": "^1.4.11", "prettier": "^3.0.3", @@ -67,7 +69,6 @@ "version": "7.22.13", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", - "dev": true, "dependencies": { "@babel/highlight": "^7.22.13", "chalk": "^2.4.2" @@ -80,7 +81,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -92,7 +92,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -106,7 +105,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -114,14 +112,12 @@ "node_modules/@babel/code-frame/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, "engines": { "node": ">=0.8.0" } @@ -130,7 +126,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, "engines": { "node": ">=4" } @@ -139,7 +134,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -361,7 +355,6 @@ "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -393,7 +386,6 @@ "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", - "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -407,7 +399,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -419,7 +410,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -433,7 +423,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -441,14 +430,12 @@ "node_modules/@babel/highlight/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "node_modules/@babel/highlight/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, "engines": { "node": ">=0.8.0" } @@ -457,7 +444,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, "engines": { "node": ">=4" } @@ -466,7 +452,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -3379,6 +3364,21 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/eslint-plugin-newline-function-call": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-newline-function-call/-/eslint-plugin-newline-function-call-1.0.0.tgz", + "integrity": "sha512-GxKpuxrnCI6iQvhxK4/IAXBIGqT3WRNNapYsQ/s1SaYaCumBIvm4IiNqBygYYa60vcH2Mdwf/pCwrAY09ItIww==", + "dev": true, + "dependencies": { + "fp-ts": "^2.16.1" + }, + "engines": { + "node": ">=8.10.0" + }, + "peerDependencies": { + "eslint": ">=5.16.0" + } + }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -4698,8 +4698,7 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.1.0", @@ -5176,6 +5175,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parser-ts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/parser-ts/-/parser-ts-0.7.0.tgz", + "integrity": "sha512-YBJYgQ6j2DiKryKkYUrw0a7WiUb2DGnanffFZNz5cRTaoTncSxjKCio6ocEBSAa26jFXr0XSNjaYXUrL61BISA==", + "dependencies": { + "@babel/code-frame": "^7.0.0" + }, + "peerDependencies": { + "fp-ts": "^2.14.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", diff --git a/package.json b/package.json index 2bece9cb..737064fd 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "esbuild": "0.17.3", "esbuild-svelte": "^0.8.0", "eslint-plugin-fp-ts": "^0.3.2", + "eslint-plugin-newline-function-call": "^1.0.0", "jest": "^29.7.0", "obsidian": "^1.4.11", "prettier": "^3.0.3", @@ -43,7 +44,8 @@ "dependencies": { "fp-ts": "^2.16.1", "fuse.js": "^6.6.2", + "parser-ts": "^0.7.0", "type-fest": "^4.6.0", "valibot": "^0.19.0" } -} \ No newline at end of file +} diff --git a/src/core/formDefinition.ts b/src/core/formDefinition.ts index 69309b3a..a2b6e193 100644 --- a/src/core/formDefinition.ts +++ b/src/core/formDefinition.ts @@ -15,6 +15,7 @@ import { MigrationError, } from "./formDefinitionSchema"; import { A, O, pipe } from "@std"; +import { ParsedTemplate } from "./template/templateParser"; //=========== Types derived from schemas type selectFromNotes = Output; type inputSlider = Output; @@ -71,6 +72,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 FormWithTemplate = FormDefinition & { template: ParsedTemplate } export type FormOptions = { values?: Record; diff --git a/src/core/formDefinitionSchema.ts b/src/core/formDefinitionSchema.ts index 55e9441d..f03087c0 100644 --- a/src/core/formDefinitionSchema.ts +++ b/src/core/formDefinitionSchema.ts @@ -22,6 +22,7 @@ import { } from "valibot"; import { AllFieldTypes, FormDefinition } from "./formDefinition"; import { findFieldErrors } from "./findInputDefinitionSchema"; +import { ParsedTemplateSchema } from "./template/templateSchema"; /** * Here are the core logic around the main domain of the plugin, @@ -72,7 +73,7 @@ export const InputDataviewSourceSchema = object({ type: literal("dataview"), query: nonEmptyString("dataview query"), }); -export const InputBasicSchema = object({ type: InputBasicTypeSchema, }); +export const InputBasicSchema = object({ type: InputBasicTypeSchema }); export const InputSelectFixedSchema = object({ type: literal("select"), source: literal("fixed"), @@ -176,6 +177,12 @@ const FormDefinitionV1Schema = merge([ object({ version: literal("1"), fields: FieldListSchema, + template: optional( + object({ + createCommand: boolean(), + parsedTemplate: ParsedTemplateSchema, + }), + ), }), ]); // This is the latest schema. @@ -221,7 +228,7 @@ export class InvalidData { constructor( public data: unknown, readonly error: ValiError, - ) { } + ) {} toString(): string { return `InvalidData: ${this.error.issues .map((issue) => issue.message) diff --git a/src/core/objectSelect.ts b/src/core/objectSelect.ts index 5aeff4cb..165c6e12 100644 --- a/src/core/objectSelect.ts +++ b/src/core/objectSelect.ts @@ -14,13 +14,15 @@ const PickOmitSchema = object({ function picKeys(obj: Record) { return (keys: NonEmptyArray) => - pipe(obj, + pipe( + obj, filterWithIndex((k) => keys.includes(k)) ); } function omitKeys(obj: Record) { return (keys: NonEmptyArray) => - pipe(obj, + pipe( + obj, filterWithIndex((k) => !keys.includes(k)) ); } diff --git a/src/core/template/templateParser.test.ts b/src/core/template/templateParser.test.ts new file mode 100644 index 00000000..52f934a1 --- /dev/null +++ b/src/core/template/templateParser.test.ts @@ -0,0 +1,106 @@ +import { parseTemplate, anythingUntilOpenOrEOF } from "./templateParser"; +import * as S from 'parser-ts/string' +import * as E from "fp-ts/Either"; +import { pipe } from "@std"; + +const inspect = (val: unknown) => { + console.dir(val, { depth: 10 }); + return val; +} +const logError = E.mapLeft(console.log); + +describe("parseTemplate", () => { + it.skip("test", () => { + pipe( + S.run("al{nam{{e}}")(anythingUntilOpenOrEOF), + inspect) + }); + it("should parse a single identifier template", () => { + const template = "{{name}}"; + const result = parseTemplate(template); + expect(result).toEqual(E.of([{ _tag: "variable", value: "name" }])); + + }); + it("templates can start with an identifier", () => { + const template = "{{name}} is a name"; + const result = parseTemplate(template); + expect(result).toEqual(E.of([ + { _tag: "variable", value: "name" }, + { _tag: "text", value: " is a name" }, + ])); + }); + it("should parse a valid template", () => { + const template = "Hello, {{name}}!"; + const result = parseTemplate(template); + logError(result); + expect(result).toEqual(E.of( + [ + { _tag: "text", value: "Hello, " }, + { _tag: "variable", value: "name" }, + { _tag: "text", value: "!" }, + ], + )); + }); + it("should parse a valid template with several variables", () => { + const template = "Hello, {{name}}! You are {{age}} years old."; + const result = parseTemplate(template); + logError(result); + expect(result).toEqual(E.of( + [ + { _tag: "text", value: "Hello, " }, + { _tag: "variable", value: "name" }, + { _tag: "text", value: "! You are " }, + { _tag: "variable", value: "age" }, + { _tag: "text", value: " years old." }, + ], + )); + }); + + it("should allow single braces in a template", () => { + const template = "This is code {bla}"; + const result = parseTemplate(template); + logError(result); + expect(result).toEqual(E.of( + [ + { _tag: "text", value: "This is code {bla}" }, + ], + )); + }) + it("should allow single braces in a template even if it has variables", () => { + const template = "This is code {bla} {{name}}"; + const result = parseTemplate(template); + logError(result); + expect(result).toEqual(E.of( + [ + { _tag: "text", value: "This is code {bla} " }, + { _tag: "variable", value: "name" }, + ], + )); + }) + + it.skip("should return a parse error for an invalid template", () => { + const template = "Hey, {{name}!"; + const result = parseTemplate(template); + inspect(result); + logError(result); + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBeDefined(); + } else { + fail("Expected a left value"); + } + }); + + it("should allow spaces within open and close braces", () => { + const template = "Hey, {{ name }}!"; + const result = parseTemplate(template); + logError(result); + expect(result).toEqual(E.of( + [ + { _tag: "text", value: "Hey, " }, + { _tag: "variable", value: "name" }, + { _tag: "text", value: "!" }, + ], + )); + }) +}); diff --git a/src/core/template/templateParser.ts b/src/core/template/templateParser.ts new file mode 100644 index 00000000..ca6936d2 --- /dev/null +++ b/src/core/template/templateParser.ts @@ -0,0 +1,135 @@ +import * as E from 'fp-ts/Either'; +import * as St from 'fp-ts/string' +import * as P from 'parser-ts/Parser' +import { run } from 'parser-ts/code-frame' +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 { TemplateText, TemplateVariable } from './templateSchema'; +import { absurd } from 'fp-ts/function'; +import { ModalFormData } from '../FormResult'; +type Token = TemplateText | TemplateVariable +export type ParsedTemplate = Token[]; + +function TemplateText(value: string): TemplateText { + return { _tag: 'text', value } +} +function TemplateVariable(value: string): TemplateVariable { + return { _tag: 'variable', value } +} + + +// === Parsers === +type TokenParser = P.Parser +// A parser that returns an empty string when it reaches the end of the input. +// required to keep the same type as the other parsers. +const EofStr = pipe( + P.eof(), + P.map(() => '')) +const open = S.fold([S.string('{{'), S.spaces]) +const close = P.expected(S.fold([S.spaces, S.string('}}')]), 'closing variable tag: "}}"') +const identifier = S.many1(C.alphanum) +const templateIdentifier: TokenParser = pipe( + identifier, + P.between(open, close), + P.map(TemplateVariable), +) + + +export const OpenOrEof = P.either(open, () => EofStr) +export const anythingUntilOpenOrEOF = P.many1Till(P.item(), P.lookAhead(OpenOrEof)) + +const text: TokenParser = pipe( + anythingUntilOpenOrEOF, + P.map((value) => TemplateText(value.join('')))) +// function parseTemplate(template: string): E.Either { +const TextOrVariable: TokenParser = pipe( + templateIdentifier, + P.alt(() => text), +) + +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 { + return run((Template), template) + // return S.run(template)(P.many(Template)) +} + +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.none + })) + ) +} + +export function templateError(parsedTemplate: ReturnType): string | undefined { + return pipe( + parsedTemplate, + E.fold( + (error) => error, + () => undefined + ) + ) +} + +function tokenToString(token: Token): string { + const tag = token._tag + switch (tag) { + case 'text': return token.value + case 'variable': return `{{${token.value}}}` + default: + return absurd(tag) + } +} + +function matchToken(onText: (value: string) => T, onVariable: (variable: string) => T) { + return (token: Token): T => { + switch (token._tag) { + case 'text': return onText(token.value) + case 'variable': return onVariable(token.value) + default: + return absurd(token) + } + } +} + +/** + * Given a correctly parsed template, convert it back into a string + * with the right format: variables are surrounded by double curly braces, etc. + * If a template is correct you should be able to parse it and then convert it back + * to a string without losing any information. + * @param parsedTemplate the template in it's already parsed form + * @returns string + */ +export function parsedTemplateToString(parsedTemplate: ParsedTemplate): string { + return pipe( + parsedTemplate, + A.foldMap(St.Monoid)(tokenToString) + ) +} + +export function executeTemplate(parsedTemplate: ParsedTemplate, formData: ModalFormData) { + return pipe( + parsedTemplate, + A.filterMap( + matchToken(O.some, (key) => O.fromNullable(formData[key])) + ), + A.foldMap(St.Monoid)(String) + ) +} diff --git a/src/core/template/templateSchema.ts b/src/core/template/templateSchema.ts new file mode 100644 index 00000000..7bf78720 --- /dev/null +++ b/src/core/template/templateSchema.ts @@ -0,0 +1,16 @@ +import { Output, array, literal, object, string, union } from "valibot"; + +const TemplateTextSchema = object({ + _tag: literal("text"), + value: string(), +}); + +const TemplateVariableSchema = object({ + _tag: literal("variable"), + value: string(), +}); + +export const ParsedTemplateSchema = array(union([TemplateTextSchema, TemplateVariableSchema])) + +export type TemplateText = Output; +export type TemplateVariable = Output; diff --git a/src/main.ts b/src/main.ts index cea8baa7..fa3311fa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ 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/ModalFormError"; -import { type FormDefinition } from "src/core/formDefinition"; +import { FormWithTemplate, type FormDefinition } from "src/core/formDefinition"; import { formNeedsMigration, migrateToLatest, MigrationError, InvalidData } from "./core/formDefinitionSchema"; import { parseSettings, type ModalFormSettings, type OpenPosition, getDefaultSettings } from "src/core/settings"; import { log_notice } from "./utils/Log"; @@ -14,6 +14,10 @@ import * as E from "fp-ts/Either"; import { pipe } from "fp-ts/function"; import * as A from "fp-ts/Array" import { settingsStore } from "./store/store"; +import { O } from "@std"; +import { executeTemplate } from "./core/template/templateParser"; +import { NewNoteModal } from "./suggesters/NewNoteModal"; +import { file_exists } from "./utils/files"; type ViewType = typeof EDIT_FORM_VIEW | typeof MANAGE_FORMS_VIEW; @@ -182,9 +186,62 @@ export default class ModalFormPlugin extends Plugin { this.manageForms(); }, }); + this.addCommand({ + id: 'create-note-from-form', + name: 'Create new note from a form', + callback: () => { + this.createNoteFromForm(); + } + }) // This adds a settings tab so the user can configure various aspects of the plugin this.addSettingTab(new ModalFormSettingTab(this.app, this)); } + /** + * Finds a unique name for a note, given a name. + * It just adds a number at the end of the name if the name is already taken. + * @param name the name of the note, without the extension + * @returns a unique name for the note, full path including the extension + */ + getUniqueNoteName(name: string, destinationFolder?: string): string { + const defaultNotesFolder = this.app.fileManager.getNewFileParent('', 'note.md') + let destinationPath = `${destinationFolder || defaultNotesFolder.path}/${name}.md` + let i = 1; + while (file_exists(destinationPath, this.app)) { + destinationPath = `${defaultNotesFolder.path}/${name}-${i}.md` + i++; + } + return destinationPath; + } + + /** + * Checks if there are forms with templates, and presents a prompt + * to select a form, then opens the forms, and creates a new note + * with the template and the form values + */ + createNoteFromForm() { + const formsWithTemplates = pipe( + this.settings!.formDefinitions, + A.filterMap((form) => { + if (form instanceof MigrationError) { + return O.none; + } + if (form.template !== undefined) { + return O.some(form as FormWithTemplate); + } + return O.none; + }) + ) + const onFormSelected = async (form: FormWithTemplate, noteName: string, destinationFolder: string) => { + const formData = await this.api.openForm(form); + const newNoteFullPath = this.getUniqueNoteName(noteName, destinationFolder); + this.app.vault.create(newNoteFullPath, executeTemplate(form.template, formData.getData())) + } + const picker = new NewNoteModal(this.app, formsWithTemplates, ({ form, folder, noteName }) => { + onFormSelected(form, noteName, folder) + }); + picker.open(); + } + } diff --git a/src/std/index.test.ts b/src/std/index.test.ts index b87c2028..f02f5390 100644 --- a/src/std/index.test.ts +++ b/src/std/index.test.ts @@ -98,9 +98,11 @@ describe("trySchemas", () => { isEmployed: true, }; const result = trySchemas([schema1])(input); - pipe(result, E.bimap( - (x) => expect(x.message).toEqual('Invalid type'), - () => fail('expected a Left') - )) + pipe( + result, + E.bimap( + (x) => expect(x.message).toEqual('Invalid type'), + () => fail('expected a Left') + )) }); }); diff --git a/src/std/index.ts b/src/std/index.ts index 80327691..0ce28c75 100644 --- a/src/std/index.ts +++ b/src/std/index.ts @@ -1,5 +1,5 @@ import { pipe as p, flow as f } from "fp-ts/function"; -import { partitionMap, findFirst, findFirstMap, partition, map as mapArr, filter, compact } from "fp-ts/Array"; +import { partitionMap, findFirst, findFirstMap, partition, map as mapArr, filter, compact, filterMap } from "fp-ts/Array"; import { map as mapO, getOrElse as getOrElseOpt, some, none, fromNullable as fromNullableOpt } from 'fp-ts/Option' import { isLeft, isRight, tryCatchK, map, getOrElse, fromNullable, right, left, mapLeft, Either, bimap, tryCatch, flatMap } from "fp-ts/Either"; import { BaseSchema, Output, ValiError, parse as parseV } from "valibot"; @@ -16,7 +16,8 @@ export const A = { findFirst, findFirstMap, map: mapArr, - filter + filter, + filterMap } /** * Non empty array diff --git a/src/store/formStore.ts b/src/store/formStore.ts index 819bb6f9..fa6cea93 100644 --- a/src/store/formStore.ts +++ b/src/store/formStore.ts @@ -205,7 +205,10 @@ export function makeFormEngine( ), triggerSubmit() { const formState = get(formStore); - pipe(formState.fields, parseForm, E.match(setErrors, onSubmit)); + pipe( + formState.fields, + parseForm, + E.match(setErrors, onSubmit)); }, addField: (field) => { const { initField: setField, setValue } = setFormField(field.name); @@ -242,7 +245,9 @@ export function makeFormEngine( ); return form; } - const newValue = pipe(current.value, O.map(updater)); + const newValue = pipe( + current.value, + O.map(updater)); return { ...form, fields: { diff --git a/src/store/store.ts b/src/store/store.ts index b9752224..ad58ca6f 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -5,11 +5,14 @@ import { ModalFormSettings, getDefaultSettings } from 'src/core/settings'; import { writable, derived } from 'svelte/store'; const settings = writable({ ...getDefaultSettings() }); -export const formsStore = derived(settings, ($settings) => pipe($settings.formDefinitions, A.filter((form): form is FormDefinition => !(form instanceof MigrationError)))); +export const formsStore = derived(settings, ($settings) => pipe( + $settings.formDefinitions, + A.filter((form): form is FormDefinition => !(form instanceof MigrationError)))); const { subscribe, update, set } = settings export const invalidFormsStore = derived(settings, ($settings) => { - return pipe($settings.formDefinitions, + return pipe( + $settings.formDefinitions, A.filter((form): form is MigrationError => form instanceof MigrationError)); }) diff --git a/src/suggesters/FormPickerModal.ts b/src/suggesters/FormPickerModal.ts new file mode 100644 index 00000000..ab228416 --- /dev/null +++ b/src/suggesters/FormPickerModal.ts @@ -0,0 +1,21 @@ +import { App, FuzzySuggestModal } from "obsidian"; +import { FormDefinition } from "src/core/formDefinition"; + +export class FormPickerModal extends FuzzySuggestModal { + constructor(app: App, protected forms: Definition[], protected onSelected: (form: Definition) => void) { + super(app); + } + + getItems(): Definition[] { + return this.forms; + } + + getItemText(item: Definition): string { + return item.title; + } + + onChooseItem(item: Definition, _: MouseEvent | KeyboardEvent): void { + this.close(); + this.onSelected(item); + } +} diff --git a/src/suggesters/NewNoteModal.ts b/src/suggesters/NewNoteModal.ts new file mode 100644 index 00000000..ef047268 --- /dev/null +++ b/src/suggesters/NewNoteModal.ts @@ -0,0 +1,93 @@ +import { App, Modal, Setting } from "obsidian"; +import { FormWithTemplate } from "src/core/formDefinition"; +import { FolderSuggest } from "./suggestFolder"; +import { GenericSuggest } from "./suggestGeneric"; +import { log_notice } from "src/utils/Log"; + +interface OnSelectArgs { + form: FormWithTemplate + folder: string + noteName: string +} + +const formSuggester = (app: App, input: HTMLInputElement, forms: FormWithTemplate[], onChange: (form: FormWithTemplate) => void) => new GenericSuggest( + app, + input, + new Set(forms), + { + getSuggestions: (inputStr, forms) => { + return forms.filter((form) => form.name.toLowerCase().contains(inputStr)) + }, + renderSuggestion: (form, el) => { + el.setText(form.name) + }, + selectSuggestion: (form) => { + onChange(form) + return form.name + } + } +) + +/** + * A modal to select a form, a destination folder and a name + * to create a new note from a form + * @param app the obsidian app + * @param forms the list of forms that have a template + * @param onSelected the callback to call when the user completes the selection + * @returns the modal instance + * @category UI + * */ +export class NewNoteModal extends Modal { + constructor(app: App, private forms: FormWithTemplate[], protected onSelected: (args: OnSelectArgs) => void) { + super(app); + } + + onOpen() { + let destinationFolder = '' + let form: FormWithTemplate + let noteName = '' + const { contentEl } = this; + // h1 is a title + contentEl.createEl('h1', { text: 'New Note from form' }) + + // picker of existing forms + new Setting(contentEl).addSearch((element) => { + formSuggester(this.app, element.inputEl, this.forms, (value) => { + form = value + }) + }).setDesc('Pick a form') + // picker for destination folder + new Setting(contentEl).addSearch((element) => { + new FolderSuggest(element.inputEl, this.app) + element.onChange((value) => { + destinationFolder = value + }) + }).setName('Destination folder') + new Setting(contentEl).addText((element) => { + element.onChange((value) => { + noteName = value + }) + }).setName('Note name'); + // button to create new form + new Setting(contentEl).addButton((element) => { + element.setButtonText('Create new note') + element.onClick(() => { + if (!form || !destinationFolder.trim() || !noteName.trim()) { + log_notice('Missing fields', 'Please fill all the fields') + return + } + this.close() + this.onSelected({ + form, + folder: destinationFolder.trim(), + noteName: noteName.trim(), + }) + }) + }) + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/suggesters/suggestArray.ts b/src/suggesters/suggestArray.ts deleted file mode 100644 index ecf2024b..00000000 --- a/src/suggesters/suggestArray.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { AbstractInputSuggest, App } from "obsidian"; - -export class ArraySuggest extends AbstractInputSuggest { - content: Set; - - constructor(app: App, private inputEl: HTMLInputElement, content: Set) { - super(app, inputEl); - this.content = content; - } - - getSuggestions(inputStr: string): string[] { - const lowerCaseInputStr = inputStr.toLowerCase(); - return [...this.content].filter((content) => - content.contains(lowerCaseInputStr) - ); - } - - renderSuggestion(content: string, el: HTMLElement): void { - el.setText(content); - } - - selectSuggestion(content: string): void { - this.inputEl.value = content; - this.inputEl.trigger("input"); - this.close(); - } -} diff --git a/src/suggesters/suggestGeneric.ts b/src/suggesters/suggestGeneric.ts new file mode 100644 index 00000000..f8dda188 --- /dev/null +++ b/src/suggesters/suggestGeneric.ts @@ -0,0 +1,37 @@ +import { AbstractInputSuggest, App } from "obsidian"; + +export interface Suggester { + getSuggestions(inputStr: string, options: T[]): T[]; + renderSuggestion(option: T, el: HTMLElement): void; + selectSuggestion(option: T): string; +} + +/** + * A generic suggester that can be used with any type of content + * as long as you provide a strategy to get suggestions, render them and select one. + * It abstracts the obsidian OOP abstract input suggester, so you can do it + * in a more functional way. + */ +export class GenericSuggest extends AbstractInputSuggest { + content: Set; + + constructor(app: App, private inputEl: HTMLInputElement, content: Set, private strategy: Suggester) { + super(app, inputEl); + this.content = content; + } + + getSuggestions(inputStr: string): T[] { + const lowerCaseInputStr = inputStr.toLowerCase(); + return this.strategy.getSuggestions(lowerCaseInputStr, [...this.content]) + } + + renderSuggestion(content: T, el: HTMLElement): void { + return this.strategy.renderSuggestion(content, el); + } + + selectSuggestion(value: T): void { + this.inputEl.value = this.strategy.selectSuggestion(value); + this.inputEl.trigger("input"); + this.close(); + } +} diff --git a/src/utils/files.ts b/src/utils/files.ts index 4eed9b2f..bc5078c1 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -73,3 +73,11 @@ export function get_tfiles_from_folder(folder_str: string, app: App): Either app.vault.getAbstractFileByPath(path), + (value) => value !== null + ) +} diff --git a/src/views/FormBuilder.svelte b/src/views/FormBuilder.svelte index be105681..d7ae4467 100644 --- a/src/views/FormBuilder.svelte +++ b/src/views/FormBuilder.svelte @@ -6,7 +6,7 @@ FieldTypeReadable, validateFields, } from "src/core/formDefinition"; - import { setIcon, App } from "obsidian"; + import { setIcon } from "obsidian"; import InputBuilderDataview from "./components/inputBuilderDataview.svelte"; import InputBuilderSelect from "./components/InputBuilderSelect.svelte"; import InputFolder from "./components/InputFolder.svelte"; @@ -14,6 +14,14 @@ import { ModalFormError } from "src/utils/ModalFormError"; 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 Tabs from "./components/Tabs.svelte"; + import { + ParsedTemplate, + parsedTemplateToString, + } from "src/core/template/templateParser"; export let definition: EditableFormDefinition = { title: "", @@ -25,10 +33,15 @@ export let onSubmit: (formDefinition: FormDefinition) => void; export let onCancel: () => void; export let onPreview: (formDefinition: FormDefinition) => void; + let currentTab: "form" | "template" = "form"; $: isValid = isValidFormDefinition(definition); $: errors = validateFields(definition.fields); $: activeFieldIndex = 0; + $: fieldNames = pipe( + definition.fields, + A.map((f) => f.name), + ); function scrollWhenActive(element: HTMLElement, isActive: boolean) { function update(isActive: boolean) { @@ -107,6 +120,12 @@ if (!isValidFormDefinition(definition)) return; onSubmit(definition); }; + function saveTemplate(parsedTemplate: ParsedTemplate) { + onSubmit({ + ...definition, + template: { parsedTemplate, createCommand: true }, + }); + } const handlePreview = () => { if (!isValidFormDefinition(definition)) return; console.log("preview of", definition); @@ -114,271 +133,327 @@ }; - diff --git a/src/views/components/Tabs.svelte b/src/views/components/Tabs.svelte new file mode 100644 index 00000000..3f2ea0d8 --- /dev/null +++ b/src/views/components/Tabs.svelte @@ -0,0 +1,76 @@ + + +
+ {#each tabs as tab} +
+ +
+ {/each} +
+ + diff --git a/src/views/components/TemplateEditor.svelte b/src/views/components/TemplateEditor.svelte new file mode 100644 index 00000000..e3d98e15 --- /dev/null +++ b/src/views/components/TemplateEditor.svelte @@ -0,0 +1,78 @@ + + +
+ Template for {formName} +
+ +

+ Templates are used when you create a note directly from a form. You can put + any text you want and reference the form fields using the {`{{name}}`} + syntax. +

+
+
For example:
+ {exampleText} +
+ +
+ Available fields: +
    + {#each fieldNames as field} +
  • + {field} + {usedVariables.includes(field) ? "✅" : ""} +
  • + {/each} +
+
+ + +{#if templateErrorMessage} +
+
The template is invalid:
+ {templateErrorMessage} +
+{/if} + + diff --git a/tsconfig.json b/tsconfig.json index f6c710cf..7a041f12 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,17 +47,41 @@ "A": { "importPath": "fp-ts/Array" }, + "R": { + "importPath": "fp-ts/Record" + }, + "RA": { + "importPath": "fp-ts/ReadonlyArray" + }, + "RE": { + "importPath": "fp-ts/ReaderEither" + }, + "RIO": { + "importPath": "fp-ts/ReaderIO" + }, + "RNEA": { + "importPath": "fp-ts/ReadonlyNonEmptyArray" + }, + "RT": { + "importPath": "fp-ts/ReaderTask" + }, + "RTE": { + "importPath": "fp-ts/ReaderTaskEither" + }, + "S": { + "importPath": "fp-ts/string" + }, "T": { "importPath": "fp-ts/Task" }, "TE": { "importPath": "fp-ts/TaskEither" }, - "R": { - "importPath": "fp-ts/Record" + "TO": { + "importPath": "fp-ts/TaskOption" }, - "RA": { - "importPath": "fp-ts/ReadonlyArray" + "TU": { + "importPath": "fp-ts/Tuple" }, } }