diff --git a/src/cli.ts b/src/cli.ts index ebd2ba0..d3f359c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,10 +18,12 @@ program .description('A command-line interface for OmniFocus on macOS') .version(__VERSION__) .option('-c, --compact', 'Minified JSON output (single line)') + .option('-j, --json', 'Force JSON output (default when piped)') .hook('preAction', (thisCommand) => { const options = thisCommand.opts(); setOutputOptions({ compact: options.compact, + json: options.json, }); }); diff --git a/src/commands/task.ts b/src/commands/task.ts index fa39205..0f664e6 100644 --- a/src/commands/task.ts +++ b/src/commands/task.ts @@ -98,14 +98,31 @@ export function createTaskCommand(): Command { ); command - .command('delete ') + .command('complete ') + .alias('done') + .description('Mark one or more tasks as completed') + .action( + withErrorHandling(async (idsOrNames: string[]) => { + const of = new OmniFocus(); + const tasks = await of.completeTasks(idsOrNames); + outputJson(tasks); + }) + ); + + command + .command('delete ') .alias('rm') - .description('Delete a task') + .description('Delete one or more tasks') .action( - withErrorHandling(async (idOrName) => { + withErrorHandling(async (idsOrNames: string[]) => { const of = new OmniFocus(); - await of.deleteTask(idOrName); - outputJson({ message: 'Task deleted successfully' }); + if (idsOrNames.length === 1) { + await of.deleteTask(idsOrNames[0]); + outputJson({ message: 'Task deleted successfully' }); + } else { + const count = await of.deleteTasks(idsOrNames); + outputJson({ message: `${count} tasks deleted successfully` }); + } }) ); diff --git a/src/lib/__tests__/output.test.ts b/src/lib/__tests__/output.test.ts new file mode 100644 index 0000000..0c7c51c --- /dev/null +++ b/src/lib/__tests__/output.test.ts @@ -0,0 +1,388 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + isRecord, isTask, isProject, isTag, isFolder, isPerspective, + pad, relativeDate, shortDate, renderTable, + formatTaskTable, formatProjectTable, formatTagTable, + formatFolderTable, formatPerspectiveTable, formatKeyValue, +} from '../output.js'; +import type { Task, Project, Tag, Folder, Perspective } from '../../types.js'; + +// ── test fixtures ── + +const baseTask: Task = { + id: 'task-1', + name: 'Test Task', + note: null, + completed: false, + dropped: false, + effectivelyActive: true, + flagged: false, + project: 'My Project', + tags: ['work', 'urgent'], + defer: null, + due: null, + estimatedMinutes: null, + completionDate: null, + added: '2025-01-15T10:00:00Z', + modified: '2025-01-15T10:00:00Z', +}; + +const baseProject: Project = { + id: 'proj-1', + name: 'Test Project', + note: null, + status: 'active', + folder: 'Work', + sequential: false, + taskCount: 10, + remainingCount: 5, + tags: ['office'], +}; + +const baseTag: Tag = { + id: 'tag-1', + name: 'Work', + taskCount: 10, + remainingTaskCount: 5, + added: '2023-01-01T00:00:00Z', + modified: '2023-06-01T00:00:00Z', + lastActivity: '2025-01-01T00:00:00Z', + active: true, + status: 'active', + parent: null, + children: ['Important', 'Low'], + allowsNextAction: true, +}; + +const baseFolder: Folder = { + id: 'folder-1', + name: 'Work', + status: 'active', + effectivelyActive: true, + parent: null, + projectCount: 3, + remainingProjectCount: 2, + folderCount: 1, + children: [], +}; + +const basePerspective: Perspective = { + id: 'Inbox', + name: 'Inbox', +}; + +// ── type guards ── + +describe('isRecord', () => { + it('returns true for plain objects', () => { + expect(isRecord({})).toBe(true); + expect(isRecord({ a: 1 })).toBe(true); + }); + + it('returns false for non-objects', () => { + expect(isRecord(null)).toBe(false); + expect(isRecord(undefined)).toBe(false); + expect(isRecord(42)).toBe(false); + expect(isRecord('string')).toBe(false); + expect(isRecord([])).toBe(false); + }); +}); + +describe('isTask', () => { + it('identifies task objects', () => { + expect(isTask(baseTask)).toBe(true); + }); + + it('rejects non-task objects', () => { + expect(isTask(baseProject)).toBe(false); + expect(isTask(baseTag)).toBe(false); + expect(isTask({})).toBe(false); + expect(isTask({ id: '1', name: 'x' })).toBe(false); + }); +}); + +describe('isProject', () => { + it('identifies project objects', () => { + expect(isProject(baseProject)).toBe(true); + }); + + it('rejects non-project objects', () => { + expect(isProject(baseTask)).toBe(false); + expect(isProject(baseTag)).toBe(false); + expect(isProject({})).toBe(false); + }); + + it('does not misidentify tags as projects', () => { + expect(isProject(baseTag)).toBe(false); + }); +}); + +describe('isTag', () => { + it('identifies tag objects', () => { + expect(isTag(baseTag)).toBe(true); + }); + + it('rejects non-tag objects', () => { + expect(isTag(baseTask)).toBe(false); + expect(isTag(baseProject)).toBe(false); + expect(isTag({})).toBe(false); + }); +}); + +describe('isFolder', () => { + it('identifies folder objects', () => { + expect(isFolder(baseFolder)).toBe(true); + }); + + it('rejects non-folder objects', () => { + expect(isFolder(baseTask)).toBe(false); + expect(isFolder({})).toBe(false); + }); +}); + +describe('isPerspective', () => { + it('identifies perspective objects (exactly 2 keys: id and name)', () => { + expect(isPerspective(basePerspective)).toBe(true); + }); + + it('rejects objects with extra keys', () => { + expect(isPerspective({ id: '1', name: 'x', extra: true })).toBe(false); + }); + + it('rejects objects missing keys', () => { + expect(isPerspective({ id: '1' })).toBe(false); + expect(isPerspective({ name: 'x' })).toBe(false); + }); +}); + +// ── table utilities ── + +describe('pad', () => { + it('pads short strings with spaces', () => { + expect(pad('hi', 5)).toBe('hi '); + }); + + it('returns exact-width strings unchanged', () => { + expect(pad('hello', 5)).toBe('hello'); + }); + + it('truncates long strings with ellipsis', () => { + expect(pad('hello world', 5)).toBe('hell…'); + }); +}); + +describe('relativeDate', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-13T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns "-" for null', () => { + expect(relativeDate(null)).toBe('-'); + }); + + it('returns "today" for today', () => { + expect(relativeDate('2026-03-13T10:00:00Z')).toBe('today'); + }); + + it('returns days ago for recent dates', () => { + expect(relativeDate('2026-03-10T12:00:00Z')).toBe('3d ago'); + }); + + it('returns months ago for older dates', () => { + expect(relativeDate('2026-01-13T12:00:00Z')).toBe('1mo ago'); + }); + + it('returns years ago for old dates', () => { + expect(relativeDate('2024-03-13T12:00:00Z')).toBe('2y ago'); + }); + + it('returns future dates with "in" prefix', () => { + expect(relativeDate('2026-03-20T12:00:00Z')).toBe('in 7d'); + expect(relativeDate('2026-06-13T12:00:00Z')).toBe('in 3mo'); + expect(relativeDate('2027-04-13T12:00:00Z')).toBe('in 1y'); + }); +}); + +describe('shortDate', () => { + it('returns "-" for null', () => { + expect(shortDate(null)).toBe('-'); + }); + + it('extracts YYYY-MM-DD from ISO string', () => { + expect(shortDate('2024-08-01T10:30:00Z')).toBe('2024-08-01'); + }); +}); + +describe('renderTable', () => { + it('renders headers, separator, and data rows', () => { + const result = renderTable(['NAME', 'AGE'], [['Alice', '30'], ['Bob', '25']]); + const lines = result.split('\n'); + expect(lines).toHaveLength(4); + expect(lines[0]).toContain('NAME'); + expect(lines[0]).toContain('AGE'); + expect(lines[1]).toMatch(/^─+───+$/); + expect(lines[2]).toContain('Alice'); + expect(lines[3]).toContain('Bob'); + }); + + it('adjusts column widths to fit longest content', () => { + const result = renderTable(['N'], [['LongName']]); + const lines = result.split('\n'); + // Header is padded to match the longest data cell + expect(lines[0].length).toBeGreaterThanOrEqual('LongName'.length); + // Separator matches the column width + expect(lines[1].length).toBeGreaterThanOrEqual('LongName'.length); + // Data cell is present + expect(lines[2]).toContain('LongName'); + }); +}); + +// ── table formatters ── + +describe('formatTaskTable', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-13T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns "No tasks found." for empty array', () => { + expect(formatTaskTable([])).toBe('No tasks found.'); + }); + + it('shows flagged indicator ★', () => { + const flagged = { ...baseTask, flagged: true }; + const result = formatTaskTable([flagged]); + expect(result).toContain('★'); + }); + + it('shows overdue indicator !', () => { + const overdue = { ...baseTask, due: '2020-01-01T00:00:00Z' }; + const result = formatTaskTable([overdue]); + expect(result).toContain('!'); + expect(result).toContain('OVERDUE'); + }); + + it('shows project name and tags', () => { + const result = formatTaskTable([baseTask]); + expect(result).toContain('My Project'); + expect(result).toContain('#work'); + expect(result).toContain('#urgent'); + }); + + it('shows task count footer', () => { + const result = formatTaskTable([baseTask, { ...baseTask, id: 'task-2', name: 'Task 2' }]); + expect(result).toContain('2 tasks'); + }); +}); + +describe('formatProjectTable', () => { + it('returns "No projects found." for empty array', () => { + expect(formatProjectTable([])).toBe('No projects found.'); + }); + + it('shows project details', () => { + const result = formatProjectTable([baseProject]); + expect(result).toContain('Test Project'); + expect(result).toContain('active'); + expect(result).toContain('Work'); + expect(result).toContain('5'); + expect(result).toContain('10'); + expect(result).toContain('#office'); + }); + + it('shows "-" for missing folder', () => { + const noFolder = { ...baseProject, folder: null }; + const result = formatProjectTable([noFolder]); + const lines = result.split('\n'); + const dataLine = lines[2]; + expect(dataLine).toContain('-'); + }); +}); + +describe('formatTagTable', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-13T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns "No tags found." for empty array', () => { + expect(formatTagTable([])).toBe('No tags found.'); + }); + + it('shows tag details', () => { + const result = formatTagTable([baseTag]); + expect(result).toContain('Work'); + expect(result).toContain('10'); + expect(result).toContain('5'); + expect(result).toContain('active'); + expect(result).toContain('1y ago'); + expect(result).toContain('1 tags'); + }); +}); + +describe('formatFolderTable', () => { + it('returns "No folders found." for empty array', () => { + expect(formatFolderTable([])).toBe('No folders found.'); + }); + + it('shows folder hierarchy with indentation', () => { + const nested: Folder = { + ...baseFolder, + children: [{ + ...baseFolder, + id: 'folder-2', + name: 'Subproject', + parent: 'Work', + children: [], + }], + }; + const result = formatFolderTable([nested]); + expect(result).toContain('Work'); + expect(result).toContain(' Subproject'); + }); +}); + +describe('formatPerspectiveTable', () => { + it('returns "No perspectives found." for empty array', () => { + expect(formatPerspectiveTable([])).toBe('No perspectives found.'); + }); + + it('lists perspective names', () => { + const result = formatPerspectiveTable([basePerspective, { id: 'Flagged', name: 'Flagged' }]); + expect(result).toContain('Inbox'); + expect(result).toContain('Flagged'); + expect(result).toContain('2 perspectives'); + }); +}); + +describe('formatKeyValue', () => { + it('renders flat key-value pairs', () => { + const result = formatKeyValue({ total: 10, active: 5 }); + expect(result).toBe('total: 10\nactive: 5'); + }); + + it('renders arrays as indented lists', () => { + const result = formatKeyValue({ items: [{ name: 'A', count: 1 }] }); + expect(result).toContain('items:'); + expect(result).toContain(' - name: A, count: 1'); + }); + + it('renders non-object array items inline', () => { + const result = formatKeyValue({ tags: ['work', 'home'] }); + expect(result).toContain(' - work'); + expect(result).toContain(' - home'); + }); +}); diff --git a/src/lib/omnifocus.ts b/src/lib/omnifocus.ts index 0eea0c7..145ac2b 100644 --- a/src/lib/omnifocus.ts +++ b/src/lib/omnifocus.ts @@ -1,8 +1,4 @@ -import { execFile } from 'child_process'; -import { writeFile, unlink } from 'fs/promises'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { promisify } from 'util'; +import { spawn } from 'child_process'; import type { Task, Project, @@ -24,8 +20,6 @@ import type { FolderFilters, } from '../types.js'; -const execFileAsync = promisify(execFile); - export class OmniFocus { private readonly PROJECT_STATUS_MAP = { active: 'Active', @@ -245,25 +239,47 @@ export class OmniFocus { } `; - private async executeJXA(script: string, timeoutMs = 30000): Promise { - const tmpFile = join(tmpdir(), `omnifocus-${Date.now()}.js`); + /** + * Execute a JXA script via osascript using stdin pipe. + * Avoids temp file creation/deletion overhead on every invocation. + */ + private executeJXA(script: string, timeoutMs = 30000): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('osascript', ['-l', 'JavaScript', '-'], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let timedOut = false; + + const timer = setTimeout(() => { + timedOut = true; + proc.kill(); + reject(new Error(`osascript timed out after ${timeoutMs}ms`)); + }, timeoutMs); - try { - await writeFile(tmpFile, script, 'utf-8'); + proc.stdout.on('data', (data: Buffer) => { stdout += data; }); + proc.stderr.on('data', (data: Buffer) => { stderr += data; }); - const { stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', tmpFile], { - timeout: timeoutMs, - maxBuffer: 10 * 1024 * 1024, + proc.on('close', (code) => { + clearTimeout(timer); + if (timedOut) return; + if (code !== 0) { + reject(new Error(stderr.trim() || `osascript exited with code ${code}`)); + } else { + resolve(stdout.trim()); + } }); - return stdout.trim(); - } finally { - try { - await unlink(tmpFile); - } catch { - /* ignore cleanup errors */ - } - } + proc.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + + proc.stdin.write(script); + proc.stdin.end(); + }); } private escapeString(str: string): string { @@ -493,6 +509,52 @@ export class OmniFocus { await this.executeJXA(this.wrapOmniScript(omniScript)); } + /** + * Complete multiple tasks in a single JXA invocation. + * Returns the list of tasks that were completed. + */ + async completeTasks(idsOrNames: string[]): Promise { + const escaped = idsOrNames.map(id => `"${this.escapeString(id)}"`); + const omniScript = ` + ${this.OMNI_HELPERS} + (() => { + const ids = [${escaped.join(', ')}]; + const results = []; + for (const id of ids) { + const task = findTask(id); + task.markComplete(); + results.push(serializeTask(task)); + } + return JSON.stringify(results); + })(); + `; + + const output = await this.executeJXA(this.wrapOmniScript(omniScript)); + return JSON.parse(output); + } + + /** + * Delete multiple tasks in a single JXA invocation. + * Returns the count of deleted tasks. + */ + async deleteTasks(idsOrNames: string[]): Promise { + const escaped = idsOrNames.map(id => `"${this.escapeString(id)}"`); + const omniScript = ` + ${this.OMNI_HELPERS} + (() => { + const ids = [${escaped.join(', ')}]; + const tasks = ids.map(id => findTask(id)); + for (const task of tasks) { + deleteObject(task); + } + return JSON.stringify(tasks.length); + })(); + `; + + const output = await this.executeJXA(this.wrapOmniScript(omniScript)); + return JSON.parse(output); + } + async listProjects(filters: ProjectFilters = {}): Promise { const filterCode = this.buildProjectFilters(filters); const omniScript = ` @@ -559,13 +621,47 @@ export class OmniFocus { await this.executeJXA(this.wrapOmniScript(omniScript)); } + /** + * List inbox tasks using the Omni Automation `inbox` global object directly. + * Unlike getPerspectiveTasks('Inbox'), this does NOT require an open window. + */ async listInboxTasks(): Promise { - return this.getPerspectiveTasks('Inbox'); + const omniScript = ` + ${this.OMNI_HELPERS} + (() => { + const results = []; + for (const task of inbox) { + if (task.completed) continue; + if (!task.effectiveActive) continue; + results.push(serializeTask(task)); + } + return JSON.stringify(results); + })(); + `; + + const output = await this.executeJXA(this.wrapOmniScript(omniScript)); + return JSON.parse(output); } + /** + * Count inbox tasks directly without serializing every task object. + * Uses the `inbox` global to avoid window dependency. + */ async getInboxCount(): Promise { - const tasks = await this.getPerspectiveTasks('Inbox'); - return tasks.length; + const omniScript = ` + (() => { + let count = 0; + for (const task of inbox) { + if (task.completed) continue; + if (!task.effectiveActive) continue; + count++; + } + return JSON.stringify(count); + })(); + `; + + const output = await this.executeJXA(this.wrapOmniScript(omniScript)); + return JSON.parse(output); } async searchTasks(query: string): Promise { diff --git a/src/lib/output.ts b/src/lib/output.ts index 4b8a5b8..72640f5 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -1,5 +1,9 @@ +import { isTaskOverdue, formatTags } from './display.js'; +import type { Task, Project, Tag, Folder, Perspective } from '../types.js'; + export interface OutputOptions { compact?: boolean; + json?: boolean; } let globalOutputOptions: OutputOptions = {}; @@ -8,9 +12,255 @@ export function setOutputOptions(options: OutputOptions): void { globalOutputOptions = options; } +/** + * Determine whether to use table format. + * Table is the default when stdout is a TTY (interactive terminal). + * --json flag or piped/redirected stdout forces JSON output. + */ +function shouldUseTable(): boolean { + if (globalOutputOptions.json) return false; + if (globalOutputOptions.compact) return false; + return process.stdout.isTTY === true; +} + +/** + * Output data as JSON or human-readable table depending on context. + * Pass a `tableFormatter` to enable table output for a specific data type. + */ export function outputJson(data: unknown, options: OutputOptions = {}): void { const mergedOptions = { ...globalOutputOptions, ...options }; - const jsonString = mergedOptions.compact ? JSON.stringify(data) : JSON.stringify(data, null, 2); + if (shouldUseTable() && Array.isArray(data)) { + const formatted = formatArray(data); + if (formatted !== null) { + console.log(formatted); + return; + } + } + + if (shouldUseTable() && isRecord(data)) { + const formatted = formatRecord(data); + if (formatted !== null) { + console.log(formatted); + return; + } + } + + const jsonString = mergedOptions.compact ? JSON.stringify(data) : JSON.stringify(data, null, 2); console.log(jsonString); } + +// ── type guards ── + +export function isRecord(data: unknown): data is Record { + return typeof data === 'object' && data !== null && !Array.isArray(data); +} + +export function isTask(item: unknown): item is Task { + return isRecord(item) && 'id' in item && 'name' in item && 'completed' in item && 'flagged' in item; +} + +export function isProject(item: unknown): item is Project { + return isRecord(item) && 'id' in item && 'name' in item && 'sequential' in item && 'taskCount' in item; +} + +export function isTag(item: unknown): item is Tag { + return isRecord(item) && 'id' in item && 'name' in item && 'taskCount' in item && 'allowsNextAction' in item; +} + +export function isFolder(item: unknown): item is Folder { + return isRecord(item) && 'id' in item && 'name' in item && 'projectCount' in item; +} + +export function isPerspective(item: unknown): item is Perspective { + return isRecord(item) && 'id' in item && 'name' in item && Object.keys(item as object).length === 2; +} + +// ── table rendering ── + +/** Pad or truncate a string to fit a column width. */ +export function pad(str: string, width: number): string { + if (str.length > width) return str.slice(0, width - 1) + '…'; + return str.padEnd(width); +} + +/** Format a relative date string like "3d ago", "2mo ago", "1y ago". */ +export function relativeDate(isoStr: string | null): string { + if (!isoStr) return '-'; + const diff = Date.now() - new Date(isoStr).getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + if (days < 0) { + const absDays = Math.abs(days); + if (absDays < 30) return `in ${absDays}d`; + if (absDays < 365) return `in ${Math.floor(absDays / 30)}mo`; + return `in ${Math.floor(absDays / 365)}y`; + } + if (days === 0) return 'today'; + if (days < 30) return `${days}d ago`; + if (days < 365) return `${Math.floor(days / 30)}mo ago`; + return `${Math.floor(days / 365)}y ago`; +} + +/** Format a short date like "2024-08-01". */ +export function shortDate(isoStr: string | null): string { + if (!isoStr) return '-'; + return isoStr.slice(0, 10); +} + +/** + * Render a simple table from rows of strings. + * First row is treated as the header. + */ +export function renderTable(headers: string[], rows: string[][]): string { + const widths = headers.map((h, i) => { + const maxDataWidth = rows.reduce((max, row) => Math.max(max, (row[i] || '').length), 0); + return Math.max(h.length, maxDataWidth); + }); + + const headerLine = headers.map((h, i) => pad(h, widths[i])).join(' '); + const separator = widths.map(w => '─'.repeat(w)).join('──'); + const dataLines = rows.map(row => + row.map((cell, i) => pad(cell, widths[i])).join(' ') + ); + + return [headerLine, separator, ...dataLines].join('\n'); +} + +// ── formatters for specific data types ── + +export function formatTaskTable(tasks: Task[]): string { + if (tasks.length === 0) return 'No tasks found.'; + + const headers = ['FLAG', 'NAME', 'PROJECT', 'TAGS', 'DUE', 'ADDED']; + const rows = tasks.map(t => [ + t.flagged ? '★' : (isTaskOverdue(t) ? '!' : ' '), + t.name, + t.project || '-', + t.tags.length > 0 ? formatTags(t.tags, ', ') : '-', + t.due ? (isTaskOverdue(t) ? `${shortDate(t.due)} OVERDUE` : shortDate(t.due)) : '-', + relativeDate(t.added), + ]); + + return `${renderTable(headers, rows)}\n${tasks.length} tasks`; +} + +export function formatProjectTable(projects: Project[]): string { + if (projects.length === 0) return 'No projects found.'; + + const headers = ['NAME', 'STATUS', 'FOLDER', 'REMAINING', 'TOTAL', 'TAGS']; + const rows = projects.map(p => [ + p.name, + p.status, + p.folder || '-', + String(p.remainingCount), + String(p.taskCount), + p.tags.length > 0 ? formatTags(p.tags, ', ') : '-', + ]); + + return `${renderTable(headers, rows)}\n${projects.length} projects`; +} + +export function formatTagTable(tags: Tag[]): string { + if (tags.length === 0) return 'No tags found.'; + + const headers = ['NAME', 'TASKS', 'REMAINING', 'STATUS', 'PARENT', 'LAST ACTIVITY']; + const rows = tags.map(t => [ + t.name, + String(t.taskCount), + String(t.remainingTaskCount), + t.status, + t.parent || '-', + relativeDate(t.lastActivity), + ]); + + return `${renderTable(headers, rows)}\n${tags.length} tags`; +} + +export function formatFolderTable(folders: Folder[]): string { + if (folders.length === 0) return 'No folders found.'; + + const headers = ['NAME', 'STATUS', 'PROJECTS', 'ACTIVE PROJECTS']; + const result: string[][] = []; + + function flatten(folder: Folder, depth: number) { + result.push([ + ' '.repeat(depth) + folder.name, + folder.status, + String(folder.projectCount), + String(folder.remainingProjectCount), + ]); + for (const child of folder.children) { + flatten(child, depth + 1); + } + } + + for (const f of folders) flatten(f, 0); + + return `${renderTable(headers, result)}\n${folders.length} top-level folders`; +} + +export function formatPerspectiveTable(perspectives: Perspective[]): string { + if (perspectives.length === 0) return 'No perspectives found.'; + + const headers = ['NAME']; + const rows = perspectives.map(p => [p.name]); + + return `${renderTable(headers, rows)}\n${perspectives.length} perspectives`; +} + +// ── stats formatters ── + +export function formatKeyValue(obj: Record, indent = 0): string { + const prefix = ' '.repeat(indent); + const lines: string[] = []; + + for (const [key, value] of Object.entries(obj)) { + if (Array.isArray(value)) { + lines.push(`${prefix}${key}:`); + for (const item of value) { + if (isRecord(item)) { + const parts = Object.entries(item).map(([k, v]) => `${k}: ${v}`); + lines.push(`${prefix} - ${parts.join(', ')}`); + } else { + lines.push(`${prefix} - ${item}`); + } + } + } else { + lines.push(`${prefix}${key}: ${value}`); + } + } + + return lines.join('\n'); +} + +// ── dispatch ── + +function formatArray(data: unknown[]): string | null { + if (data.length === 0) return 'No items found.'; + + const first = data[0]; + if (isTask(first)) return formatTaskTable(data as Task[]); + if (isProject(first)) return formatProjectTable(data as Project[]); + if (isTag(first)) return formatTagTable(data as Tag[]); + if (isFolder(first)) return formatFolderTable(data as Folder[]); + if (isPerspective(first)) return formatPerspectiveTable(data as Perspective[]); + + return null; +} + +function formatRecord(data: Record): string | null { + // Single task/project/tag view + if (isTask(data)) return formatTaskTable([data as Task]); + if (isProject(data)) return formatProjectTable([data as Project]); + + // Simple message like { message: "..." } or { count: N } + if ('message' in data && typeof data.message === 'string') return data.message as string; + if ('count' in data && typeof data.count === 'number') return `Count: ${data.count}`; + + // Stats objects and other key-value data + if ('totalTasks' in data || 'totalProjects' in data || 'totalTags' in data) { + return formatKeyValue(data); + } + + return null; +}