obsidian-formfire/docs/plans/2026-02-13-formfire-implementation.md
tolvitty 5945a93ef3 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 <noreply@anthropic.com>
2026-02-13 13:07:08 +01:00

2080 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<void> {
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<T extends Record<string, unknown>>(
target: T,
source: Partial<T>,
): 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<string, unknown>,
srcVal as Record<string, unknown>,
) 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, string>,
): string {
const now = new Date();
const builtins: Record<string, string> = {
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<string, unknown>,
): 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<FormDefinition>): 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<string, unknown>,
targetFile?: TFile,
): Promise<TFile> {
if (form.mode === 'create') {
return this.createNote(form, values);
} else {
return this.updateNote(form, values, targetFile);
}
}
private async createNote(
form: FormDefinition,
values: Record<string, unknown>,
): Promise<TFile> {
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<string, unknown>,
targetFile?: TFile,
): Promise<TFile> {
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, unknown>,
): 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<string, unknown>): Record<string, string> {
const result: Record<string, string> = {};
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<void> {
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<string, RenderedField> = 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<void> {
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<void> {
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<string, unknown> = {};
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<void> {
// Collect values
const values: Record<string, unknown> = {};
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<TFile | null> {
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<TFile> {
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<FormDefinition> {
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<void> {
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<void> {
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<void> {
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<void> {
const saved = (await this.loadData()) as Partial<FormfireSettings> | null;
this.settings = deepMerge(DEFAULT_SETTINGS, saved ?? {});
}
async saveSettings(): Promise<void> {
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<void> {
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 |