diff --git a/AGENTS.md b/AGENTS.md index 87d4fc21..b4aabcd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ This file provides guidance to Claude Code and other agents when working with co ## Project Overview -Flow is an Obsidian plugin implementing GTD (Getting Things Done) with AI-powered inbox processing. It uses Claude or OpenAI-compatible models to categorise inbox items into projects, next actions, reference material, someday/maybe items, and person notes. +Flow is an Obsidian plugin implementing GTD (Getting Things Done). It provides manual inbox processing, project management with hierarchical spheres, focus lists, waiting-for tracking, and someday/maybe items. LLM integration (via OpenRouter) is used for cover image generation. ## Commands @@ -15,7 +15,6 @@ npm run test:watch # Tests in watch mode npm run test:coverage # Coverage report (80% threshold) npm run format # Format with Prettier npm run format:check # Check formatting without modifying -npm run evaluate # Run AI evaluations (requires Anthropic credentials) npm run release # Interactive production release workflow npm run release:beta # Interactive beta release workflow ``` @@ -30,14 +29,32 @@ Before declaring any task complete, always run: ## Architecture -### Core Processing Flow +### Scanners -1. **Flow Scanner** (`src/flow-scanner.ts`) - Scans vault for projects (files with `project/*` tags in frontmatter) -2. **Person Scanner** (`src/person-scanner.ts`) - Scans for person notes (files with `person` tag) -3. **Inbox Scanner** (`src/inbox-scanner.ts`) - Scans inbox folders for items to process -4. **GTD Processor** (`src/gtd-processor.ts`) - AI analysis with context from existing projects/people -5. **LLM Factory** (`src/llm-factory.ts`) - Factory for Anthropic/OpenAI-compatible clients -6. **File Writer** (`src/file-writer.ts`) - Creates/updates project files with Flow frontmatter +- **Flow Scanner** (`src/flow-scanner.ts`) - Scans vault for projects (files with `project/*` tags in frontmatter) +- **Person Scanner** (`src/person-scanner.ts`) - Scans for person notes (files with `person` tag) +- **Inbox Scanner** (`src/inbox-scanner.ts`) - Scans inbox folders for items to process +- **Waiting For Scanner** (`src/waiting-for-scanner.ts`) - Scans for `[w]` items across vault +- **Someday Scanner** (`src/someday-scanner.ts`) - Scans for someday/maybe items +- **GTD Context Scanner** (`src/gtd-context-scanner.ts`) - Scans vault for comprehensive GTD system state + +### Inbox Processing + +Inbox processing is manual and UI-driven (no AI involvement): + +- **InboxProcessingView** (`src/inbox-processing-view.ts`) - Full Obsidian tab view for processing +- **InboxProcessingController** (`src/inbox-processing-controller.ts`) - Coordinates the processing workflow +- **InboxItemPersistence** (`src/inbox-item-persistence.ts`) - Saves processed items to vault +- **File Writer** (`src/file-writer.ts`) - Creates/updates project files with Flow frontmatter + +Supporting UI: `src/inbox-modal-state.ts`, `src/inbox-modal-utils.ts`, `src/inbox-modal-views.ts`, `src/inbox-types.ts` + +### Project Management + +- **Project Hierarchy** (`src/project-hierarchy.ts`) - Builds/manages hierarchical project relationships +- **Project Filters** (`src/project-filters.ts`) - Filtering utilities (live projects, templates) +- **Sphere Data Loader** (`src/sphere-data-loader.ts`) - Loads and filters sphere data +- **System Analyzer** (`src/system-analyzer.ts`) - Detects GTD system issues (stalled projects, large inboxes) ### Key Domain Concepts @@ -90,14 +107,17 @@ Project description and context. - **FocusView** (`src/focus-view.ts`) - Curated action list with pinning and reordering - **WaitingForView** (`src/waiting-for-view.ts`) - Aggregated `[w]` items across vault - **SomedayView** (`src/someday-view.ts`) - Someday/maybe items -- **FlowCoachView** (`src/flow-coach-view.ts`) - Chat interface for GTD coaching (incomplete, not currently enabled) +- **InboxProcessingView** (`src/inbox-processing-view.ts`) - Inbox processing interface +- **RefreshingView** (`src/refreshing-view.ts`) - Base class for auto-refreshing views ### Focus System -- Items stored in `flow-focus-data/focus.md` as JSON with: file, lineNumber, lineContent, text, sphere, isPinned, completedAt +- Items stored in `flow-focus-data/focus.md` as JSONL (one JSON object per line, sync-friendly) +- **FocusPersistence** (`src/focus-persistence.ts`) - Reads/writes focus items in JSONL format - **ActionLineFinder** (`src/action-line-finder.ts`) - Finds exact line numbers for actions - **FocusValidator** (`src/focus-validator.ts`) - Validates items when source files change - **FocusAutoClear** (`src/focus-auto-clear.ts`) - Automatic daily clearing at configured time +- **WaitingForValidator** (`src/waiting-for-validator.ts`) - Validates/resolves waiting-for items ### Task Status Cycling @@ -150,7 +170,8 @@ All source files start with two ABOUTME lines: ### LLM Integration -- Default provider: OpenAI-compatible (OpenRouter) -- Fallback: Anthropic Claude -- British English for all AI responses -- Structured JSON output from Claude +LLM is used only for cover image generation, not for inbox processing: + +- **LLM Factory** (`src/llm-factory.ts`) - Creates LLM clients +- **Cover Image Generator** (`src/cover-image-generator.ts`) - Generates project cover images via OpenRouter +- Provider: OpenAI-compatible (OpenRouter) diff --git a/docs/plans/2026-02-16-create-person-command-design.md b/docs/plans/2026-02-16-create-person-command-design.md new file mode 100644 index 00000000..653f7ca8 --- /dev/null +++ b/docs/plans/2026-02-16-create-person-command-design.md @@ -0,0 +1,49 @@ +# Create Person Command + +## Summary + +Add a "Create person" command to the Flow plugin so users can scaffold person notes via the command palette, mirroring the existing "Create new project" command pattern. + +Prompted by [Discussion #35](https://github.com/tavva/flow/discussions/35). + +## Components + +### 1. Settings additions + +- `personsFolderPath: string` (default `"People"`) — folder where person notes are created +- `personTemplateFilePath: string` (default `"Templates/Person.md"`) — path to person template file + +Both configurable in the settings tab. + +### 2. Default person template + +```markdown +--- +creation-date: {{ date }}T{{ time }} +tags: person +--- + +## Discuss next +``` + +Template variables: `{{ date }}` (YYYY-MM-DD), `{{ time }}` (HH:MM:00) — same as the project template. + +### 3. NewPersonModal + +A simple modal with: +- Name text input (required) +- Creates file at `{personsFolderPath}/{sanitizedName}.md` +- Reads template from `personTemplateFilePath`, falls back to hardcoded default if template file missing +- Replaces `{{ name }}`, `{{ date }}`, `{{ time }}` template variables +- Errors if file already exists +- Opens the file after creation + +### 4. Command registration + +`addCommand` in `main.ts` with id `create-person`, name `Create person`, wired to open `NewPersonModal`. Same pattern as "Create new project". + +## What's excluded + +- No sphere/priority/status — person notes don't use these +- No AI integration +- No pre-population of "Discuss next" items diff --git a/docs/plans/2026-02-16-create-person-command.md b/docs/plans/2026-02-16-create-person-command.md new file mode 100644 index 00000000..866079a2 --- /dev/null +++ b/docs/plans/2026-02-16-create-person-command.md @@ -0,0 +1,649 @@ +# Create Person Command Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a "Create person" command that scaffolds person notes with template support, mirroring the existing "Create new project" pattern. + +**Architecture:** New settings (`personsFolderPath`, `personTemplateFilePath`) feed a `NewPersonModal` that reads a template, replaces variables, creates the file, and opens it. `FileWriter` gains a `createPerson` method for the file creation logic (template reading, variable substitution, fallback). + +**Tech Stack:** Obsidian API (Modal, Setting, TFile, normalizePath), existing FileWriter patterns, Jest for testing. + +--- + +### Task 1: Add settings fields + +**Files:** +- Modify: `src/types/settings.ts` + +**Step 1: Add the two new fields to `PluginSettings` interface** + +Add after `projectTemplateFilePath` (line 23): + +```typescript + personsFolderPath: string; + personTemplateFilePath: string; +``` + +**Step 2: Add defaults to `DEFAULT_SETTINGS`** + +Add after `projectTemplateFilePath: "Templates/Project.md"` (line 56): + +```typescript + personsFolderPath: "People", + personTemplateFilePath: "Templates/Person.md", +``` + +**Step 3: Run build to verify types** + +Run: `npm run build` +Expected: PASS (no type errors) + +**Step 4: Commit** + +```bash +git add src/types/settings.ts +git commit -m "Add personsFolderPath and personTemplateFilePath settings" +``` + +--- + +### Task 2: Add settings UI + +**Files:** +- Modify: `src/settings-tab.ts` + +**Step 1: Add "People Folder" setting** + +Add after the "Project Template File" setting block (after line 372), following the same pattern as "Projects Folder" (lines 342-355): + +```typescript + // People Folder + new Setting(containerEl) + .setName("People Folder") + .setDesc("Folder where new person notes will be created.") + .addText((text) => { + text + .setPlaceholder("People") + .setValue(this.plugin.settings.personsFolderPath) + .onChange(async (value) => { + this.plugin.settings.personsFolderPath = value; + await this.plugin.saveSettings(); + }); + new FolderPathSuggest(this.app, text.inputEl); + }); + + // Person Template File + new Setting(containerEl) + .setName("Person Template File") + .setDesc( + "Template file used when creating new person notes. Supports {{date}}, {{time}}, and {{name}} variables." + ) + .addText((text) => { + text + .setPlaceholder("Templates/Person.md") + .setValue(this.plugin.settings.personTemplateFilePath) + .onChange(async (value) => { + this.plugin.settings.personTemplateFilePath = value; + await this.plugin.saveSettings(); + }); + new FilePathSuggest(this.app, text.inputEl, ["md"]); + }); +``` + +**Step 2: Run build to verify** + +Run: `npm run build` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/settings-tab.ts +git commit -m "Add People Folder and Person Template settings to settings tab" +``` + +--- + +### Task 3: Add `createPerson` to FileWriter (TDD) + +**Files:** +- Create: `tests/file-writer-create-person.test.ts` +- Modify: `src/file-writer.ts` + +**Step 1: Write failing tests** + +Create `tests/file-writer-create-person.test.ts`: + +```typescript +// ABOUTME: Tests for FileWriter.createPerson method +// ABOUTME: Verifies person note creation with template support and fallback + +import { App, TFile } from "obsidian"; +import { FileWriter } from "../src/file-writer"; +import { DEFAULT_SETTINGS, PluginSettings } from "../src/types"; + +describe("FileWriter.createPerson", () => { + let app: App; + let settings: PluginSettings; + let fileWriter: FileWriter; + + beforeEach(() => { + app = new App(); + settings = { ...DEFAULT_SETTINGS }; + fileWriter = new FileWriter(app, settings); + + // Default: no existing file, no template file + (app.vault.getAbstractFileByPath as jest.Mock).mockReturnValue(null); + (app.vault.create as jest.Mock).mockImplementation((path: string, content: string) => { + const file = new TFile(); + file.path = path; + file.basename = path.split("/").pop()?.replace(".md", "") || ""; + return Promise.resolve(file); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create person note in configured folder with fallback template", async () => { + const file = await fileWriter.createPerson("Alice Smith"); + + expect(app.vault.createFolder).toHaveBeenCalledWith("People"); + expect(app.vault.create).toHaveBeenCalledWith( + "People/Alice Smith.md", + expect.stringContaining("tags: person") + ); + expect(file.path).toBe("People/Alice Smith.md"); + }); + + it("should include creation-date in fallback template", async () => { + await fileWriter.createPerson("Bob"); + + const content = (app.vault.create as jest.Mock).mock.calls[0][1]; + expect(content).toMatch(/creation-date: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00/); + }); + + it("should include Discuss next section in fallback template", async () => { + await fileWriter.createPerson("Bob"); + + const content = (app.vault.create as jest.Mock).mock.calls[0][1]; + expect(content).toContain("## Discuss next"); + }); + + it("should use template file when available", async () => { + const templateFile = new TFile(); + templateFile.path = "Templates/Person.md"; + + (app.vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => { + if (path === "Templates/Person.md") return templateFile; + return null; + }); + (app.vault.read as jest.Mock).mockResolvedValue( + "---\ncreation-date: {{ date }}T{{ time }}\ntags: person\n---\n\nHello {{ name }}\n" + ); + + await fileWriter.createPerson("Alice"); + + const content = (app.vault.create as jest.Mock).mock.calls[0][1]; + expect(content).toContain("Hello Alice"); + expect(content).toMatch(/creation-date: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00/); + expect(content).not.toContain("{{ name }}"); + expect(content).not.toContain("{{ date }}"); + expect(content).not.toContain("{{ time }}"); + }); + + it("should throw if file already exists", async () => { + const existingFile = new TFile(); + existingFile.path = "People/Alice.md"; + + (app.vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => { + if (path === "People/Alice.md") return existingFile; + return null; + }); + + await expect(fileWriter.createPerson("Alice")).rejects.toThrow("already exists"); + }); + + it("should sanitize the filename", async () => { + await fileWriter.createPerson("Alice / Bob"); + + expect(app.vault.create).toHaveBeenCalledWith( + "People/Alice Bob.md", + expect.any(String) + ); + }); + + it("should use configured folder path", async () => { + settings.personsFolderPath = "Contacts"; + fileWriter = new FileWriter(app, settings); + + await fileWriter.createPerson("Alice"); + + expect(app.vault.createFolder).toHaveBeenCalledWith("Contacts"); + expect(app.vault.create).toHaveBeenCalledWith( + "Contacts/Alice.md", + expect.any(String) + ); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm test -- file-writer-create-person` +Expected: FAIL — `createPerson` does not exist on `FileWriter` + +**Step 3: Implement `createPerson` in `FileWriter`** + +Add to `src/file-writer.ts`, after the `createProject` method (after line 62): + +```typescript + /** + * Create a new person note file + */ + async createPerson(name: string): Promise { + const fileName = this.generateFileName(name); + const folderPath = normalizePath(this.settings.personsFolderPath); + await this.ensureFolderExists(folderPath); + const filePath = normalizePath(`${folderPath}/${fileName}.md`); + + const existingFile = this.app.vault.getAbstractFileByPath(filePath); + if (existingFile) { + throw new ValidationError(`File ${filePath} already exists`); + } + + const content = await this.buildPersonContent(name); + const file = await this.app.vault.create(filePath, content); + + return file; + } +``` + +Add the template/fallback methods near the other `build*Content` methods (after `buildProjectContentFallback`, around line 590): + +```typescript + /** + * Build person note content from template or fallback + */ + private async buildPersonContent(name: string): Promise { + const templateFile = this.app.vault.getAbstractFileByPath( + this.settings.personTemplateFilePath + ); + + if (!templateFile || !(templateFile instanceof TFile)) { + return this.buildPersonContentFallback(name); + } + + let templateContent = await this.app.vault.read(templateFile); + + const now = new Date(); + const date = this.formatDate(now); + const time = this.formatTime(now); + + templateContent = templateContent + .replace(/{{\s*date\s*}}/g, date) + .replace(/{{\s*time\s*}}/g, time) + .replace(/{{\s*name\s*}}/g, name); + + return templateContent; + } + + /** + * Fallback content when person template file is not available + */ + private buildPersonContentFallback(name: string): string { + const now = new Date(); + const dateTime = this.formatDateTime(now); + + return `---\ncreation-date: ${dateTime}\ntags: person\n---\n\n## Discuss next\n`; + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm test -- file-writer-create-person` +Expected: PASS + +**Step 5: Commit** + +```bash +git add tests/file-writer-create-person.test.ts src/file-writer.ts +git commit -m "Add FileWriter.createPerson with template support" +``` + +--- + +### Task 4: Create NewPersonModal (TDD) + +**Files:** +- Create: `tests/new-person-modal.test.ts` +- Create: `src/new-person-modal.ts` + +**Step 1: Write failing tests** + +Create `tests/new-person-modal.test.ts`: + +```typescript +// ABOUTME: Tests for the NewPersonModal class +// ABOUTME: Verifies person creation flow and validation logic + +import { App } from "obsidian"; +import { NewPersonModal } from "../src/new-person-modal"; +import { DEFAULT_SETTINGS } from "../src/types"; + +jest.mock("../src/file-writer", () => ({ + FileWriter: jest.fn().mockImplementation(() => ({ + createPerson: jest.fn().mockImplementation((name: string) => { + return Promise.resolve({ + path: `People/${name}.md`, + name: `${name}.md`, + }); + }), + })), +})); + +describe("NewPersonModal", () => { + let mockApp: App; + let modal: NewPersonModal; + + beforeEach(() => { + mockApp = new App(); + (mockApp.workspace.getLeaf as jest.Mock).mockReturnValue({ + openFile: jest.fn().mockResolvedValue(undefined), + }); + + const settings = { ...DEFAULT_SETTINGS }; + modal = new NewPersonModal(mockApp, settings); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("validation", () => { + it("should require person name", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = ""; + + await (modal as any).createPerson(); + + const { FileWriter } = require("../src/file-writer"); + const writerInstance = FileWriter.mock.results[0].value; + expect(writerInstance.createPerson).not.toHaveBeenCalled(); + }); + + it("should reject names with only invalid characters", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = "///"; + + await (modal as any).createPerson(); + + const { FileWriter } = require("../src/file-writer"); + const writerInstance = FileWriter.mock.results[0].value; + expect(writerInstance.createPerson).not.toHaveBeenCalled(); + }); + }); + + describe("person creation", () => { + it("should create person with trimmed name", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = " Alice Smith "; + + await (modal as any).createPerson(); + + const { FileWriter } = require("../src/file-writer"); + const writerInstance = FileWriter.mock.results[0].value; + expect(writerInstance.createPerson).toHaveBeenCalledWith("Alice Smith"); + }); + + it("should open the created file", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = "Alice"; + + await (modal as any).createPerson(); + + const leaf = (mockApp.workspace.getLeaf as jest.Mock).mock.results[0].value; + expect(leaf.openFile).toHaveBeenCalled(); + }); + + it("should close the modal after creation", async () => { + await modal.onOpen(); + const closeSpy = jest.spyOn(modal, "close"); + + const data = (modal as any).data; + data.name = "Alice"; + + await (modal as any).createPerson(); + + expect(closeSpy).toHaveBeenCalled(); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm test -- new-person-modal` +Expected: FAIL — module `../src/new-person-modal` not found + +**Step 3: Implement `NewPersonModal`** + +Create `src/new-person-modal.ts`: + +```typescript +// ABOUTME: Modal for creating a new person note with name input. +// ABOUTME: Uses configurable template file with fallback for person note scaffolding. + +import { App, Modal, Setting } from "obsidian"; +import { PluginSettings } from "./types"; +import { FileWriter } from "./file-writer"; +import { sanitizeFileName } from "./validation"; + +interface NewPersonData { + name: string; +} + +export class NewPersonModal extends Modal { + private settings: PluginSettings; + private fileWriter: FileWriter; + private data: NewPersonData; + + constructor(app: App, settings: PluginSettings) { + super(app); + this.settings = settings; + this.fileWriter = new FileWriter(app, settings); + this.data = { + name: "", + }; + } + + async onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("flow-gtd-new-person-modal"); + this.render(); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } + + private render() { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h2", { text: "Create Person" }); + + new Setting(contentEl) + .setName("Name") + .setDesc("The person's name") + .addText((text) => + text + .setPlaceholder("Enter name...") + .setValue(this.data.name) + .onChange((value) => { + this.data.name = value; + }) + ); + + const buttonContainer = contentEl.createDiv({ cls: "flow-gtd-modal-buttons" }); + buttonContainer.style.display = "flex"; + buttonContainer.style.justifyContent = "flex-end"; + buttonContainer.style.gap = "8px"; + buttonContainer.style.marginTop = "16px"; + + const cancelButton = buttonContainer.createEl("button", { text: "Cancel" }); + cancelButton.addEventListener("click", () => this.close()); + + const createButton = buttonContainer.createEl("button", { + text: "Create Person", + cls: "mod-cta", + }); + createButton.addEventListener("click", () => this.createPerson()); + } + + private async createPerson() { + if (!this.data.name.trim()) { + this.showError("Person name is required"); + return; + } + + const sanitizedName = sanitizeFileName(this.data.name.trim()); + if (sanitizedName.length === 0) { + this.showError( + "Name contains only invalid characters. Please use letters, numbers, or spaces." + ); + return; + } + + try { + const file = await this.fileWriter.createPerson(this.data.name.trim()); + this.close(); + + const leaf = this.app.workspace.getLeaf(false); + await leaf.openFile(file); + } catch (error) { + console.error("Failed to create person:", error); + this.showError( + `Failed to create person: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + private showError(message: string) { + const { contentEl } = this; + const existingError = contentEl.querySelector(".flow-gtd-modal-error"); + if (existingError) { + existingError.remove(); + } + + const errorEl = contentEl.createDiv({ cls: "flow-gtd-modal-error" }); + errorEl.style.color = "var(--text-error)"; + errorEl.style.marginTop = "8px"; + errorEl.style.padding = "8px"; + errorEl.style.backgroundColor = "var(--background-modifier-error)"; + errorEl.style.borderRadius = "4px"; + errorEl.setText(message); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm test -- new-person-modal` +Expected: PASS + +**Step 5: Commit** + +```bash +git add tests/new-person-modal.test.ts src/new-person-modal.ts +git commit -m "Add NewPersonModal for creating person notes" +``` + +--- + +### Task 5: Register command in main.ts + +**Files:** +- Modify: `main.ts` + +**Step 1: Add import** + +Add after the `NewProjectModal` import (line 6): + +```typescript +import { NewPersonModal } from "./src/new-person-modal"; +``` + +**Step 2: Add command registration** + +Add after the "create-project" command block (after line 120): + +```typescript + // Add create person command + this.addCommand({ + id: "create-person", + name: "Create person", + callback: () => { + this.openNewPersonModal(); + }, + }); +``` + +**Step 3: Add the private method** + +Add after `openNewProjectModal` (after line 414): + +```typescript + private openNewPersonModal() { + const modal = new NewPersonModal(this.app, this.settings); + modal.open(); + } +``` + +**Step 4: Run build and all tests** + +Run: `npm run build && npm test` +Expected: PASS + +**Step 5: Commit** + +```bash +git add main.ts +git commit -m "Register create-person command in plugin" +``` + +--- + +### Task 6: Format, build, and verify + +**Step 1: Run formatter** + +Run: `npm run format` + +**Step 2: Run build** + +Run: `npm run build` +Expected: PASS + +**Step 3: Run all tests** + +Run: `npm test` +Expected: PASS, all existing tests still green + +**Step 4: Commit any formatting changes** + +```bash +git add -A && git commit -m "Format code" +``` +(Only if there are changes from formatting.) diff --git a/src/file-writer.ts b/src/file-writer.ts index eb9b8b3a..83b7b786 100644 --- a/src/file-writer.ts +++ b/src/file-writer.ts @@ -293,20 +293,9 @@ export class FileWriter { */ async createPerson(personName: string, discussionItem: string): Promise { const fileName = this.generateFileName(personName); - // Create in the same folder as the person template, or root if not specified - const templatePath = this.settings.personTemplateFilePath; - const templateFolder = templatePath.includes("/") - ? templatePath.substring(0, templatePath.lastIndexOf("/")) - : ""; - const folderPath = templateFolder ? normalizePath(templateFolder) : ""; - - if (folderPath) { - await this.ensureFolderExists(folderPath); - } - - const filePath = folderPath - ? normalizePath(`${folderPath}/${fileName}.md`) - : normalizePath(`${fileName}.md`); + const folderPath = normalizePath(this.settings.personsFolderPath); + await this.ensureFolderExists(folderPath); + const filePath = normalizePath(`${folderPath}/${fileName}.md`); // Check if file already exists const existingFile = this.app.vault.getAbstractFileByPath(filePath); @@ -316,6 +305,7 @@ export class FileWriter { const content = await this.buildPersonContent(personName); const file = await this.app.vault.create(filePath, content); + await this.processWithTemplater(file); // Create PersonNote structure for the new file const newPerson: PersonNote = { diff --git a/src/focus-view.ts b/src/focus-view.ts index 73688cf3..cad86c2d 100644 --- a/src/focus-view.ts +++ b/src/focus-view.ts @@ -1,7 +1,7 @@ // ABOUTME: Leaf view displaying curated focus of next actions from across the vault. // ABOUTME: Allows marking items complete, converting to waiting-for, or removing from list. -import { WorkspaceLeaf, TFile, setIcon } from "obsidian"; +import { WorkspaceLeaf, TFile, setIcon, MarkdownRenderer } from "obsidian"; import { getAPI } from "obsidian-dataview"; import { FocusItem, PluginSettings, FlowProject } from "./types"; import { FocusValidator, ValidationResult } from "./focus-validator"; @@ -367,7 +367,7 @@ export class FocusView extends RefreshingView { cls: "flow-gtd-focus-items flow-gtd-focus-pinned-items", }); pinnedItems.forEach((item) => { - this.renderPinnedItem(pinnedList, item); + void this.renderPinnedItem(pinnedList, item); }); } @@ -488,7 +488,7 @@ export class FocusView extends RefreshingView { const itemsList = fileSection.createEl("ul", { cls: "flow-gtd-focus-items" }); items.forEach((item) => { - this.renderCompletedItem(itemsList, item); + void this.renderCompletedItem(itemsList, item); }); } @@ -502,7 +502,7 @@ export class FocusView extends RefreshingView { const itemsList = sphereSection.createEl("ul", { cls: "flow-gtd-focus-items" }); items.forEach((item) => { - this.renderCompletedItem(itemsList, item); + void this.renderCompletedItem(itemsList, item); }); } @@ -537,7 +537,7 @@ export class FocusView extends RefreshingView { const itemsList = fileSection.createEl("ul", { cls: "flow-gtd-focus-items" }); items.forEach((item) => { - this.renderItem(itemsList, item); + void this.renderItem(itemsList, item); }); } @@ -551,11 +551,11 @@ export class FocusView extends RefreshingView { const itemsList = sphereSection.createEl("ul", { cls: "flow-gtd-focus-items" }); items.forEach((item) => { - this.renderItem(itemsList, item); + void this.renderItem(itemsList, item); }); } - private renderItem(container: HTMLElement, item: FocusItem) { + private async renderItem(container: HTMLElement, item: FocusItem) { const itemEl = container.createEl("li", { cls: "flow-gtd-focus-item" }); // Check if this is a waiting-for item @@ -572,7 +572,7 @@ export class FocusView extends RefreshingView { } const textSpan = itemEl.createSpan({ cls: "flow-gtd-focus-item-text" }); - textSpan.setText(item.text); + await MarkdownRenderer.renderMarkdown(item.text, textSpan, item.file, this); textSpan.style.cursor = "pointer"; // Gray out waiting-for items @@ -581,8 +581,8 @@ export class FocusView extends RefreshingView { textSpan.style.fontStyle = "italic"; } - textSpan.addEventListener("click", () => { - this.openFile(item.file, item.lineNumber); + textSpan.addEventListener("click", (e) => { + this.handleRenderedTextClick(e, item); }); const actionsSpan = itemEl.createSpan({ cls: "flow-gtd-focus-item-actions" }); @@ -644,7 +644,7 @@ export class FocusView extends RefreshingView { }); } - private renderPinnedItem(container: HTMLElement, item: FocusItem) { + private async renderPinnedItem(container: HTMLElement, item: FocusItem) { const itemEl = container.createEl("li", { cls: "flow-gtd-focus-item flow-gtd-focus-pinned-item", attr: { draggable: "true" }, @@ -688,7 +688,7 @@ export class FocusView extends RefreshingView { } const textSpan = actionRow.createSpan({ cls: "flow-gtd-focus-item-text" }); - textSpan.setText(item.text); + await MarkdownRenderer.renderMarkdown(item.text, textSpan, item.file, this); textSpan.style.cursor = "pointer"; // Gray out waiting-for items @@ -697,8 +697,8 @@ export class FocusView extends RefreshingView { textSpan.style.fontStyle = "italic"; } - textSpan.addEventListener("click", () => { - this.openFile(item.file, item.lineNumber); + textSpan.addEventListener("click", (e) => { + this.handleRenderedTextClick(e, item); }); const actionsSpan = itemEl.createSpan({ cls: "flow-gtd-focus-item-actions" }); @@ -758,7 +758,7 @@ export class FocusView extends RefreshingView { }); } - private renderCompletedItem(container: HTMLElement, item: FocusItem) { + private async renderCompletedItem(container: HTMLElement, item: FocusItem) { const itemEl = container.createEl("li", { cls: "flow-gtd-focus-item flow-gtd-focus-completed", }); @@ -770,13 +770,13 @@ export class FocusView extends RefreshingView { }); const textSpan = itemEl.createSpan({ cls: "flow-gtd-focus-item-text" }); - textSpan.setText(item.text); + await MarkdownRenderer.renderMarkdown(item.text, textSpan, item.file, this); textSpan.style.cursor = "pointer"; textSpan.style.textDecoration = "line-through"; textSpan.style.opacity = "0.6"; - textSpan.addEventListener("click", () => { - this.openFile(item.file, item.lineNumber); + textSpan.addEventListener("click", (e) => { + this.handleRenderedTextClick(e, item); }); // No action buttons for completed items @@ -907,6 +907,45 @@ export class FocusView extends RefreshingView { } } + private handleRenderedTextClick(e: MouseEvent, item: FocusItem): void { + const target = e.target; + if (!(target instanceof HTMLElement)) { + this.openFile(item.file, item.lineNumber); + return; + } + + // Handle clicks on internal links (wikilinks) + const internalLink = target.closest("a.internal-link") as HTMLElement | null; + if (internalLink) { + e.preventDefault(); + const href = internalLink.getAttribute("data-href"); + if (href) { + this.app.workspace.openLinkText(href, item.file); + } + return; + } + + // Handle clicks on tags + const tagLink = target.closest("a.tag") as HTMLElement | null; + if (tagLink) { + e.preventDefault(); + const tag = tagLink.textContent; + if (tag) { + const searchPlugin = (this.app as any).internalPlugins?.getPluginById?.("global-search"); + searchPlugin?.instance?.openGlobalSearch?.(`tag:${tag}`); + } + return; + } + + // Handle clicks on any other link (e.g. external URLs) + if (target.closest("a")) { + return; + } + + // Default: navigate to source file + this.openFile(item.file, item.lineNumber); + } + private async openFile(filePath: string, lineNumber?: number): Promise { const file = this.app.vault.getAbstractFileByPath(filePath); @@ -980,6 +1019,10 @@ export class FocusView extends RefreshingView { if (focusIndex !== -1) { this.focusItems[focusIndex].completedAt = Date.now(); await this.saveFocus(); + (this.app.workspace as any).trigger("flow:action-completed", { + file: item.file, + action: item.text, + }); await this.onOpen(); // Re-render } } @@ -1015,6 +1058,10 @@ export class FocusView extends RefreshingView { if (focusIndex !== -1) { this.focusItems[focusIndex].lineContent = updatedLine; await this.saveFocus(); + (this.app.workspace as any).trigger("flow:action-waiting", { + file: item.file, + action: item.text, + }); await this.onOpen(); // Re-render } } diff --git a/src/new-person-modal.ts b/src/new-person-modal.ts index 1c38b36e..9e33d83b 100644 --- a/src/new-person-modal.ts +++ b/src/new-person-modal.ts @@ -96,13 +96,11 @@ export class NewPersonModal extends Modal { } private async createPerson() { - // Validate required fields if (!this.data.name.trim()) { this.showError("Person name is required"); return; } - // Validate and sanitize the name for file system const sanitizedName = sanitizeFileName(this.data.name.trim()); if (sanitizedName.length === 0) { this.showError( diff --git a/src/project-title-prompt.ts b/src/project-title-prompt.ts deleted file mode 100644 index 25d02dee..00000000 --- a/src/project-title-prompt.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const buildProjectTitlePrompt = ( - originalItem: string -): string => `Given this inbox item: "${originalItem}" - -The user wants to create a project for this. Suggest a clear, concise project title that: -- States the desired outcome (not just the topic) -- Is specific and measurable -- Defines what "done" looks like -- Uses past tense or completion-oriented language when appropriate - -Examples: -- Good: "Website redesign complete and deployed" -- Bad: "Website project" -- Good: "Kitchen renovation finished" -- Bad: "Kitchen stuff" - -Respond with ONLY the project title, nothing else.`; diff --git a/src/settings-tab.ts b/src/settings-tab.ts index fcb4505b..361f8a4d 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -198,19 +198,37 @@ export class FlowGTDSettingTab extends PluginSettingTab { new FilePathSuggest(this.app, text.inputEl, ["md"]); }); + // People Folder + new Setting(containerEl) + .setName("People Folder") + .setDesc("Folder where new person notes will be created.") + .addText((text) => { + text + .setPlaceholder("People") + .setValue(this.plugin.settings.personsFolderPath) + .onChange(async (value) => { + this.plugin.settings.personsFolderPath = value; + await this.plugin.saveSettings(); + }); + new FolderPathSuggest(this.app, text.inputEl); + }); + // Person Template File new Setting(containerEl) .setName("Person Template File") - .setDesc("Template file used when creating new person notes.") - .addText((text) => + .setDesc( + "Template file used when creating new person notes. Supports {{date}}, {{time}}, and {{name}} variables." + ) + .addText((text) => { text .setPlaceholder("Templates/Person.md") .setValue(this.plugin.settings.personTemplateFilePath) .onChange(async (value) => { this.plugin.settings.personTemplateFilePath = value; await this.plugin.saveSettings(); - }) - ); + }); + new FilePathSuggest(this.app, text.inputEl, ["md"]); + }); // Default Inbox File new Setting(containerEl) diff --git a/src/sphere-view.ts b/src/sphere-view.ts index 29977044..d0111503 100644 --- a/src/sphere-view.ts +++ b/src/sphere-view.ts @@ -32,8 +32,10 @@ export class SphereView extends ItemView { private refreshInProgress: boolean = false; private showNextActions: boolean = true; private metadataCacheEventRef: EventRef | null = null; + private workspaceEventRefs: EventRef[] = []; private scheduledRefreshTimeout: ReturnType | null = null; private selectedContexts: string[] = []; + private suppressFocusRefresh: boolean = false; constructor( leaf: WorkspaceLeaf, @@ -77,6 +79,7 @@ export class SphereView extends ItemView { // Register metadata cache listener for automatic refresh when files change this.registerMetadataCacheListener(); + this.registerWorkspaceEvents(); try { const data = await this.loadSphereData(); @@ -119,6 +122,10 @@ export class SphereView extends ItemView { this.app.metadataCache.offref(this.metadataCacheEventRef); this.metadataCacheEventRef = null; } + for (const ref of this.workspaceEventRefs) { + this.app.workspace.offref(ref); + } + this.workspaceEventRefs = []; if (this.scheduledRefreshTimeout) { clearTimeout(this.scheduledRefreshTimeout); this.scheduledRefreshTimeout = null; @@ -176,18 +183,93 @@ export class SphereView extends ItemView { this.app.metadataCache.offref(this.metadataCacheEventRef); } this.metadataCacheEventRef = this.app.metadataCache.on("changed", (file) => { - if (this.isRelevantFile(file)) { + if (file.path === FOCUS_FILE_PATH) { + if (this.suppressFocusRefresh) { + this.suppressFocusRefresh = false; + return; + } + void this.refreshFocusHighlighting(); + } else if (this.isRelevantFile(file)) { this.scheduleAutoRefresh(); } }); } - private isRelevantFile(file: TFile): boolean { - // Refresh when focus file changes (for "in focus" highlighting) - if (file.path === FOCUS_FILE_PATH) { - return true; + private registerWorkspaceEvents(): void { + for (const ref of this.workspaceEventRefs) { + this.app.workspace.offref(ref); } + this.workspaceEventRefs = []; + + const completedRef = (this.app.workspace as any).on( + "flow:action-completed", + (detail: { file: string; action: string }) => { + this.removeActionFromDom(detail.file, detail.action); + } + ); + this.workspaceEventRefs.push(completedRef); + + const waitingRef = (this.app.workspace as any).on( + "flow:action-waiting", + (detail: { file: string; action: string }) => { + void this.markActionWaitingInDom(detail.file, detail.action); + } + ); + this.workspaceEventRefs.push(waitingRef); + } + private removeActionFromDom(file: string, action: string): void { + const container = this.contentEl; + const items = container.querySelectorAll("li[data-focus-file]"); + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if ( + item.getAttribute("data-focus-file") === file && + item.getAttribute("data-focus-action") === action + ) { + item.remove(); + return; + } + } + } + + private async markActionWaitingInDom(file: string, action: string): Promise { + const container = this.contentEl; + const items = container.querySelectorAll("li[data-focus-file]"); + for (let i = 0; i < items.length; i++) { + const item = items[i] as HTMLElement; + if ( + item.getAttribute("data-focus-file") === file && + item.getAttribute("data-focus-action") === action + ) { + item.empty(); + await MarkdownRenderer.renderMarkdown(`🤝 ${action}`, item, "", this); + return; + } + } + } + + private async refreshFocusHighlighting(): Promise { + const focusItems = await loadFocusItems(this.app.vault); + const container = this.contentEl; + const items = container.querySelectorAll("li[data-focus-file]"); + + for (let i = 0; i < items.length; i++) { + const item = items[i] as HTMLElement; + const file = item.getAttribute("data-focus-file"); + const action = item.getAttribute("data-focus-action"); + const inFocus = focusItems.some( + (focusItem) => focusItem.file === file && focusItem.text === action + ); + if (inFocus) { + item.classList.add("sphere-action-in-focus"); + } else { + item.classList.remove("sphere-action-in-focus"); + } + } + } + + private isRelevantFile(file: TFile): boolean { // Refresh when any markdown file in the vault changes that might be a project // We check the metadata cache for project tags const metadata = this.app.metadataCache.getFileCache(file); @@ -360,8 +442,8 @@ export class SphereView extends ItemView { private async refresh(): Promise { const container = this.contentEl; - container.empty(); const data = await this.loadSphereData(); + container.empty(); this.renderContent(container, data); } @@ -664,6 +746,8 @@ export class SphereView extends ItemView { const displayText = isWaitingFor ? `🤝 ${action}` : action; const item = list.createEl("li"); + item.setAttribute("data-focus-file", file); + item.setAttribute("data-focus-action", action); await MarkdownRenderer.renderMarkdown(displayText, item, "", this); item.style.cursor = "pointer"; @@ -821,6 +905,7 @@ export class SphereView extends ItemView { const focusItems = await loadFocusItems(this.app.vault); focusItems.push(item); + this.suppressFocusRefresh = true; await saveFocusItems(this.app.vault, focusItems); await this.activateFocusView(); await this.refreshFocusView(); @@ -840,6 +925,7 @@ export class SphereView extends ItemView { const updatedFocus = focusItems.filter( (item) => !(item.file === file && item.lineNumber === lineNumber) ); + this.suppressFocusRefresh = true; await saveFocusItems(this.app.vault, updatedFocus); await this.activateFocusView(); await this.refreshFocusView(); diff --git a/src/types/settings.ts b/src/types/settings.ts index 867f645b..09ce8aa2 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -15,6 +15,7 @@ export interface PluginSettings { somedayFilePath: string; projectsFolderPath: string; projectTemplateFilePath: string; + personsFolderPath: string; personTemplateFilePath: string; defaultInboxFile: string; // Filename for built-in Flow quick capture (will be created in Flow Inbox Files folder) coverImagesFolderPath: string; // Folder path for generated project cover images @@ -46,6 +47,7 @@ export const DEFAULT_SETTINGS: PluginSettings = { somedayFilePath: "Someday.md", projectsFolderPath: "Projects", projectTemplateFilePath: "Templates/Project.md", + personsFolderPath: "People", personTemplateFilePath: "Templates/Person.md", defaultInboxFile: "Inbox.md", coverImagesFolderPath: "Assets/flow-project-cover-images", diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts index 9cf3bdf1..90bfe369 100644 --- a/tests/__mocks__/obsidian.ts +++ b/tests/__mocks__/obsidian.ts @@ -63,6 +63,8 @@ export class Workspace { detachLeavesOfType = jest.fn(); getActiveFile = jest.fn(); on = jest.fn(() => ({ unload: jest.fn() })); + offref = jest.fn(); + trigger = jest.fn(); onLayoutReady = jest.fn((callback: () => void) => callback()); iterateRootLeaves = jest.fn(); viewRegistry: Record = {}; diff --git a/tests/file-writer-create-person.test.ts b/tests/file-writer-create-person.test.ts new file mode 100644 index 00000000..1c2448c2 --- /dev/null +++ b/tests/file-writer-create-person.test.ts @@ -0,0 +1,106 @@ +// ABOUTME: Tests for FileWriter.createPerson method +// ABOUTME: Verifies person note creation with template support and fallback + +import { App, TFile } from "obsidian"; +import { FileWriter } from "../src/file-writer"; +import { DEFAULT_SETTINGS, PluginSettings } from "../src/types"; + +describe("FileWriter.createPerson", () => { + let app: App; + let settings: PluginSettings; + let fileWriter: FileWriter; + + beforeEach(() => { + app = new App(); + settings = { ...DEFAULT_SETTINGS }; + fileWriter = new FileWriter(app, settings); + + // Default: no existing file, no template file + (app.vault.getAbstractFileByPath as jest.Mock).mockReturnValue(null); + (app.vault.create as jest.Mock).mockImplementation((path: string, content: string) => { + const file = new TFile(); + file.path = path; + file.basename = path.split("/").pop()?.replace(".md", "") || ""; + return Promise.resolve(file); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create person note in configured folder with fallback template", async () => { + const person = await fileWriter.createPerson("Alice Smith", ""); + + expect(app.vault.createFolder).toHaveBeenCalledWith("People"); + expect(app.vault.create).toHaveBeenCalledWith( + "People/Alice Smith.md", + expect.stringContaining("person") + ); + expect(person.file).toBe("People/Alice Smith.md"); + }); + + it("should include creation-date in fallback template", async () => { + await fileWriter.createPerson("Bob", ""); + + const content = (app.vault.create as jest.Mock).mock.calls[0][1]; + expect(content).toMatch(/creation-date: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00/); + }); + + it("should include Discuss next section in fallback template", async () => { + await fileWriter.createPerson("Bob", ""); + + const content = (app.vault.create as jest.Mock).mock.calls[0][1]; + expect(content).toContain("## Discuss next"); + }); + + it("should use template file when available", async () => { + const templateFile = new TFile(); + templateFile.path = "Templates/Person.md"; + + (app.vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => { + if (path === "Templates/Person.md") return templateFile; + return null; + }); + (app.vault.read as jest.Mock).mockResolvedValue( + "---\ncreation-date: {{ date }}T{{ time }}\ntags: person\n---\n\nHello {{ name }}\n" + ); + + await fileWriter.createPerson("Alice", ""); + + const content = (app.vault.create as jest.Mock).mock.calls[0][1]; + expect(content).toContain("Hello Alice"); + expect(content).toMatch(/creation-date: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}/); + expect(content).not.toContain("{{ name }}"); + expect(content).not.toContain("{{ date }}"); + expect(content).not.toContain("{{ time }}"); + }); + + it("should throw if file already exists", async () => { + const existingFile = new TFile(); + existingFile.path = "People/Alice.md"; + + (app.vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => { + if (path === "People/Alice.md") return existingFile; + return null; + }); + + await expect(fileWriter.createPerson("Alice", "")).rejects.toThrow("already exists"); + }); + + it("should sanitize the filename", async () => { + await fileWriter.createPerson("Alice / Bob", ""); + + expect(app.vault.create).toHaveBeenCalledWith("People/Alice Bob.md", expect.any(String)); + }); + + it("should use configured folder path", async () => { + settings.personsFolderPath = "Contacts"; + fileWriter = new FileWriter(app, settings); + + await fileWriter.createPerson("Alice", ""); + + expect(app.vault.createFolder).toHaveBeenCalledWith("Contacts"); + expect(app.vault.create).toHaveBeenCalledWith("Contacts/Alice.md", expect.any(String)); + }); +}); diff --git a/tests/file-writer.test.ts b/tests/file-writer.test.ts index 4560e5fa..467600f1 100644 --- a/tests/file-writer.test.ts +++ b/tests/file-writer.test.ts @@ -26,6 +26,7 @@ describe("FileWriter", () => { somedayFilePath: "Someday.md", projectsFolderPath: "Projects", projectTemplateFilePath: "Templates/Project.md", + personsFolderPath: "People", personTemplateFilePath: "Templates/Person.md", spheres: ["personal", "work"], }; @@ -1851,13 +1852,13 @@ status: live describe("createPerson", () => { it("should create person with empty Discuss next section when discussion item is empty", async () => { - const mockFile = new TFile("Templates/John Doe.md", "John Doe"); + const mockFile = new TFile("People/John Doe.md", "John Doe"); let fileCreated = false; // Mock: template doesn't exist, but after create() the file exists (mockVault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => { if (path === "Templates/Person.md") return null; // no template - if (path === "Templates/John Doe.md" && fileCreated) return mockFile; + if (path === "People/John Doe.md" && fileCreated) return mockFile; return null; }); (mockVault.getMarkdownFiles as jest.Mock).mockReturnValue([]); @@ -1885,12 +1886,12 @@ tags: }); it("should add discussion item when provided", async () => { - const mockFile = new TFile("Templates/Jane Smith.md", "Jane Smith"); + const mockFile = new TFile("People/Jane Smith.md", "Jane Smith"); let fileCreated = false; (mockVault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => { if (path === "Templates/Person.md") return null; // no template - if (path === "Templates/Jane Smith.md" && fileCreated) return mockFile; + if (path === "People/Jane Smith.md" && fileCreated) return mockFile; return null; }); (mockVault.getMarkdownFiles as jest.Mock).mockReturnValue([]); diff --git a/tests/focus-integration.test.ts b/tests/focus-integration.test.ts index fe5acc2f..95bc46f8 100644 --- a/tests/focus-integration.test.ts +++ b/tests/focus-integration.test.ts @@ -311,6 +311,7 @@ describe("Focus Manual Reordering Integration", () => { workspace: { getLeaf: jest.fn(), getLeavesOfType: jest.fn().mockReturnValue([]), + trigger: jest.fn(), }, metadataCache: { on: jest.fn(), diff --git a/tests/focus-view.test.ts b/tests/focus-view.test.ts index 2a7474cd..321587cb 100644 --- a/tests/focus-view.test.ts +++ b/tests/focus-view.test.ts @@ -1,7 +1,7 @@ // tests/focus-view.test.ts import { FocusView, FOCUS_VIEW_TYPE } from "../src/focus-view"; import { FocusItem } from "../src/types"; -import { WorkspaceLeaf } from "obsidian"; +import { WorkspaceLeaf, MarkdownRenderer } from "obsidian"; jest.mock("obsidian"); @@ -46,6 +46,7 @@ describe("FocusView", () => { workspace: { getLeaf: jest.fn(), getLeavesOfType: jest.fn().mockReturnValue([]), + trigger: jest.fn(), }, metadataCache: { on: jest.fn(), @@ -296,6 +297,40 @@ describe("FocusView", () => { (view as any).refreshSphereViews = originalRefreshSphereViews; }); + it("should trigger flow:action-waiting workspace event", async () => { + const item: FocusItem = { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Call client about proposal", + text: "Call client about proposal", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }; + + (view as any).focusItems = [item]; + + const { TFile } = require("obsidian"); + const mockFile = new TFile(); + mockFile.path = "Projects/Test.md"; + + mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile); + mockApp.vault.read.mockResolvedValue( + "line1\nline2\nline3\nline4\n- [ ] Call client about proposal\nline6" + ); + + (view as any).validator = { + validateItem: jest.fn().mockResolvedValue({ found: true, updatedLineNumber: 5 }), + }; + + await (view as any).convertToWaitingFor((view as any).focusItems[0]); + + expect(mockApp.workspace.trigger).toHaveBeenCalledWith("flow:action-waiting", { + file: "Projects/Test.md", + action: "Call client about proposal", + }); + }); + it("should extract checkbox status from line content", () => { const getCheckboxStatusChar = (view as any).getCheckboxStatusChar.bind(view); @@ -1084,6 +1119,37 @@ describe("FocusView", () => { expect(items[0].completedAt).toBeDefined(); expect(items[0].completedAt).toBeGreaterThan(Date.now() - 1000); }); + + it("should trigger flow:action-completed workspace event", async () => { + const mockItem: FocusItem = { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Test action", + text: "Test action", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }; + + const TFile = require("obsidian").TFile; + const mockTFile = Object.create(TFile.prototype); + mockTFile.path = "Projects/Test.md"; + + mockApp.vault.getAbstractFileByPath.mockReturnValue(mockTFile); + mockApp.vault.read.mockResolvedValue("line1\nline2\nline3\nline4\n- [ ] Test action\nline6"); + + (view as any).focusItems = [mockItem]; + (view as any).validator = { + validateItem: jest.fn().mockResolvedValue({ found: true }), + }; + + await (view as any).markItemComplete(mockItem); + + expect(mockApp.workspace.trigger).toHaveBeenCalledWith("flow:action-completed", { + file: "Projects/Test.md", + action: "Test action", + }); + }); }); describe("getMidnightTimestamp", () => { @@ -1390,6 +1456,181 @@ describe("FocusView", () => { }); }); + describe("Clickable links in action text", () => { + let renderSpy: jest.SpyInstance; + + beforeEach(() => { + renderSpy = jest.spyOn(MarkdownRenderer, "renderMarkdown"); + }); + + afterEach(() => { + renderSpy.mockRestore(); + }); + + it("should render action text using MarkdownRenderer for unpinned items", async () => { + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Call [[John]] about #project/alpha", + text: "Call [[John]] about #project/alpha", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }, + ]; + + await view.onOpen(); + + expect(renderSpy).toHaveBeenCalledWith( + "Call [[John]] about #project/alpha", + expect.any(HTMLElement), + "Projects/Test.md", + expect.anything() + ); + }); + + it("should render action text using MarkdownRenderer for pinned items", async () => { + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Review [[PR-42]] notes", + text: "Review [[PR-42]] notes", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + isPinned: true, + }, + ]; + + await view.onOpen(); + + expect(renderSpy).toHaveBeenCalledWith( + "Review [[PR-42]] notes", + expect.any(HTMLElement), + "Projects/Test.md", + expect.anything() + ); + }); + + it("should render action text using MarkdownRenderer for completed items", async () => { + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [x] Email [[Sarah]] ✅ 2026-02-19", + text: "Email [[Sarah]]", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + completedAt: Date.now(), + }, + ]; + + await view.onOpen(); + + expect(renderSpy).toHaveBeenCalledWith( + "Email [[Sarah]]", + expect.any(HTMLElement), + "Projects/Test.md", + expect.anything() + ); + }); + + it("should open linked note when clicking an internal link in action text", async () => { + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Call [[John]]", + text: "Call [[John]]", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }, + ]; + + // Mock workspace.openLinkText + mockApp.workspace.openLinkText = jest.fn(); + + await view.onOpen(); + + // Find the text span in the rendered DOM + const container = (view as any).contentEl as HTMLElement; + const textSpan = container.querySelector(".flow-gtd-focus-item-text") as HTMLElement; + expect(textSpan).toBeTruthy(); + + // Create a fake anchor element inside the text span (simulating a rendered wikilink) + const fakeLink = document.createElement("a"); + fakeLink.className = "internal-link"; + fakeLink.setAttribute("data-href", "John"); + textSpan.appendChild(fakeLink); + + // Spy on openFile to ensure it's NOT called + const openFileSpy = jest.fn(); + (view as any).openFile = openFileSpy; + + // Simulate clicking the link element + const clickEvent = new (window as any).MouseEvent("click", { bubbles: true }); + Object.defineProperty(clickEvent, "target", { value: fakeLink }); + textSpan.dispatchEvent(clickEvent); + + // Should open the linked note, not the source file + expect(openFileSpy).not.toHaveBeenCalled(); + expect(mockApp.workspace.openLinkText).toHaveBeenCalledWith("John", "Projects/Test.md"); + }); + + it("should open tag search when clicking a tag in action text", async () => { + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Buy groceries #context/errands", + text: "Buy groceries #context/errands", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }, + ]; + + // Mock the global search plugin + const mockOpenGlobalSearch = jest.fn(); + (mockApp as any).internalPlugins = { + getPluginById: jest.fn().mockReturnValue({ + instance: { openGlobalSearch: mockOpenGlobalSearch }, + }), + }; + + await view.onOpen(); + + // Find the text span + const container = (view as any).contentEl as HTMLElement; + const textSpan = container.querySelector(".flow-gtd-focus-item-text") as HTMLElement; + expect(textSpan).toBeTruthy(); + + // Create a fake tag element inside the text span + const fakeTag = document.createElement("a"); + fakeTag.className = "tag"; + fakeTag.setAttribute("href", "#context/errands"); + fakeTag.textContent = "#context/errands"; + textSpan.appendChild(fakeTag); + + // Spy on openFile to ensure it's NOT called + const openFileSpy = jest.fn(); + (view as any).openFile = openFileSpy; + + // Simulate clicking the tag element + const clickEvent = new (window as any).MouseEvent("click", { bubbles: true }); + Object.defineProperty(clickEvent, "target", { value: fakeTag }); + textSpan.dispatchEvent(clickEvent); + + // Should open search for the tag, not navigate to source file + expect(openFileSpy).not.toHaveBeenCalled(); + expect(mockOpenGlobalSearch).toHaveBeenCalledWith("tag:#context/errands"); + }); + }); + // Helper function for creating mock focus items const createMockFocusItem = (): FocusItem => ({ file: "test.md", @@ -1402,7 +1643,7 @@ describe("FocusView", () => { }); describe("renderCompletedItem", () => { - it("should render completed item with strikethrough and no actions", () => { + it("should render completed item with strikethrough and no actions", async () => { const mockItem: FocusItem = { file: "test.md", lineNumber: 5, @@ -1450,7 +1691,7 @@ describe("FocusView", () => { } }); - (view as any).renderCompletedItem(container, mockItem); + await (view as any).renderCompletedItem(container, mockItem); const itemEl = container.querySelector(".flow-gtd-focus-completed"); expect(itemEl).toBeTruthy(); diff --git a/tests/new-person-modal.test.ts b/tests/new-person-modal.test.ts new file mode 100644 index 00000000..b97e82c8 --- /dev/null +++ b/tests/new-person-modal.test.ts @@ -0,0 +1,107 @@ +// ABOUTME: Tests for the NewPersonModal class +// ABOUTME: Verifies person creation flow and validation logic + +import { App } from "obsidian"; +import { NewPersonModal } from "../src/new-person-modal"; +import { DEFAULT_SETTINGS } from "../src/types"; + +jest.mock("../src/file-writer", () => ({ + FileWriter: jest.fn().mockImplementation(() => ({ + createPerson: jest.fn().mockImplementation((name: string, _discussionItem: string) => { + return Promise.resolve({ + file: `People/${name}.md`, + title: name, + tags: ["person"], + }); + }), + })), +})); + +describe("NewPersonModal", () => { + let mockApp: App; + let modal: NewPersonModal; + + beforeEach(() => { + mockApp = new App(); + (mockApp.workspace.getLeaf as jest.Mock).mockReturnValue({ + openFile: jest.fn().mockResolvedValue(undefined), + }); + + const settings = { ...DEFAULT_SETTINGS }; + modal = new NewPersonModal(mockApp, settings); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("validation", () => { + it("should require person name", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = ""; + + await (modal as any).createPerson(); + + const { FileWriter } = require("../src/file-writer"); + const writerInstance = FileWriter.mock.results[0].value; + expect(writerInstance.createPerson).not.toHaveBeenCalled(); + }); + + it("should reject names with only invalid characters", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = "///"; + + await (modal as any).createPerson(); + + const { FileWriter } = require("../src/file-writer"); + const writerInstance = FileWriter.mock.results[0].value; + expect(writerInstance.createPerson).not.toHaveBeenCalled(); + }); + }); + + describe("person creation", () => { + it("should create person with trimmed name", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = " Alice Smith "; + + await (modal as any).createPerson(); + + const { FileWriter } = require("../src/file-writer"); + const writerInstance = FileWriter.mock.results[0].value; + expect(writerInstance.createPerson).toHaveBeenCalledWith("Alice Smith", ""); + }); + + it("should open the created file", async () => { + const mockFile = { path: "People/Alice.md" }; + (mockApp.vault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile); + + await modal.onOpen(); + + const data = (modal as any).data; + data.name = "Alice"; + + await (modal as any).createPerson(); + + const leaf = (mockApp.workspace.getLeaf as jest.Mock).mock.results[0].value; + expect(leaf.openFile).toHaveBeenCalledWith(mockFile); + }); + + it("should close the modal after creation", async () => { + await modal.onOpen(); + const closeSpy = jest.spyOn(modal, "close"); + + const data = (modal as any).data; + data.name = "Alice"; + + await (modal as any).createPerson(); + + expect(closeSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/project-title-prompt.test.ts b/tests/project-title-prompt.test.ts deleted file mode 100644 index d5a11464..00000000 --- a/tests/project-title-prompt.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { buildProjectTitlePrompt } from "../src/project-title-prompt"; - -describe("buildProjectTitlePrompt", () => { - it("includes the original inbox item and clear guidance", () => { - const prompt = buildProjectTitlePrompt("Organize files"); - - expect(prompt).toContain("Organize files"); - expect(prompt).toContain("Respond with ONLY the project title"); - expect(prompt.startsWith("Given this inbox item")).toBe(true); - }); -}); diff --git a/tests/sphere-view.test.ts b/tests/sphere-view.test.ts index 032cd06c..c7e9e5a4 100644 --- a/tests/sphere-view.test.ts +++ b/tests/sphere-view.test.ts @@ -19,6 +19,7 @@ jest.mock("../src/focus-persistence", () => ({ mockFocusItems = items; return Promise.resolve(); }), + FOCUS_FILE_PATH: "flow-focus-data/focus.md", })); import { saveFocusItems as mockSaveFocusItems } from "../src/focus-persistence"; @@ -666,6 +667,346 @@ describe("SphereView", () => { }); }); + describe("action item data attributes", () => { + it("should set data-focus-file and data-focus-action on rendered action items", async () => { + mockLineFinder.findActionLine.mockResolvedValue({ + found: true, + lineNumber: 5, + lineContent: "- [ ] Call client about meeting", + }); + + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + // Create a real DOM list element with Obsidian methods + const listEl = document.createElement("ul"); + (listEl as any).createEl = function (tag: string, opts?: any) { + const el = document.createElement(tag); + if (opts?.cls) el.className = opts.cls; + if (opts?.text) el.textContent = opts.text; + this.appendChild(el); + (el as any).createSpan = function (spanOpts?: any) { + const span = document.createElement("span"); + if (spanOpts?.cls) span.className = spanOpts.cls; + if (spanOpts?.text) span.textContent = spanOpts.text; + this.appendChild(span); + return span; + }; + (el as any).addClass = function (cls: string) { + this.classList.add(cls); + }; + return el; + }; + + await (view as any).renderActionItem( + listEl, + "Call client about meeting", + "Projects/Test.md", + "work", + false + ); + + const item = listEl.querySelector("li"); + expect(item).toBeTruthy(); + expect(item!.getAttribute("data-focus-file")).toBe("Projects/Test.md"); + expect(item!.getAttribute("data-focus-action")).toBe("Call client about meeting"); + }); + }); + + describe("focus highlighting on refresh", () => { + it("should update sphere-action-in-focus class on refresh", async () => { + const project = { + file: "Projects/Test.md", + title: "Test Project", + tags: ["project/work"], + status: "live" as const, + priority: 1, + nextActions: ["Call client", "Send email"], + mtime: Date.now(), + }; + + mockScanner.scanProjects.mockResolvedValue([project]); + mockLineFinder.findActionLine.mockResolvedValue({ + found: true, + lineNumber: 5, + lineContent: "- [ ] Call client", + }); + + // Start with no focus items + mockFocusItems = []; + + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const container = view.containerEl.children[1] as HTMLElement; + let items = container.querySelectorAll("li[data-focus-file]"); + expect(items.length).toBe(2); + items.forEach((item) => { + expect(item.classList.contains("sphere-action-in-focus")).toBe(false); + }); + + // Add "Call client" to focus and refresh + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Call client", + text: "Call client", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }, + ]; + + await (view as any).refresh(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + items = container.querySelectorAll("li[data-focus-file]"); + let focusedCount = 0; + items.forEach((item) => { + if (item.classList.contains("sphere-action-in-focus")) { + focusedCount++; + expect(item.getAttribute("data-focus-action")).toBe("Call client"); + } + }); + + expect(focusedCount).toBe(1); + }); + }); + + describe("focus file change handling", () => { + it("should update CSS classes without full refresh when focus file changes", async () => { + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + + const refreshSpy = jest.spyOn(view as any, "scheduleAutoRefresh"); + const highlightSpy = jest.spyOn(view as any, "refreshFocusHighlighting"); + + const metadataCacheOnCall = (app.metadataCache.on as jest.Mock).mock.calls.find( + (call: any[]) => call[0] === "changed" + ); + expect(metadataCacheOnCall).toBeTruthy(); + + const changeHandler = metadataCacheOnCall[1]; + const focusFile = new TFile("flow-focus-data/focus.md"); + changeHandler(focusFile); + + expect(refreshSpy).not.toHaveBeenCalled(); + expect(highlightSpy).toHaveBeenCalled(); + + refreshSpy.mockRestore(); + highlightSpy.mockRestore(); + }); + + it("should suppress CSS update when focus file change originated from this view", async () => { + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + + const highlightSpy = jest.spyOn(view as any, "refreshFocusHighlighting"); + + const metadataCacheOnCall = (app.metadataCache.on as jest.Mock).mock.calls.find( + (call: any[]) => call[0] === "changed" + ); + const changeHandler = metadataCacheOnCall[1]; + const focusFile = new TFile("flow-focus-data/focus.md"); + + // Set the suppress flag (as addToFocus/removeFromFocus would) + (view as any).suppressFocusRefresh = true; + changeHandler(focusFile); + + expect(highlightSpy).not.toHaveBeenCalled(); + expect((view as any).suppressFocusRefresh).toBe(false); + + highlightSpy.mockRestore(); + }); + + it("should add/remove sphere-action-in-focus via CSS-only update", async () => { + const project = { + file: "Projects/Test.md", + title: "Test Project", + tags: ["project/work"], + status: "live" as const, + priority: 1, + nextActions: ["Call client", "Send email"], + mtime: Date.now(), + }; + + mockScanner.scanProjects.mockResolvedValue([project]); + mockLineFinder.findActionLine.mockResolvedValue({ + found: true, + lineNumber: 5, + lineContent: "- [ ] Call client", + }); + + mockFocusItems = []; + + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const container = view.containerEl.children[1] as HTMLElement; + let items = container.querySelectorAll("li[data-focus-file]"); + expect(items.length).toBe(2); + items.forEach((item) => { + expect(item.classList.contains("sphere-action-in-focus")).toBe(false); + }); + + // Add "Call client" to focus and run CSS-only update + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Call client", + text: "Call client", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }, + ]; + + await (view as any).refreshFocusHighlighting(); + + items = container.querySelectorAll("li[data-focus-file]"); + let focusedCount = 0; + items.forEach((item) => { + if (item.classList.contains("sphere-action-in-focus")) { + focusedCount++; + expect(item.getAttribute("data-focus-action")).toBe("Call client"); + } + }); + expect(focusedCount).toBe(1); + + // Remove from focus and run CSS-only update + mockFocusItems = []; + await (view as any).refreshFocusHighlighting(); + + items = container.querySelectorAll("li[data-focus-file]"); + items.forEach((item) => { + expect(item.classList.contains("sphere-action-in-focus")).toBe(false); + }); + }); + }); + + describe("workspace event handling", () => { + it("should remove action from DOM on flow:action-completed event", async () => { + const project = { + file: "Projects/Test.md", + title: "Test Project", + tags: ["project/work"], + status: "live" as const, + priority: 1, + nextActions: ["Call client", "Send email"], + mtime: Date.now(), + }; + + mockScanner.scanProjects.mockResolvedValue([project]); + mockLineFinder.findActionLine.mockResolvedValue({ + found: true, + lineNumber: 5, + lineContent: "- [ ] Call client", + }); + + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const container = view.containerEl.children[1] as HTMLElement; + let items = container.querySelectorAll("li[data-focus-file]"); + expect(items.length).toBe(2); + + // Find the workspace.on call for flow:action-completed + const workspaceOnCalls = (app.workspace.on as jest.Mock).mock.calls; + const completedHandler = workspaceOnCalls.find( + (call: any[]) => call[0] === "flow:action-completed" + ); + expect(completedHandler).toBeTruthy(); + + // Simulate completion event for "Call client" + completedHandler[1]({ file: "Projects/Test.md", action: "Call client" }); + + items = container.querySelectorAll("li[data-focus-file]"); + expect(items.length).toBe(1); + expect(items[0].getAttribute("data-focus-action")).toBe("Send email"); + }); + + it("should add handshake emoji on flow:action-waiting event", async () => { + const project = { + file: "Projects/Test.md", + title: "Test Project", + tags: ["project/work"], + status: "live" as const, + priority: 1, + nextActions: ["Call client", "Send email"], + mtime: Date.now(), + }; + + mockScanner.scanProjects.mockResolvedValue([project]); + mockLineFinder.findActionLine.mockResolvedValue({ + found: true, + lineNumber: 5, + lineContent: "- [ ] Call client", + }); + + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const container = view.containerEl.children[1] as HTMLElement; + + // Find the workspace.on call for flow:action-waiting + const workspaceOnCalls = (app.workspace.on as jest.Mock).mock.calls; + const waitingHandler = workspaceOnCalls.find( + (call: any[]) => call[0] === "flow:action-waiting" + ); + expect(waitingHandler).toBeTruthy(); + + // Simulate waiting-for event for "Call client" + waitingHandler[1]({ file: "Projects/Test.md", action: "Call client" }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // The action should still exist but now have the handshake emoji + const items = container.querySelectorAll("li[data-focus-file]"); + expect(items.length).toBe(2); + const callClientItem = Array.from(items).find( + (item) => item.getAttribute("data-focus-action") === "Call client" + ); + expect(callClientItem).toBeTruthy(); + expect(callClientItem!.textContent).toContain("🤝"); + }); + + it("should clean up workspace event listeners on close", async () => { + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + + // Verify workspace events were registered + const workspaceOnCalls = (app.workspace.on as jest.Mock).mock.calls; + const completedHandler = workspaceOnCalls.find( + (call: any[]) => call[0] === "flow:action-completed" + ); + expect(completedHandler).toBeTruthy(); + + await view.onClose(); + + // offref should have been called for each workspace event ref + expect(app.workspace.offref).toHaveBeenCalled(); + }); + }); + describe("waiting-for visual indicator", () => { it("should display handshake emoji for waiting-for items", async () => { // Mock line finder to return waiting-for checkbox for one action @@ -691,6 +1032,7 @@ describe("SphereView", () => { add: jest.fn(), }, addEventListener: jest.fn(), + setAttribute: jest.fn(), }), }; diff --git a/versions.json b/versions.json index 49a6cb42..bf38db24 100644 --- a/versions.json +++ b/versions.json @@ -8,5 +8,7 @@ "1.1.2": "0.15.0", "1.1.3": "0.15.0", "1.2.0": "0.15.0", - "1.2.1": "0.15.0" + "1.2.1": "0.15.0", + "1.2.2": "0.15.0", + "1.2.3": "0.15.0" }