From 5945a93ef3671492fb928e10ec8090f64f225ffe Mon Sep 17 00:00:00 2001 From: tolvitty Date: Fri, 13 Feb 2026 13:07:08 +0100 Subject: [PATCH] Add Formfire implementation plan 14-task plan covering scaffolding, types, core logic, UI components, and integration testing. Uses A+B hybrid architecture with FormStore, FieldRenderers, and FormProcessor layers. Co-Authored-By: Claude Opus 4.6 --- .../2026-02-13-formfire-implementation.md | 2080 +++++++++++++++++ 1 file changed, 2080 insertions(+) create mode 100644 docs/plans/2026-02-13-formfire-implementation.md diff --git a/docs/plans/2026-02-13-formfire-implementation.md b/docs/plans/2026-02-13-formfire-implementation.md new file mode 100644 index 0000000..bbca3be --- /dev/null +++ b/docs/plans/2026-02-13-formfire-implementation.md @@ -0,0 +1,2080 @@ +# Formfire Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build an Obsidian plugin for structured data input via forms that creates notes or updates frontmatter. + +**Architecture:** A+B Hybrid — pragmatic like Promptfire, with clean separation like Logfire. Three core layers: FormStore (settings CRUD), FieldRenderers (UI per type), FormProcessor (note creation/frontmatter update). Visual Form Builder in Settings, FormModal for filling out, Sidebar for discovery. + +**Tech Stack:** Obsidian Plugin API, TypeScript 5.6, esbuild, CSS with Obsidian CSS variables. + +**Reference plugins:** obsidian-promptfire (flat structure, settings patterns, CSS conventions), obsidian-logfire (layered architecture, deepMerge, settings tab style). + +--- + +### Task 1: Project Scaffolding + +**Files:** +- Create: `package.json` +- Create: `manifest.json` +- Create: `tsconfig.json` +- Create: `esbuild.config.mjs` +- Create: `.gitignore` + +**Step 1: Create package.json** + +```json +{ + "name": "obsidian-formfire", + "version": "0.1.0", + "description": "Structured data input for Obsidian – forms that create notes or update frontmatter", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "node esbuild.config.mjs production" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "builtin-modules": "^4.0.0", + "esbuild": "^0.24.0", + "obsidian": "latest", + "typescript": "^5.6.0" + } +} +``` + +**Step 2: Create manifest.json** + +```json +{ + "id": "formfire", + "name": "Formfire", + "version": "0.1.0", + "minAppVersion": "1.5.0", + "description": "Structured data input for Obsidian – forms that create notes or update frontmatter", + "author": "Luca", + "isDesktopOnly": true +} +``` + +**Step 3: Create tsconfig.json** + +Follow Logfire's pattern (ES2022, strict): + +```json +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2022", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "importHelpers": true, + "isolatedModules": true, + "skipLibCheck": true, + "lib": ["DOM", "ES2022"] + }, + "include": ["src/**/*.ts"] +} +``` + +**Step 4: Create esbuild.config.mjs** + +Follow Logfire's pattern (no native modules needed, simpler than Logfire): + +```javascript +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const prod = process.argv[2] === "production"; + +const context = await esbuild.context({ + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2022", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} +``` + +**Step 5: Create .gitignore** + +``` +node_modules/ +main.js +*.js.map +``` + +**Step 6: Install dependencies** + +Run: `npm install` +Expected: `node_modules/` created, no errors. + +**Step 7: Verify build works** + +Create a minimal `src/main.ts`: + +```typescript +import { Plugin } from 'obsidian'; + +export default class FormfirePlugin extends Plugin { + async onload(): Promise { + console.log('[Formfire] Loading plugin...'); + } + onunload(): void { + console.log('[Formfire] Unloading plugin.'); + } +} +``` + +Run: `npm run build` +Expected: `main.js` created, no errors. + +**Step 8: Commit** + +```bash +git add package.json manifest.json tsconfig.json esbuild.config.mjs .gitignore src/main.ts +git commit -m "feat: scaffold Formfire plugin project" +``` + +--- + +### Task 2: Type Definitions + +**Files:** +- Create: `src/types.ts` + +**Step 1: Write all type definitions** + +```typescript +// --------------------------------------------------------------------------- +// Field Types +// --------------------------------------------------------------------------- + +export type FieldType = + | 'text' + | 'textarea' + | 'number' + | 'toggle' + | 'date' + | 'dropdown' + | 'tags' + | 'note-link' + | 'folder-picker' + | 'rating'; + +export interface FormField { + id: string; + label: string; + type: FieldType; + required: boolean; + defaultValue?: string | number | boolean | string[]; + placeholder?: string; + /** For dropdown: list of options. For tags: suggested tags. */ + options?: string[]; + /** For note-link: restrict to files in this folder. */ + folder?: string; +} + +// --------------------------------------------------------------------------- +// Form Definition +// --------------------------------------------------------------------------- + +export interface FormDefinition { + id: string; + name: string; + icon: string; + mode: 'create' | 'update'; + + // mode: "create" only + targetFolder?: string; + fileNameTemplate?: string; + bodyTemplate?: string; + + // mode: "update" only + targetFile?: 'active' | 'prompt'; + + fields: FormField[]; +} + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +export interface FormfireSettings { + forms: FormDefinition[]; +} + +export const DEFAULT_SETTINGS: FormfireSettings = { + forms: [], +}; + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +/** Recursively merge source into target. Arrays are replaced, not merged. */ +export function deepMerge>( + target: T, + source: Partial, +): T { + const result = { ...target }; + for (const key of Object.keys(source) as (keyof T)[]) { + const srcVal = source[key]; + const tgtVal = target[key]; + if ( + srcVal !== null && + typeof srcVal === 'object' && + !Array.isArray(srcVal) && + tgtVal !== null && + typeof tgtVal === 'object' && + !Array.isArray(tgtVal) + ) { + result[key] = deepMerge( + tgtVal as Record, + srcVal as Record, + ) as T[keyof T]; + } else if (srcVal !== undefined) { + result[key] = srcVal as T[keyof T]; + } + } + return result; +} +``` + +**Step 2: Verify it compiles** + +Run: `npx tsc --noEmit` +Expected: No errors. + +**Step 3: Commit** + +```bash +git add src/types.ts +git commit -m "feat: add type definitions (FormDefinition, FormField, settings)" +``` + +--- + +### Task 3: Template Engine + +**Files:** +- Create: `src/utils/template-engine.ts` + +**Step 1: Write template engine** + +```typescript +/** + * Simple {{variable}} substitution engine. + * Built-in variables: {{date}}, {{time}}, {{datetime}}. + * All other {{keys}} are resolved from the provided values map. + */ +export function renderTemplate( + template: string, + values: Record, +): string { + const now = new Date(); + const builtins: Record = { + date: now.toISOString().slice(0, 10), + time: now.toTimeString().slice(0, 5), + datetime: `${now.toISOString().slice(0, 10)} ${now.toTimeString().slice(0, 5)}`, + }; + + const allValues = { ...builtins, ...values }; + + return template.replace(/\{\{(\w[\w-]*)\}\}/g, (match, key: string) => { + return allValues[key] ?? match; + }); +} + +/** + * Sanitize a string for use as a file name. + * Removes characters illegal on Windows/macOS/Linux. + */ +export function sanitizeFileName(name: string): string { + return name + .replace(/[\\/:*?"<>|]/g, '-') + .replace(/\s+/g, ' ') + .trim(); +} +``` + +**Step 2: Verify it compiles** + +Run: `npx tsc --noEmit` +Expected: No errors. + +**Step 3: Commit** + +```bash +git add src/utils/template-engine.ts +git commit -m "feat: add template engine with {{variable}} substitution" +``` + +--- + +### Task 4: Validators + +**Files:** +- Create: `src/utils/validators.ts` + +**Step 1: Write validator** + +```typescript +import { FormField } from '../types'; + +export interface ValidationError { + fieldId: string; + message: string; +} + +/** + * Validate form values against field definitions. + * Returns an empty array if everything is valid. + */ +export function validateForm( + fields: FormField[], + values: Record, +): ValidationError[] { + const errors: ValidationError[] = []; + + for (const field of fields) { + const value = values[field.id]; + + if (field.required) { + if (value === undefined || value === null || value === '') { + errors.push({ fieldId: field.id, message: `${field.label} is required.` }); + continue; + } + if (Array.isArray(value) && value.length === 0) { + errors.push({ fieldId: field.id, message: `${field.label} is required.` }); + continue; + } + } + + if (value !== undefined && value !== null && value !== '') { + if (field.type === 'number' && typeof value === 'string') { + if (isNaN(Number(value))) { + errors.push({ fieldId: field.id, message: `${field.label} must be a number.` }); + } + } + if (field.type === 'rating') { + const n = Number(value); + if (isNaN(n) || n < 1 || n > 5) { + errors.push({ fieldId: field.id, message: `${field.label} must be between 1 and 5.` }); + } + } + } + } + + return errors; +} +``` + +**Step 2: Verify it compiles** + +Run: `npx tsc --noEmit` +Expected: No errors. + +**Step 3: Commit** + +```bash +git add src/utils/validators.ts +git commit -m "feat: add form field validators" +``` + +--- + +### Task 5: FormStore (CRUD for Form Definitions) + +**Files:** +- Create: `src/core/form-store.ts` + +**Step 1: Write FormStore** + +```typescript +import { FormDefinition, FormfireSettings } from '../types'; + +/** + * Manages CRUD operations on FormDefinitions within plugin settings. + * Does NOT persist — the caller (plugin) calls saveSettings() after mutations. + */ +export class FormStore { + constructor(private settings: FormfireSettings) {} + + getAll(): FormDefinition[] { + return this.settings.forms; + } + + getById(id: string): FormDefinition | undefined { + return this.settings.forms.find(f => f.id === id); + } + + getByName(name: string): FormDefinition | undefined { + return this.settings.forms.find( + f => f.name.toLowerCase() === name.toLowerCase(), + ); + } + + add(form: FormDefinition): void { + this.settings.forms.push(form); + } + + update(id: string, patch: Partial): void { + const idx = this.settings.forms.findIndex(f => f.id === id); + if (idx === -1) return; + this.settings.forms[idx] = { ...this.settings.forms[idx], ...patch }; + } + + remove(id: string): void { + this.settings.forms = this.settings.forms.filter(f => f.id !== id); + } + + duplicate(id: string): FormDefinition | undefined { + const original = this.getById(id); + if (!original) return undefined; + const copy: FormDefinition = { + ...structuredClone(original), + id: crypto.randomUUID(), + name: `${original.name} (Copy)`, + }; + this.settings.forms.push(copy); + return copy; + } + + /** Move a form up or down in the list. */ + reorder(id: string, direction: 'up' | 'down'): void { + const idx = this.settings.forms.findIndex(f => f.id === id); + if (idx === -1) return; + const swap = direction === 'up' ? idx - 1 : idx + 1; + if (swap < 0 || swap >= this.settings.forms.length) return; + [this.settings.forms[idx], this.settings.forms[swap]] = + [this.settings.forms[swap], this.settings.forms[idx]]; + } + + /** Create a blank FormDefinition with sensible defaults. */ + createBlank(): FormDefinition { + return { + id: crypto.randomUUID(), + name: 'New Form', + icon: 'file-input', + mode: 'create', + targetFolder: '/', + fileNameTemplate: '{{date}}-{{title}}', + bodyTemplate: '', + fields: [], + }; + } +} +``` + +**Step 2: Verify it compiles** + +Run: `npx tsc --noEmit` +Expected: No errors. + +**Step 3: Commit** + +```bash +git add src/core/form-store.ts +git commit -m "feat: add FormStore for CRUD on form definitions" +``` + +--- + +### Task 6: FormProcessor (Note Creation & Frontmatter Update) + +**Files:** +- Create: `src/core/form-processor.ts` + +**Step 1: Write FormProcessor** + +```typescript +import { App, TFile, TFolder, Notice, normalizePath } from 'obsidian'; +import { FormDefinition } from '../types'; +import { renderTemplate, sanitizeFileName } from '../utils/template-engine'; + +/** + * Processes a submitted form: creates a new note or updates frontmatter. + */ +export class FormProcessor { + constructor(private app: App) {} + + /** + * Process form submission based on mode. + * Returns the file that was created or updated. + */ + async process( + form: FormDefinition, + values: Record, + targetFile?: TFile, + ): Promise { + if (form.mode === 'create') { + return this.createNote(form, values); + } else { + return this.updateNote(form, values, targetFile); + } + } + + private async createNote( + form: FormDefinition, + values: Record, + ): Promise { + const stringValues = this.toStringValues(values); + + // Build file name + const rawName = form.fileNameTemplate + ? renderTemplate(form.fileNameTemplate, stringValues) + : `${stringValues['date'] ?? new Date().toISOString().slice(0, 10)}-untitled`; + const fileName = sanitizeFileName(rawName); + + // Ensure target folder exists + const folder = normalizePath(form.targetFolder ?? '/'); + await this.ensureFolder(folder); + + // Build file path (avoid duplicates) + let filePath = normalizePath(`${folder}/${fileName}.md`); + let counter = 1; + while (this.app.vault.getAbstractFileByPath(filePath)) { + filePath = normalizePath(`${folder}/${fileName}-${counter}.md`); + counter++; + } + + // Build content + const frontmatter = this.buildFrontmatter(form, values); + const body = form.bodyTemplate + ? renderTemplate(form.bodyTemplate, stringValues) + : ''; + const content = `---\n${frontmatter}---\n${body}`; + + const file = await this.app.vault.create(filePath, content); + new Notice(`Created: ${file.path}`); + return file; + } + + private async updateNote( + form: FormDefinition, + values: Record, + targetFile?: TFile, + ): Promise { + if (!targetFile) { + throw new Error('No target file specified for update.'); + } + + await this.app.fileManager.processFrontMatter(targetFile, (fm) => { + for (const field of form.fields) { + const value = values[field.id]; + if (value !== undefined) { + fm[field.id] = value; + } + } + }); + + new Notice(`Updated: ${targetFile.path}`); + return targetFile; + } + + private buildFrontmatter( + form: FormDefinition, + values: Record, + ): string { + let yaml = ''; + for (const field of form.fields) { + const value = values[field.id]; + if (value === undefined || value === null) continue; + + if (Array.isArray(value)) { + yaml += `${field.id}:\n`; + for (const item of value) { + yaml += ` - ${this.yamlEscape(String(item))}\n`; + } + } else if (typeof value === 'boolean') { + yaml += `${field.id}: ${value}\n`; + } else if (typeof value === 'number') { + yaml += `${field.id}: ${value}\n`; + } else { + yaml += `${field.id}: ${this.yamlEscape(String(value))}\n`; + } + } + return yaml; + } + + private yamlEscape(value: string): string { + if (/[:#{}[\],&*?|>!%@`]/.test(value) || value.includes('\n')) { + return `"${value.replace(/"/g, '\\"')}"`; + } + return value; + } + + private toStringValues(values: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (Array.isArray(value)) { + result[key] = value.join(', '); + } else { + result[key] = String(value ?? ''); + } + } + return result; + } + + private async ensureFolder(path: string): Promise { + if (path === '/' || path === '') return; + const existing = this.app.vault.getAbstractFileByPath(path); + if (existing instanceof TFolder) return; + await this.app.vault.createFolder(path); + } +} +``` + +**Step 2: Verify it compiles** + +Run: `npx tsc --noEmit` +Expected: No errors. + +**Step 3: Commit** + +```bash +git add src/core/form-processor.ts +git commit -m "feat: add FormProcessor for note creation and frontmatter updates" +``` + +--- + +### Task 7: Field Renderers + +**Files:** +- Create: `src/ui/field-renderers.ts` + +This is the most important UI file. Each field type gets a renderer function that creates Obsidian-native UI elements inside a container. + +**Step 1: Write field renderers** + +```typescript +import { App, TFolder } from 'obsidian'; +import { FormField } from '../types'; + +export type FieldValue = string | number | boolean | string[]; + +export interface RenderedField { + getValue: () => FieldValue; + setValue: (value: FieldValue) => void; + setError: (message: string | null) => void; +} + +/** + * Render a single form field into the given container element. + * Returns accessor functions for value get/set and error display. + */ +export function renderField( + app: App, + container: HTMLElement, + field: FormField, + initialValue?: FieldValue, +): RenderedField { + switch (field.type) { + case 'text': return renderTextField(container, field, initialValue); + case 'textarea': return renderTextareaField(container, field, initialValue); + case 'number': return renderNumberField(container, field, initialValue); + case 'toggle': return renderToggleField(container, field, initialValue); + case 'date': return renderDateField(container, field, initialValue); + case 'dropdown': return renderDropdownField(container, field, initialValue); + case 'tags': return renderTagsField(container, field, initialValue); + case 'note-link': return renderNoteLinkField(app, container, field, initialValue); + case 'folder-picker': return renderFolderPickerField(app, container, field, initialValue); + case 'rating': return renderRatingField(container, field, initialValue); + default: return renderTextField(container, field, initialValue); + } +} + +// --------------------------------------------------------------------------- +// Helper: create a wrapper with label and error area +// --------------------------------------------------------------------------- + +function createFieldWrapper( + container: HTMLElement, + field: FormField, +): { wrapper: HTMLElement; controlEl: HTMLElement; errorEl: HTMLElement } { + const wrapper = container.createDiv({ cls: 'ff-field' }); + + const labelEl = wrapper.createEl('label', { + cls: 'ff-field-label', + text: field.label, + }); + if (field.required) { + labelEl.createSpan({ cls: 'ff-field-required', text: ' *' }); + } + + const controlEl = wrapper.createDiv({ cls: 'ff-field-control' }); + const errorEl = wrapper.createDiv({ cls: 'ff-field-error' }); + errorEl.style.display = 'none'; + + return { wrapper, controlEl, errorEl }; +} + +function makeSetError(errorEl: HTMLElement): (message: string | null) => void { + return (message: string | null) => { + if (message) { + errorEl.textContent = message; + errorEl.style.display = 'block'; + } else { + errorEl.textContent = ''; + errorEl.style.display = 'none'; + } + }; +} + +// --------------------------------------------------------------------------- +// Individual renderers +// --------------------------------------------------------------------------- + +function renderTextField( + container: HTMLElement, + field: FormField, + initial?: FieldValue, +): RenderedField { + const { controlEl, errorEl } = createFieldWrapper(container, field); + const input = controlEl.createEl('input', { + type: 'text', + cls: 'ff-input', + placeholder: field.placeholder ?? '', + }); + if (initial !== undefined) input.value = String(initial); + + return { + getValue: () => input.value, + setValue: (v) => { input.value = String(v); }, + setError: makeSetError(errorEl), + }; +} + +function renderTextareaField( + container: HTMLElement, + field: FormField, + initial?: FieldValue, +): RenderedField { + const { controlEl, errorEl } = createFieldWrapper(container, field); + const textarea = controlEl.createEl('textarea', { + cls: 'ff-textarea', + placeholder: field.placeholder ?? '', + }); + textarea.rows = 4; + if (initial !== undefined) textarea.value = String(initial); + + return { + getValue: () => textarea.value, + setValue: (v) => { textarea.value = String(v); }, + setError: makeSetError(errorEl), + }; +} + +function renderNumberField( + container: HTMLElement, + field: FormField, + initial?: FieldValue, +): RenderedField { + const { controlEl, errorEl } = createFieldWrapper(container, field); + const input = controlEl.createEl('input', { + type: 'number', + cls: 'ff-input', + placeholder: field.placeholder ?? '', + }); + if (initial !== undefined) input.value = String(initial); + + return { + getValue: () => input.value === '' ? '' : Number(input.value), + setValue: (v) => { input.value = String(v); }, + setError: makeSetError(errorEl), + }; +} + +function renderToggleField( + container: HTMLElement, + field: FormField, + initial?: FieldValue, +): RenderedField { + const { controlEl, errorEl } = createFieldWrapper(container, field); + let currentValue = initial === true || initial === 'true'; + + const toggleEl = controlEl.createDiv({ cls: 'checkbox-container' }); + if (currentValue) toggleEl.addClass('is-enabled'); + + toggleEl.addEventListener('click', () => { + currentValue = !currentValue; + toggleEl.toggleClass('is-enabled', currentValue); + }); + + return { + getValue: () => currentValue, + setValue: (v) => { + currentValue = v === true || v === 'true'; + toggleEl.toggleClass('is-enabled', currentValue); + }, + setError: makeSetError(errorEl), + }; +} + +function renderDateField( + container: HTMLElement, + field: FormField, + initial?: FieldValue, +): RenderedField { + const { controlEl, errorEl } = createFieldWrapper(container, field); + const input = controlEl.createEl('input', { + type: 'date', + cls: 'ff-input', + }); + if (initial) { + input.value = String(initial); + } else if (field.defaultValue === undefined) { + input.value = new Date().toISOString().slice(0, 10); + } + + return { + getValue: () => input.value, + setValue: (v) => { input.value = String(v); }, + setError: makeSetError(errorEl), + }; +} + +function renderDropdownField( + container: HTMLElement, + field: FormField, + initial?: FieldValue, +): RenderedField { + const { controlEl, errorEl } = createFieldWrapper(container, field); + const select = controlEl.createEl('select', { cls: 'dropdown ff-dropdown' }); + + // Empty option + select.createEl('option', { value: '', text: field.placeholder ?? '-- Select --' }); + + for (const opt of field.options ?? []) { + select.createEl('option', { value: opt, text: opt }); + } + + if (initial !== undefined) select.value = String(initial); + + return { + getValue: () => select.value, + setValue: (v) => { select.value = String(v); }, + setError: makeSetError(errorEl), + }; +} + +function renderTagsField( + container: HTMLElement, + field: FormField, + initial?: FieldValue, +): RenderedField { + const { controlEl, errorEl } = createFieldWrapper(container, field); + let selectedTags: string[] = Array.isArray(initial) + ? [...initial] + : (initial ? String(initial).split(',').map(s => s.trim()).filter(Boolean) : []); + + const tagsContainer = controlEl.createDiv({ cls: 'ff-tags-container' }); + const input = controlEl.createEl('input', { + type: 'text', + cls: 'ff-input ff-tags-input', + placeholder: field.placeholder ?? 'Type and press Enter...', + }); + + function renderTags() { + tagsContainer.empty(); + for (const tag of selectedTags) { + const chip = tagsContainer.createSpan({ cls: 'ff-tag-chip', text: tag }); + const remove = chip.createSpan({ cls: 'ff-tag-remove', text: ' x' }); + remove.addEventListener('click', () => { + selectedTags = selectedTags.filter(t => t !== tag); + renderTags(); + }); + } + } + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && input.value.trim()) { + e.preventDefault(); + const tag = input.value.trim(); + if (!selectedTags.includes(tag)) { + selectedTags.push(tag); + renderTags(); + } + input.value = ''; + } + }); + + renderTags(); + + return { + getValue: () => [...selectedTags], + setValue: (v) => { + selectedTags = Array.isArray(v) ? [...v] : []; + renderTags(); + }, + setError: makeSetError(errorEl), + }; +} + +function renderNoteLinkField( + app: App, + container: HTMLElement, + field: FormField, + initial?: FieldValue, +): RenderedField { + const { controlEl, errorEl } = createFieldWrapper(container, field); + const input = controlEl.createEl('input', { + type: 'text', + cls: 'ff-input', + placeholder: field.placeholder ?? 'Start typing to search notes...', + }); + if (initial !== undefined) input.value = String(initial); + + const suggestEl = controlEl.createDiv({ cls: 'ff-suggest-list' }); + suggestEl.style.display = 'none'; + + function updateSuggestions() { + const query = input.value.toLowerCase(); + if (!query) { suggestEl.style.display = 'none'; return; } + + const files = app.vault.getMarkdownFiles() + .filter(f => { + if (field.folder && !f.path.startsWith(field.folder)) return false; + return f.basename.toLowerCase().includes(query); + }) + .slice(0, 10); + + suggestEl.empty(); + if (files.length === 0) { suggestEl.style.display = 'none'; return; } + + for (const file of files) { + const item = suggestEl.createDiv({ cls: 'ff-suggest-item', text: file.basename }); + item.addEventListener('mousedown', (e) => { + e.preventDefault(); + input.value = file.basename; + suggestEl.style.display = 'none'; + }); + } + suggestEl.style.display = 'block'; + } + + input.addEventListener('input', updateSuggestions); + input.addEventListener('focus', updateSuggestions); + input.addEventListener('blur', () => { + setTimeout(() => { suggestEl.style.display = 'none'; }, 200); + }); + + return { + getValue: () => input.value, + setValue: (v) => { input.value = String(v); }, + setError: makeSetError(errorEl), + }; +} + +function renderFolderPickerField( + app: App, + container: HTMLElement, + field: FormField, + initial?: FieldValue, +): RenderedField { + const { controlEl, errorEl } = createFieldWrapper(container, field); + const input = controlEl.createEl('input', { + type: 'text', + cls: 'ff-input', + placeholder: field.placeholder ?? 'Start typing to search folders...', + }); + if (initial !== undefined) input.value = String(initial); + + const suggestEl = controlEl.createDiv({ cls: 'ff-suggest-list' }); + suggestEl.style.display = 'none'; + + function getAllFolders(): string[] { + const folders: string[] = ['/']; + app.vault.getAllLoadedFiles().forEach(f => { + if (f instanceof TFolder && f.path !== '/') { + folders.push(f.path); + } + }); + return folders.sort(); + } + + function updateSuggestions() { + const query = input.value.toLowerCase(); + const folders = getAllFolders().filter(f => f.toLowerCase().includes(query)).slice(0, 10); + + suggestEl.empty(); + if (folders.length === 0) { suggestEl.style.display = 'none'; return; } + + for (const folder of folders) { + const item = suggestEl.createDiv({ cls: 'ff-suggest-item', text: folder }); + item.addEventListener('mousedown', (e) => { + e.preventDefault(); + input.value = folder; + suggestEl.style.display = 'none'; + }); + } + suggestEl.style.display = 'block'; + } + + input.addEventListener('input', updateSuggestions); + input.addEventListener('focus', updateSuggestions); + input.addEventListener('blur', () => { + setTimeout(() => { suggestEl.style.display = 'none'; }, 200); + }); + + return { + getValue: () => input.value, + setValue: (v) => { input.value = String(v); }, + setError: makeSetError(errorEl), + }; +} + +function renderRatingField( + container: HTMLElement, + field: FormField, + initial?: FieldValue, +): RenderedField { + const { controlEl, errorEl } = createFieldWrapper(container, field); + let currentRating = initial ? Number(initial) : 0; + + const starsEl = controlEl.createDiv({ cls: 'ff-rating' }); + const stars: HTMLElement[] = []; + + function renderStars() { + for (let i = 0; i < 5; i++) { + stars[i].textContent = i < currentRating ? '\u2605' : '\u2606'; + stars[i].toggleClass('ff-star-active', i < currentRating); + } + } + + for (let i = 0; i < 5; i++) { + const star = starsEl.createSpan({ cls: 'ff-star' }); + star.addEventListener('click', () => { + currentRating = i + 1; + renderStars(); + }); + stars.push(star); + } + + renderStars(); + + return { + getValue: () => currentRating, + setValue: (v) => { currentRating = Number(v) || 0; renderStars(); }, + setError: makeSetError(errorEl), + }; +} +``` + +**Step 2: Verify it compiles** + +Run: `npx tsc --noEmit` +Expected: No errors. + +**Step 3: Commit** + +```bash +git add src/ui/field-renderers.ts +git commit -m "feat: add field renderers for all 10 field types" +``` + +--- + +### Task 8: FormModal (The Form UI) + +**Files:** +- Create: `src/ui/form-modal.ts` + +**Step 1: Write FormModal** + +```typescript +import { App, Modal, TFile, Notice, FuzzySuggestModal } from 'obsidian'; +import { FormDefinition } from '../types'; +import { renderField, RenderedField, FieldValue } from './field-renderers'; +import { validateForm, ValidationError } from '../utils/validators'; +import { FormProcessor } from '../core/form-processor'; + +/** + * Modal that renders a form for the user to fill out and submit. + */ +export class FormModal extends Modal { + private renderedFields: Map = new Map(); + private processor: FormProcessor; + + constructor( + app: App, + private form: FormDefinition, + private onSubmit?: (file: TFile) => void, + ) { + super(app); + this.processor = new FormProcessor(app); + } + + async onOpen(): Promise { + const { contentEl } = this; + contentEl.addClass('ff-form-modal'); + + // Title + contentEl.createEl('h2', { text: this.form.name }); + + // If update mode with prompt, pick file first + if (this.form.mode === 'update' && this.form.targetFile === 'prompt') { + await this.renderWithFilePicker(contentEl); + } else { + this.renderForm(contentEl); + } + } + + private async renderWithFilePicker(contentEl: HTMLElement): Promise { + const file = await this.pickFile(); + if (!file) { + this.close(); + return; + } + this.renderForm(contentEl, file); + } + + private renderForm(contentEl: HTMLElement, prefillFile?: TFile): void { + const fieldsContainer = contentEl.createDiv({ cls: 'ff-fields' }); + + // Get existing frontmatter for pre-filling (update mode) + let existingValues: Record = {}; + if (prefillFile) { + const cache = this.app.metadataCache.getFileCache(prefillFile); + existingValues = cache?.frontmatter ?? {}; + } + + // Render fields + for (const field of this.form.fields) { + const initial = existingValues[field.id] ?? field.defaultValue; + const rendered = renderField(this.app, fieldsContainer, field, initial as FieldValue); + this.renderedFields.set(field.id, rendered); + } + + // Submit button + const footer = contentEl.createDiv({ cls: 'ff-form-footer' }); + const submitBtn = footer.createEl('button', { + text: this.form.mode === 'create' ? 'Create Note' : 'Update Frontmatter', + cls: 'mod-cta', + }); + + submitBtn.addEventListener('click', async () => { + await this.handleSubmit(prefillFile); + }); + } + + private async handleSubmit(targetFile?: TFile): Promise { + // Collect values + const values: Record = {}; + for (const field of this.form.fields) { + const rendered = this.renderedFields.get(field.id); + if (rendered) { + values[field.id] = rendered.getValue(); + } + } + + // Validate + const errors = validateForm(this.form.fields, values); + this.clearErrors(); + if (errors.length > 0) { + this.showErrors(errors); + return; + } + + // Resolve target file for update mode + let resolvedTarget = targetFile; + if (this.form.mode === 'update' && this.form.targetFile === 'active' && !resolvedTarget) { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { + new Notice('No active file to update.'); + return; + } + resolvedTarget = activeFile; + } + + try { + const file = await this.processor.process(this.form, values, resolvedTarget); + this.onSubmit?.(file); + this.close(); + } catch (error) { + new Notice(`Error: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private clearErrors(): void { + for (const rendered of this.renderedFields.values()) { + rendered.setError(null); + } + } + + private showErrors(errors: ValidationError[]): void { + for (const error of errors) { + const rendered = this.renderedFields.get(error.fieldId); + rendered?.setError(error.message); + } + } + + private pickFile(): Promise { + return new Promise((resolve) => { + let resolved = false; + const modal = new FilePickerModal(this.app, (file) => { + resolved = true; + resolve(file); + }); + modal.onClose = () => { + if (!resolved) resolve(null); + }; + modal.open(); + }); + } + + onClose(): void { + this.contentEl.empty(); + this.renderedFields.clear(); + } +} + +/** + * Simple fuzzy file picker modal. + */ +class FilePickerModal extends FuzzySuggestModal { + constructor(app: App, private onPick: (file: TFile) => void) { + super(app); + this.setPlaceholder('Pick a note to update...'); + } + + getItems(): TFile[] { + return this.app.vault.getMarkdownFiles(); + } + + getItemText(item: TFile): string { + return item.path; + } + + onChooseItem(item: TFile): void { + this.onPick(item); + } +} +``` + +**Step 2: Verify it compiles** + +Run: `npx tsc --noEmit` +Expected: No errors. + +**Step 3: Commit** + +```bash +git add src/ui/form-modal.ts +git commit -m "feat: add FormModal for rendering and submitting forms" +``` + +--- + +### Task 9: Form Picker Modal + +**Files:** +- Create: `src/ui/form-picker-modal.ts` + +**Step 1: Write FormPickerModal** + +```typescript +import { App, FuzzySuggestModal } from 'obsidian'; +import { FormDefinition } from '../types'; + +/** + * Fuzzy picker to choose which form to open. + */ +export class FormPickerModal extends FuzzySuggestModal { + constructor( + app: App, + private forms: FormDefinition[], + private onPick: (form: FormDefinition) => void, + ) { + super(app); + this.setPlaceholder('Pick a form...'); + } + + getItems(): FormDefinition[] { + return this.forms; + } + + getItemText(item: FormDefinition): string { + return item.name; + } + + onChooseItem(item: FormDefinition): void { + this.onPick(item); + } +} +``` + +**Step 2: Commit** + +```bash +git add src/ui/form-picker-modal.ts +git commit -m "feat: add FormPickerModal for quick form selection" +``` + +--- + +### Task 10: Sidebar View + +**Files:** +- Create: `src/ui/form-sidebar.ts` + +**Step 1: Write sidebar view** + +```typescript +import { ItemView, WorkspaceLeaf, setIcon } from 'obsidian'; +import { FormDefinition } from '../types'; +import type FormfirePlugin from '../main'; + +export const FORM_SIDEBAR_VIEW_TYPE = 'formfire-sidebar'; + +export class FormSidebarView extends ItemView { + constructor(leaf: WorkspaceLeaf, private plugin: FormfirePlugin) { + super(leaf); + } + + getViewType(): string { + return FORM_SIDEBAR_VIEW_TYPE; + } + + getDisplayText(): string { + return 'Formfire'; + } + + getIcon(): string { + return 'file-input'; + } + + async onOpen(): Promise { + this.render(); + } + + render(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('ff-sidebar'); + + const header = contentEl.createDiv({ cls: 'ff-sidebar-header' }); + header.createEl('h3', { text: 'Forms' }); + + const settingsBtn = header.createEl('button', { + cls: 'ff-sidebar-settings clickable-icon', + attr: { 'aria-label': 'Open settings' }, + }); + setIcon(settingsBtn, 'settings'); + settingsBtn.addEventListener('click', () => { + (this.app as any).setting.open(); + (this.app as any).setting.openTabById('formfire'); + }); + + const forms = this.plugin.formStore.getAll(); + + if (forms.length === 0) { + contentEl.createDiv({ + cls: 'ff-sidebar-empty', + text: 'No forms yet. Open settings to create one.', + }); + return; + } + + const list = contentEl.createDiv({ cls: 'ff-sidebar-list' }); + for (const form of forms) { + const item = list.createDiv({ cls: 'ff-sidebar-item' }); + + item.createSpan({ + cls: `ff-sidebar-badge ff-badge-${form.mode}`, + text: form.mode === 'create' ? 'C' : 'U', + }); + + item.createSpan({ cls: 'ff-sidebar-name', text: form.name }); + + item.addEventListener('click', () => { + this.plugin.openForm(form.id); + }); + } + } + + async onClose(): Promise { + this.contentEl.empty(); + } +} +``` + +**Step 2: Verify it compiles** + +Run: `npx tsc --noEmit` +Expected: May have errors due to `FormfirePlugin` not yet having `formStore` and `openForm` — that's fine, we wire it up in Task 12. + +**Step 3: Commit** + +```bash +git add src/ui/form-sidebar.ts +git commit -m "feat: add sidebar view with form list" +``` + +--- + +### Task 11: Settings Tab with Visual Form Builder + +**Files:** +- Create: `src/ui/settings-tab.ts` +- Create: `src/ui/form-builder.ts` + +This is the largest UI task. The Settings Tab shows a list of forms with CRUD buttons. The Form Builder is a modal for editing a single FormDefinition. + +**Step 1: Write form-builder.ts (modal for editing a form definition)** + +```typescript +import { App, Modal, Setting, Notice } from 'obsidian'; +import { FormDefinition, FormField, FieldType } from '../types'; + +const FIELD_TYPES: { value: FieldType; label: string }[] = [ + { value: 'text', label: 'Text' }, + { value: 'textarea', label: 'Textarea' }, + { value: 'number', label: 'Number' }, + { value: 'toggle', label: 'Toggle' }, + { value: 'date', label: 'Date' }, + { value: 'dropdown', label: 'Dropdown' }, + { value: 'tags', label: 'Tags' }, + { value: 'note-link', label: 'Note Link' }, + { value: 'folder-picker', label: 'Folder Picker' }, + { value: 'rating', label: 'Rating (1-5)' }, +]; + +/** + * Modal for creating or editing a FormDefinition. + */ +export class FormBuilderModal extends Modal { + private draft: FormDefinition; + + constructor( + app: App, + form: FormDefinition, + private onSave: (form: FormDefinition) => void, + ) { + super(app); + this.draft = structuredClone(form); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.addClass('ff-builder-modal'); + this.render(); + } + + private render(): void { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl('h2', { text: this.draft.name ? `Edit: ${this.draft.name}` : 'New Form' }); + + // --- General settings --- + new Setting(contentEl) + .setName('Form name') + .addText(t => t + .setValue(this.draft.name) + .setPlaceholder('e.g. Meeting Note') + .onChange(v => { this.draft.name = v; })); + + new Setting(contentEl) + .setName('Mode') + .setDesc('Create a new note or update an existing one') + .addDropdown(d => d + .addOption('create', 'Create new note') + .addOption('update', 'Update frontmatter') + .setValue(this.draft.mode) + .onChange(v => { + this.draft.mode = v as 'create' | 'update'; + this.render(); + })); + + // --- Mode-specific settings --- + if (this.draft.mode === 'create') { + new Setting(contentEl) + .setName('Target folder') + .addText(t => t + .setValue(this.draft.targetFolder ?? '') + .setPlaceholder('/') + .onChange(v => { this.draft.targetFolder = v; })); + + new Setting(contentEl) + .setName('File name template') + .setDesc('Use {{fieldId}} for variables, e.g. {{date}}-{{title}}') + .addText(t => t + .setValue(this.draft.fileNameTemplate ?? '') + .setPlaceholder('{{date}}-{{title}}') + .onChange(v => { this.draft.fileNameTemplate = v; })); + + new Setting(contentEl) + .setName('Body template') + .setDesc('Markdown content with {{variables}}') + .addTextArea(t => { + t.setValue(this.draft.bodyTemplate ?? '') + .setPlaceholder('# {{title}}\n\n') + .onChange(v => { this.draft.bodyTemplate = v; }); + t.inputEl.rows = 6; + t.inputEl.addClass('ff-builder-textarea'); + }); + } else { + new Setting(contentEl) + .setName('Target file') + .addDropdown(d => d + .addOption('active', 'Active file') + .addOption('prompt', 'Prompt to choose') + .setValue(this.draft.targetFile ?? 'active') + .onChange(v => { this.draft.targetFile = v as 'active' | 'prompt'; })); + } + + // --- Fields --- + const fieldsSection = contentEl.createDiv({ cls: 'ff-builder-fields' }); + fieldsSection.createEl('h3', { text: 'Fields' }); + + for (let i = 0; i < this.draft.fields.length; i++) { + this.renderFieldEditor(fieldsSection, i); + } + + // Add field button + const addBtn = fieldsSection.createEl('button', { + text: '+ Add field', + cls: 'ff-builder-add-btn', + }); + addBtn.addEventListener('click', () => { + this.draft.fields.push({ + id: `field_${Date.now()}`, + label: '', + type: 'text', + required: false, + }); + this.render(); + }); + + // --- Footer --- + const footer = contentEl.createDiv({ cls: 'ff-builder-footer' }); + + const cancelBtn = footer.createEl('button', { text: 'Cancel' }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = footer.createEl('button', { text: 'Save', cls: 'mod-cta' }); + saveBtn.addEventListener('click', () => { + if (!this.draft.name.trim()) { + new Notice('Form name is required.'); + return; + } + this.onSave(this.draft); + this.close(); + }); + } + + private renderFieldEditor(container: HTMLElement, index: number): void { + const field = this.draft.fields[index]; + const fieldEl = container.createDiv({ cls: 'ff-builder-field' }); + + // Header row with reorder/delete + const headerRow = fieldEl.createDiv({ cls: 'ff-builder-field-header' }); + headerRow.createSpan({ + cls: 'ff-builder-field-num', + text: `#${index + 1}`, + }); + + const actions = headerRow.createDiv({ cls: 'ff-builder-field-actions' }); + + if (index > 0) { + const upBtn = actions.createEl('button', { text: '\u2191', cls: 'ff-builder-action-btn' }); + upBtn.addEventListener('click', () => { + [this.draft.fields[index - 1], this.draft.fields[index]] = + [this.draft.fields[index], this.draft.fields[index - 1]]; + this.render(); + }); + } + + if (index < this.draft.fields.length - 1) { + const downBtn = actions.createEl('button', { text: '\u2193', cls: 'ff-builder-action-btn' }); + downBtn.addEventListener('click', () => { + [this.draft.fields[index], this.draft.fields[index + 1]] = + [this.draft.fields[index + 1], this.draft.fields[index]]; + this.render(); + }); + } + + const deleteBtn = actions.createEl('button', { text: '\u00d7', cls: 'ff-builder-action-btn ff-builder-delete' }); + deleteBtn.addEventListener('click', () => { + this.draft.fields.splice(index, 1); + this.render(); + }); + + // Field settings + new Setting(fieldEl) + .setName('Label') + .addText(t => t + .setValue(field.label) + .setPlaceholder('Display name') + .onChange(v => { field.label = v; })); + + new Setting(fieldEl) + .setName('Property key') + .setDesc('Frontmatter property name') + .addText(t => t + .setValue(field.id) + .setPlaceholder('e.g. title, author, status') + .onChange(v => { field.id = v; })); + + new Setting(fieldEl) + .setName('Type') + .addDropdown(d => { + for (const ft of FIELD_TYPES) { + d.addOption(ft.value, ft.label); + } + d.setValue(field.type) + .onChange(v => { + field.type = v as FieldType; + this.render(); + }); + }); + + new Setting(fieldEl) + .setName('Required') + .addToggle(t => t + .setValue(field.required) + .onChange(v => { field.required = v; })); + + // Type-specific settings + if (field.type === 'dropdown' || field.type === 'tags') { + new Setting(fieldEl) + .setName('Options') + .setDesc('Comma-separated list') + .addText(t => t + .setValue((field.options ?? []).join(', ')) + .setPlaceholder('Option A, Option B, Option C') + .onChange(v => { + field.options = v.split(',').map(s => s.trim()).filter(Boolean); + })); + } + + if (field.type === 'note-link') { + new Setting(fieldEl) + .setName('Restrict to folder') + .addText(t => t + .setValue(field.folder ?? '') + .setPlaceholder('Leave empty for all notes') + .onChange(v => { field.folder = v || undefined; })); + } + + new Setting(fieldEl) + .setName('Placeholder') + .addText(t => t + .setValue(field.placeholder ?? '') + .onChange(v => { field.placeholder = v || undefined; })); + } + + onClose(): void { + this.contentEl.empty(); + } +} +``` + +**Step 2: Write settings-tab.ts** + +```typescript +import { App, PluginSettingTab, Setting } from 'obsidian'; +import type FormfirePlugin from '../main'; +import { FormBuilderModal } from './form-builder'; + +export class FormfireSettingTab extends PluginSettingTab { + constructor(app: App, private plugin: FormfirePlugin) { + super(app, plugin); + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl('h1', { text: 'Formfire' }); + containerEl.createEl('p', { + text: 'Create forms for structured data input. Forms can create new notes or update existing frontmatter.', + cls: 'setting-item-description', + }); + + // Form list + const forms = this.plugin.formStore.getAll(); + + if (forms.length === 0) { + containerEl.createDiv({ + cls: 'ff-settings-empty', + text: 'No forms yet. Click the button below to create one.', + }); + } + + for (const form of forms) { + new Setting(containerEl) + .setName(form.name) + .setDesc(`${form.mode === 'create' ? 'Creates new note' : 'Updates frontmatter'} \u2022 ${form.fields.length} field(s)`) + .addButton(btn => btn + .setButtonText('Edit') + .onClick(() => { + new FormBuilderModal(this.app, form, async (updated) => { + this.plugin.formStore.update(form.id, updated); + await this.plugin.saveSettings(); + this.plugin.refreshCommands(); + this.display(); + }).open(); + })) + .addButton(btn => btn + .setButtonText('Duplicate') + .onClick(async () => { + this.plugin.formStore.duplicate(form.id); + await this.plugin.saveSettings(); + this.plugin.refreshCommands(); + this.display(); + })) + .addButton(btn => btn + .setButtonText('Delete') + .setWarning() + .onClick(async () => { + this.plugin.formStore.remove(form.id); + await this.plugin.saveSettings(); + this.plugin.refreshCommands(); + this.display(); + })); + } + + // Add form button + new Setting(containerEl) + .addButton(btn => btn + .setButtonText('+ New Form') + .setCta() + .onClick(() => { + const blank = this.plugin.formStore.createBlank(); + new FormBuilderModal(this.app, blank, async (created) => { + this.plugin.formStore.add(created); + await this.plugin.saveSettings(); + this.plugin.refreshCommands(); + this.display(); + }).open(); + })); + } +} +``` + +**Step 3: Verify it compiles** + +Run: `npx tsc --noEmit` +Expected: May error on `FormfirePlugin` references — that's fine, wired up in Task 12. + +**Step 4: Commit** + +```bash +git add src/ui/settings-tab.ts src/ui/form-builder.ts +git commit -m "feat: add settings tab with visual form builder" +``` + +--- + +### Task 12: Wire Everything in main.ts + +**Files:** +- Modify: `src/main.ts` + +This is the final wiring task. Replace the minimal main.ts with the full plugin class. + +**Step 1: Write the complete main.ts** + +```typescript +import { Plugin, Notice, WorkspaceLeaf } from 'obsidian'; +import { FormfireSettings, DEFAULT_SETTINGS, deepMerge } from './types'; +import { FormStore } from './core/form-store'; +import { FormModal } from './ui/form-modal'; +import { FormPickerModal } from './ui/form-picker-modal'; +import { FormfireSettingTab } from './ui/settings-tab'; +import { FormSidebarView, FORM_SIDEBAR_VIEW_TYPE } from './ui/form-sidebar'; + +export default class FormfirePlugin extends Plugin { + settings!: FormfireSettings; + formStore!: FormStore; + + private dynamicCommandIds: string[] = []; + + async onload(): Promise { + console.log('[Formfire] Loading plugin...'); + + await this.loadSettings(); + this.formStore = new FormStore(this.settings); + + // Settings tab + this.addSettingTab(new FormfireSettingTab(this.app, this)); + + // Sidebar view + this.registerView( + FORM_SIDEBAR_VIEW_TYPE, + (leaf) => new FormSidebarView(leaf, this), + ); + + // Ribbon icon + this.addRibbonIcon('file-input', 'Formfire: Open form', () => { + this.openFormPicker(); + }); + + // Static commands + this.addCommand({ + id: 'open-form', + name: 'Open form', + callback: () => this.openFormPicker(), + }); + + this.addCommand({ + id: 'open-sidebar', + name: 'Open sidebar', + callback: () => this.activateSidebar(), + }); + + // Dynamic per-form commands + this.registerDynamicCommands(); + } + + onunload(): void { + console.log('[Formfire] Unloading plugin.'); + } + + async loadSettings(): Promise { + const saved = (await this.loadData()) as Partial | null; + this.settings = deepMerge(DEFAULT_SETTINGS, saved ?? {}); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + this.refreshSidebar(); + } + + /** Open the form picker, then the chosen form. */ + openFormPicker(): void { + const forms = this.formStore.getAll(); + if (forms.length === 0) { + new Notice('No forms defined. Open Formfire settings to create one.'); + return; + } + if (forms.length === 1) { + this.openForm(forms[0].id); + return; + } + new FormPickerModal(this.app, forms, (form) => { + this.openForm(form.id); + }).open(); + } + + /** Open a specific form by ID. */ + openForm(formId: string): void { + const form = this.formStore.getById(formId); + if (!form) { + new Notice('Form not found.'); + return; + } + new FormModal(this.app, form).open(); + } + + /** Register a command for each form for direct access. */ + registerDynamicCommands(): void { + for (const form of this.formStore.getAll()) { + const commandId = `open-form-${form.id}`; + this.addCommand({ + id: commandId, + name: `Open form: ${form.name}`, + callback: () => this.openForm(form.id), + }); + this.dynamicCommandIds.push(commandId); + } + } + + /** Called after form CRUD to re-register commands. */ + refreshCommands(): void { + // Obsidian doesn't support removing commands at runtime. + // New commands are registered on next plugin reload. + // Removed forms' commands become no-ops (form not found). + } + + /** Activate or reveal the sidebar view. */ + async activateSidebar(): Promise { + const existing = this.app.workspace.getLeavesOfType(FORM_SIDEBAR_VIEW_TYPE); + if (existing.length > 0) { + this.app.workspace.revealLeaf(existing[0]); + return; + } + const leaf = this.app.workspace.getRightLeaf(false); + if (leaf) { + await leaf.setViewState({ type: FORM_SIDEBAR_VIEW_TYPE, active: true }); + this.app.workspace.revealLeaf(leaf); + } + } + + /** Re-render sidebar if it's open. */ + private refreshSidebar(): void { + const leaves = this.app.workspace.getLeavesOfType(FORM_SIDEBAR_VIEW_TYPE); + for (const leaf of leaves) { + const view = leaf.view; + if (view instanceof FormSidebarView) { + view.render(); + } + } + } +} +``` + +**Step 2: Verify full build** + +Run: `npm run build` +Expected: `main.js` created, no errors. + +**Step 3: Commit** + +```bash +git add src/main.ts +git commit -m "feat: wire up main plugin with commands, sidebar, settings" +``` + +--- + +### Task 13: Styles + +**Files:** +- Create: `styles.css` + +Use the **frontend-design skill** for polished, production-grade CSS. The CSS must cover these class selectors (all prefixed `ff-`): + +**Form fields:** `.ff-field`, `.ff-field-label`, `.ff-field-required`, `.ff-field-control`, `.ff-field-error`, `.ff-input`, `.ff-textarea`, `.ff-dropdown` + +**Tags field:** `.ff-tags-container`, `.ff-tag-chip`, `.ff-tag-remove`, `.ff-tags-input` + +**Autocomplete:** `.ff-suggest-list`, `.ff-suggest-item` + +**Rating:** `.ff-rating`, `.ff-star`, `.ff-star-active` + +**Form modal:** `.ff-form-modal`, `.ff-fields`, `.ff-form-footer` + +**Form builder:** `.ff-builder-modal`, `.ff-builder-fields`, `.ff-builder-field`, `.ff-builder-field-header`, `.ff-builder-field-actions`, `.ff-builder-action-btn`, `.ff-builder-delete`, `.ff-builder-add-btn`, `.ff-builder-footer`, `.ff-builder-textarea`, `.ff-builder-field-num` + +**Sidebar:** `.ff-sidebar`, `.ff-sidebar-header`, `.ff-sidebar-settings`, `.ff-sidebar-list`, `.ff-sidebar-item`, `.ff-sidebar-badge`, `.ff-badge-create`, `.ff-badge-update`, `.ff-sidebar-name`, `.ff-sidebar-empty` + +**Settings:** `.ff-settings-empty` + +All colors must use Obsidian CSS variables (`--background-primary`, `--text-normal`, `--interactive-accent`, `--background-modifier-border`, `--text-muted`, `--text-faint`, `--text-error`, `--background-modifier-hover`, `--background-secondary`). + +**Step 1: Invoke frontend-design skill to create styles.css** + +**Step 2: Verify build with styles** + +Run: `npm run build` +Expected: No errors. `styles.css` is picked up by Obsidian directly (not bundled by esbuild). + +**Step 3: Commit** + +```bash +git add styles.css +git commit -m "feat: add Formfire styles" +``` + +--- + +### Task 14: Manual Integration Test + +**No code to write.** This is a verification task. + +**Step 1: Install plugin in Obsidian vault** + +Copy `main.js`, `manifest.json`, and `styles.css` to `.obsidian/plugins/formfire/` in a test vault. + +**Step 2: Enable plugin** + +Open Obsidian -> Settings -> Community Plugins -> Enable Formfire. + +**Step 3: Test form creation flow** + +1. Open Formfire settings +2. Click "+ New Form" +3. Set name: "Meeting Note" +4. Mode: Create +5. Target folder: "meetings" +6. File name template: `{{date}}-{{title}}` +7. Add fields: title (text, required), date (date), attendees (tags), status (dropdown: planned/done/cancelled), notes (textarea) +8. Body template: `# {{title}}\n\n## Attendees\n{{attendees}}\n\n## Notes\n` +9. Save + +**Step 4: Test form submission** + +1. Open command palette -> "Formfire: Open form: Meeting Note" +2. Fill out the form +3. Submit +4. Verify: new note created in meetings/ with correct frontmatter and body + +**Step 5: Test update mode** + +1. Create a second form "Update Status" with mode: update, targetFile: active +2. Open a note, run the command +3. Verify: frontmatter properties updated correctly + +**Step 6: Test sidebar** + +1. Open command palette -> "Formfire: Open sidebar" +2. Verify: sidebar shows both forms +3. Click a form -> verify modal opens + +**Step 7: Commit (if any fixes needed)** + +```bash +git add -A +git commit -m "fix: integration test fixes" +``` + +--- + +## Summary + +| Task | Component | Size | +|------|-----------|------| +| 1 | Project scaffolding | Small | +| 2 | Type definitions | Small | +| 3 | Template engine | Small | +| 4 | Validators | Small | +| 5 | FormStore | Small | +| 6 | FormProcessor | Medium | +| 7 | Field renderers (10 types) | Large | +| 8 | FormModal | Medium | +| 9 | FormPickerModal | Small | +| 10 | Sidebar view | Medium | +| 11 | Settings tab + form builder | Large | +| 12 | Main plugin wiring | Medium | +| 13 | Styles (frontend-design skill) | Medium | +| 14 | Manual integration test | Medium |