diff --git a/EXAMPLE_VAULT/.obsidian/app.json b/EXAMPLE_VAULT/.obsidian/app.json index e84d16d..bc18552 100644 --- a/EXAMPLE_VAULT/.obsidian/app.json +++ b/EXAMPLE_VAULT/.obsidian/app.json @@ -1,4 +1,5 @@ { "alwaysUpdateLinks": true, - "attachmentFolderPath": "attachments" + "attachmentFolderPath": "attachments", + "promptDelete": false } \ No newline at end of file diff --git a/EXAMPLE_VAULT/.obsidian/core-plugins.json b/EXAMPLE_VAULT/.obsidian/core-plugins.json index a1c0e0b..6e5c9e4 100644 --- a/EXAMPLE_VAULT/.obsidian/core-plugins.json +++ b/EXAMPLE_VAULT/.obsidian/core-plugins.json @@ -10,12 +10,12 @@ "properties": false, "page-preview": true, "daily-notes": false, - "templates": false, + "templates": true, "note-composer": true, "command-palette": true, "slash-command": false, "editor-status": true, - "bookmarks": true, + "bookmarks": false, "markdown-importer": false, "zk-prefixer": false, "random-note": false, @@ -24,7 +24,7 @@ "slides": false, "audio-recorder": false, "workspaces": false, - "file-recovery": true, + "file-recovery": false, "publish": false, "sync": false } \ No newline at end of file diff --git a/EXAMPLE_VAULT/.obsidian/plugins/modal-form/data.json b/EXAMPLE_VAULT/.obsidian/plugins/modal-form/data.json index 54a5fd8..558ab4c 100644 --- a/EXAMPLE_VAULT/.obsidian/plugins/modal-form/data.json +++ b/EXAMPLE_VAULT/.obsidian/plugins/modal-form/data.json @@ -382,6 +382,202 @@ } ], "version": "1" + }, + { + "title": "Templater example", + "name": "templater-example", + "fields": [ + { + "name": "name", + "label": "", + "description": "", + "isRequired": true, + "input": { + "type": "text", + "hidden": false + } + }, + { + "name": "age", + "label": "", + "description": "", + "isRequired": false, + "input": { + "type": "number", + "hidden": false + } + }, + { + "name": "favorite_book", + "label": "", + "description": "", + "isRequired": false, + "input": { + "type": "note", + "folder": "Books" + } + }, + { + "name": "isFamily", + "label": "", + "description": "", + "isRequired": false, + "input": { + "type": "toggle", + "hidden": false + } + }, + { + "name": "additional_information", + "label": "Additional info", + "description": "Provide any extra notes about this contact that you want", + "isRequired": false, + "input": { + "type": "textarea", + "hidden": false + } + }, + { + "name": "dateOfBirth", + "label": "", + "description": "", + "isRequired": false, + "input": { + "type": "date", + "hidden": false + } + } + ], + "version": "1", + "template": { + "parsedTemplate": [ + { + "_tag": "text", + "value": "---\ncreated: <% tp.date.now(\"YYYY-MM-DD HH:mm:ss\") %>\nmodified: <% tp.file.last_modified_date(\"YYYY-MM-DD HH:mm:ss\") %>\nname: " + }, + { + "_tag": "variable", + "value": "name" + }, + { + "_tag": "text", + "value": "\nage: " + }, + { + "_tag": "variable", + "value": "age" + }, + { + "_tag": "text", + "value": "\ndateOfBirth: " + }, + { + "_tag": "variable", + "value": "dateOfBirth" + }, + { + "_tag": "text", + "value": "\nisFamily: " + }, + { + "_tag": "variable", + "value": "isFamily" + }, + { + "_tag": "text", + "value": "\nfavoriteBook: " + }, + { + "_tag": "variable", + "value": "favorite_book" + }, + { + "_tag": "text", + "value": "\ntags: " + }, + { + "_tag": "variable", + "value": "Tags" + }, + { + "_tag": "text", + "value": "\n---\n\n<%*\n// Get current time in user's timezone\nconst now = moment();\nconst age = parseInt(" + }, + { + "_tag": "variable", + "value": "age" + }, + { + "_tag": "text", + "value": ");\nconst birthYear = now.year() - age;\nconst dateOfBirth = moment(" + }, + { + "_tag": "variable", + "value": "dateOfBirth" + }, + { + "_tag": "text", + "value": ")\n_%>\n\n# " + }, + { + "_tag": "variable", + "value": "name" + }, + { + "_tag": "text", + "value": "'s Profile\n> Created on <% tp.date.now(\"dddd, MMMM Do YYYY\") %> at <% tp.date.now(\"HH:mm\") %>\n\n## Basic Information\n- **Age**: " + }, + { + "_tag": "variable", + "value": "age" + }, + { + "_tag": "text", + "value": " years old *(born around <%* tR += birthYear %>)*\n- **Date of Birth**: " + }, + { + "_tag": "variable", + "value": "dateOfBirth" + }, + { + "_tag": "text", + "value": "\n- **Family Member**: " + }, + { + "_tag": "variable", + "value": "is_family" + }, + { + "_tag": "text", + "value": "\n- **Days until next birthday**: <%* \nif (dateOfBirth) {\n const nextBirthday = dateOfBirth.year(now.year());\n if (nextBirthday.isBefore(now)) {\n nextBirthday.add(1, 'year');\n }\n tR += nextBirthday.diff(now, 'days');\n} else {\n tR += \"Unknown\";\n}\n\nconsole.log({ age, birthYear, frontmatter: tp.frontmatter, dateOfBirth })\n_%> days\n\n## Preferences\n- **Favorite Book**: [[" + }, + { + "_tag": "variable", + "value": "favorite_book" + }, + { + "_tag": "text", + "value": "]]\n<%* if (tp.frontmatter.favorite_book) { %>\n> [!note] Related Books\n> ```dataview\n> LIST\n> FROM #book\n> WHERE contains(file.outlinks, [[" + }, + { + "_tag": "variable", + "value": "favorite_book" + }, + { + "_tag": "text", + "value": "]])\n> SORT file.name ASC\n> ```\n<%* } %>\n\n## Additional Information\n" + }, + { + "_tag": "variable", + "value": "additional_information" + }, + { + "_tag": "text", + "value": "\n\n---\n> Last modified: <% tp.file.last_modified_date(\"dddd, MMMM Do YYYY HH:mm:ss\") %>" + } + ], + "createCommand": true + } } ] } \ No newline at end of file diff --git a/src/core/template/BasicTemplateService.ts b/src/core/template/BasicTemplateService.ts new file mode 100644 index 0000000..94a526a --- /dev/null +++ b/src/core/template/BasicTemplateService.ts @@ -0,0 +1,36 @@ +import { TE } from "@std"; +import { App, normalizePath, TFile } from "obsidian"; +import { Logger } from "src/utils/Logger"; +import { TemplateError } from "./TemplateError"; +import { TemplateService } from "./TemplateService"; + +/** + * Basic template service that creates notes with unchanged content + */ +export class BasicTemplateService implements TemplateService { + constructor( + private app: App, + private logger: Logger, + ) {} + + createNoteFromTemplate = ( + templateContent: string, + targetFolder: string, + filename: string, + openNewNote: boolean, + ): TE.TaskEither => + TE.tryCatch(async () => { + const fullPath = normalizePath(`${targetFolder}/${filename}.md`); + await this.app.vault.create(fullPath, templateContent); + if (openNewNote) { + const file = this.app.vault.getAbstractFileByPath(fullPath); + if (!file) { + this.logger.error("File not found", fullPath); + return; + } + if (file instanceof TFile) { + await this.app.workspace.getLeaf("split").openFile(file); + } + } + }, TemplateError.of("Error creating note from template")); +} diff --git a/src/core/template/TemplateError.ts b/src/core/template/TemplateError.ts new file mode 100644 index 0000000..0ee6ef8 --- /dev/null +++ b/src/core/template/TemplateError.ts @@ -0,0 +1,14 @@ +export class TemplateError extends Error { + public readonly _tag = "TemplateError"; + constructor( + message: string, + public readonly cause?: unknown, + ) { + super(message); + this.name = "TemplateError"; + } + + static of(message: string) { + return (cause: unknown) => new TemplateError(message, cause); + } +} diff --git a/src/core/template/TemplateService.ts b/src/core/template/TemplateService.ts new file mode 100644 index 0000000..0620a62 --- /dev/null +++ b/src/core/template/TemplateService.ts @@ -0,0 +1,14 @@ +import { TE } from "@std"; +import { TemplateError } from "./TemplateError"; + +export interface TemplateService { + /** + * Creates a note from a template content + */ + createNoteFromTemplate( + templateContent: string, + targetFolder: string, + filename: string, + openNewNote: boolean, + ): TE.TaskEither; +} diff --git a/src/core/template/TemplaterService.ts b/src/core/template/TemplaterService.ts new file mode 100644 index 0000000..bc58bdc --- /dev/null +++ b/src/core/template/TemplaterService.ts @@ -0,0 +1,50 @@ +import { TE } from "@std"; +import { App } from "obsidian"; +import { Logger } from "src/utils/Logger"; +import { TemplateError } from "./TemplateError"; +import { TemplateService } from "./TemplateService"; + +export interface TemplaterApi { + create_new_note_from_template: ( + content: string, + folder: string, + title: string, + openNewNote: boolean, + ) => Promise; +} + +/** + * Template service that uses the Templater plugin + */ +export class TemplaterService implements TemplateService { + constructor( + private app: App, + private logger: Logger, + private templaterApi: TemplaterApi, + ) {} + + createNoteFromTemplate = ( + templateContent: string, + targetFolder: string, + filename: string, + openNewNote: boolean, + ): TE.TaskEither => + TE.tryCatch( + async () => { + const title = filename; + const result = await this.templaterApi.create_new_note_from_template( + templateContent, + targetFolder, + title, + openNewNote, + ); + if (result === undefined) { + throw new Error("Templater API returned undefined, probably a parsing error"); + } + }, + (e) => + e instanceof Error + ? TemplateError.of(e.message)(e) + : TemplateError.of("Unknown error")(e), + ); +} diff --git a/src/core/template/getTemplateService.ts b/src/core/template/getTemplateService.ts new file mode 100644 index 0000000..eb098cd --- /dev/null +++ b/src/core/template/getTemplateService.ts @@ -0,0 +1,16 @@ +import { App } from "obsidian"; +import { Logger } from "src/utils/Logger"; +import { BasicTemplateService } from "./BasicTemplateService"; +import { TemplaterService } from "./TemplaterService"; +import { TemplateService } from "./TemplateService"; + +export function getTemplateService(app: App, logger: Logger): TemplateService { + const templaterApi = app.plugins.plugins["templater-obsidian"]?.templater; + if (templaterApi) { + logger.debug("Using Templater plugin for templates"); + return new TemplaterService(app, logger, templaterApi); + } + + logger.debug("Using basic template service"); + return new BasicTemplateService(app, logger); +} diff --git a/src/core/template/index.ts b/src/core/template/index.ts new file mode 100644 index 0000000..2d74315 --- /dev/null +++ b/src/core/template/index.ts @@ -0,0 +1,5 @@ +export * from "./TemplateService"; +export * from "./TemplateError"; +export * from "./BasicTemplateService"; +export * from "./TemplaterService"; +export * from "./getTemplateService"; diff --git a/src/core/template/retryForm.ts b/src/core/template/retryForm.ts new file mode 100644 index 0000000..125a049 --- /dev/null +++ b/src/core/template/retryForm.ts @@ -0,0 +1,29 @@ +import { FormDefinition } from "../formDefinition"; + +export const retryForm: FormDefinition = { + title: "Templater error", + name: "retry-temlate", + version: "1", + fields: [ + { + name: "title", + label: "", + description: "", + input: { + type: "markdown_block", + body: "return `\n==Templater reported an error==\nWe are not sure about what it is, but is very likely a parse error.\nPlease try to fix the templater code below and submit it to retry\n`", + }, + isRequired: false, + }, + { + name: "template", + label: "Code", + description: "Fix the template below and try to submit again", + input: { + type: "textarea", + hidden: false, + }, + isRequired: false, + }, + ], +}; diff --git a/src/main.ts b/src/main.ts index 347c62f..4cf9e65 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,7 @@ -import { A, E, O, pipe } from "@std"; +import { A, E, O, pipe, TE } from "@std"; import { Platform, Plugin, WorkspaceLeaf } from "obsidian"; import { API } from "src/API"; import { ModalFormSettingTab } from "src/ModalFormSettingTab"; -import FormResult from "src/core/FormResult"; import { FormWithTemplate, type FormDefinition } from "src/core/formDefinition"; import { getDefaultSettings, @@ -15,16 +14,20 @@ import { ModalFormError } from "src/utils/ModalFormError"; import { EDIT_FORM_VIEW, EditFormView } from "src/views/EditFormView"; import { MANAGE_FORMS_VIEW, ManageFormsView } from "src/views/ManageFormsView"; import { - InvalidData, - MigrationError, formNeedsMigration, + InvalidData, migrateToLatest, + MigrationError, } from "./core/formDefinitionSchema"; +import { TemplateService } from "./core/template/TemplateService"; +import { getTemplateService } from "./core/template/getTemplateService"; +import { retryForm } from "./core/template/retryForm"; import { executeTemplate } from "./core/template/templateParser"; import { settingsStore } from "./store/store"; import { FormPickerModal } from "./suggesters/FormPickerModal"; import { NewNoteModal } from "./suggesters/NewNoteModal"; import { log_error, log_notice, notifyWarning } from "./utils/Log"; +import { logger } from "./utils/Logger"; import { file_exists } from "./utils/files"; import { FormImportModal } from "./views/FormImportView"; import { TemplateBuilderModal } from "./views/TemplateBuilderModal"; @@ -33,12 +36,6 @@ import { makeModel } from "./views/components/TemplateBuilder"; type ViewType = typeof EDIT_FORM_VIEW | typeof MANAGE_FORMS_VIEW | typeof TEMPLATE_BUILDER_VIEW; -// Define functions and properties you want to make available to other plugins, or templater templates, etc -export interface PublicAPI { - exampleForm(): Promise; - openForm(formReference: string | FormDefinition): Promise; -} - function notifyParsingErrors(errors: InvalidData[]) { if (errors.length === 0) { return; @@ -65,7 +62,8 @@ export default class ModalFormPlugin extends Plugin { public settings: ModalFormSettings | undefined; private unsubscribeSettingsStore: () => void = () => {}; // This things will be setup in the onload function rather than constructor - public api!: PublicAPI; + public api!: API; + private templateService!: TemplateService; manageForms() { return this.activateView(MANAGE_FORMS_VIEW); @@ -230,6 +228,7 @@ export default class ModalFormPlugin extends Plugin { }); this.api = new API(this.app, this); this.attachShortcutToGlobalWindow(); + this.templateService = getTemplateService(this.app, logger); this.registerView(EDIT_FORM_VIEW, (leaf) => new EditFormView(leaf, this)); this.registerView(MANAGE_FORMS_VIEW, (leaf) => new ManageFormsView(leaf, this)); this.registerView(TEMPLATE_BUILDER_VIEW, (leaf) => new TemplateBuilderView(leaf, this)); @@ -347,6 +346,56 @@ export default class ModalFormPlugin extends Plugin { ); } + createNoteFromTemplate( + noteName: string, + noteContent: string, + destinationFolder: string, + ): TE.TaskEither { + const loop = (noteContent: string): TE.TaskEither => { + // Use template service instead of directly creating the file + return pipe( + this.templateService.createNoteFromTemplate( + noteContent, + destinationFolder, + noteName, + false, // don't open the new note + ), + TE.orElse((error) => { + logger.error(error); + return pipe( + TE.tryCatch( + () => + this.api.openForm(retryForm, { + values: { + title: error.message, + template: noteContent, + }, + }), + E.toError, + ), + TE.map((result) => result.get("template")), + TE.chain((template) => { + if (typeof template !== "string") { + notifyWarning("Failed while retrying")("Template is not a string"); + return TE.left(new Error("Template is not a string")); + } + return loop(template); + }), + ); + }), + ); + }; + return pipe( + loop(noteContent), + TE.tapIO(() => () => { + log_notice( + "Note created successfully", + `Note "${noteName}" created in ${destinationFolder}`, + ); + }), + ); + } + /** * Checks if there are forms with templates, and presents a prompt * to select a form, then opens the forms, and creates a new note @@ -360,11 +409,11 @@ export default class ModalFormPlugin extends Plugin { destinationFolder: string, ) => { const formData = await this.api.openForm(form); - const newNoteFullPath = this.getUniqueNoteName(noteName, destinationFolder); const noteContent = executeTemplate(form.template.parsedTemplate, formData.getData()); - console.log("new note content", noteContent); - this.app.vault.create(newNoteFullPath, noteContent); + + await this.createNoteFromTemplate(noteName, noteContent, destinationFolder)(); }; + const picker = new NewNoteModal( this.app, formsWithTemplates, diff --git a/src/typings/obsidian-ex.d.ts b/src/typings/obsidian-ex.d.ts index ba9e2f3..5dd1dbe 100644 --- a/src/typings/obsidian-ex.d.ts +++ b/src/typings/obsidian-ex.d.ts @@ -2,7 +2,7 @@ import type { DataviewApi } from "api/plugin-api"; import type moment from "moment"; import "obsidian"; -import { PublicAPI } from "src/main"; +import { TemplaterApi } from "src/core/template"; declare module "obsidian" { interface MetadataCache { @@ -19,30 +19,22 @@ declare module "obsidian" { dataview?: { api: DataviewApi; }; + "templater-obsidian"?: { templater: TemplaterApi }; }; }; } interface Workspace { /** Sent to rendered dataview components to tell them to possibly refresh */ - on( - name: "dataview:refresh-views", - callback: () => void, - ctx?: unknown, - ): EventRef; + on(name: "dataview:refresh-views", callback: () => void, ctx?: unknown): EventRef; } } declare global { interface Window { DataviewAPI?: DataviewApi; - MF?: PublicAPI; - ModalForm?: PublicAPI; + MF?: API; + ModalForm?: API; + moment: typeof moment; } } - -declare global { - interface Window { - moment: typeof moment; - } -}