From 60723c01a64b1f0aeac54845a235e0285c077fb0 Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Fri, 13 Mar 2026 07:43:03 +0800 Subject: [PATCH 1/4] refactor: replace temp file with stdin pipe and optimize inbox queries - Replace temp file based osascript execution with stdin pipe via spawn(), eliminating fs write/delete overhead on every JXA invocation. - Rewrite listInboxTasks() to use the `inbox` global object directly instead of getPerspectiveTasks('Inbox'), removing the requirement for an open OmniFocus window. - Optimize getInboxCount() to count tasks in-process without serializing every task object through JSON. - Remove unused imports: writeFile, unlink, tmpdir, join, promisify. --- src/lib/omnifocus.ts | 100 ++++++++++++++++++++++++++++++++----------- 1 file changed, 75 insertions(+), 25 deletions(-) diff --git a/src/lib/omnifocus.ts b/src/lib/omnifocus.ts index 0eea0c7..65600db 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; - try { - await writeFile(tmpFile, script, 'utf-8'); + const timer = setTimeout(() => { + timedOut = true; + proc.kill(); + reject(new Error(`osascript timed out after ${timeoutMs}ms`)); + }, timeoutMs); - const { stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', tmpFile], { - timeout: timeoutMs, - maxBuffer: 10 * 1024 * 1024, + proc.stdout.on('data', (data: Buffer) => { stdout += data; }); + proc.stderr.on('data', (data: Buffer) => { stderr += data; }); + + 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 { @@ -559,13 +575,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 { From 1e442d4ec5b305dcd469e75216dc26602c780ee8 Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Fri, 13 Mar 2026 07:57:26 +0800 Subject: [PATCH 2/4] feat: add human-readable table output for interactive terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When stdout is a TTY (interactive terminal), commands now display results as formatted tables instead of raw JSON. Pipe or redirect output retains JSON for machine consumption. Use --json/-j to force JSON output in a terminal. Supported table formats: - task list/view/search/inbox list: flag, name, project, tags, due, added - project list/view: name, status, folder, remaining/total tasks, tags - tag list/view: name, task count, remaining, status, parent, activity - folder list/view: hierarchical name, status, project counts - perspective list: name - stats commands: key-value format - inbox count: plain text "Count: N" Behavior: - TTY → table (default), --json forces JSON - Pipe/redirect → JSON (always), backward compatible - --compact → JSON (single line), unchanged --- src/cli.ts | 2 + src/lib/output.ts | 252 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 253 insertions(+), 1 deletion(-) 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/lib/output.ts b/src/lib/output.ts index 4b8a5b8..74c3ed7 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 ── + +function isRecord(data: unknown): data is Record { + return typeof data === 'object' && data !== null && !Array.isArray(data); +} + +function isTask(item: unknown): item is Task { + return isRecord(item) && 'id' in item && 'name' in item && 'completed' in item && 'flagged' in item; +} + +function isProject(item: unknown): item is Project { + return isRecord(item) && 'id' in item && 'name' in item && 'sequential' in item && 'taskCount' in item; +} + +function isTag(item: unknown): item is Tag { + return isRecord(item) && 'id' in item && 'name' in item && 'taskCount' in item && 'allowsNextAction' in item; +} + +function isFolder(item: unknown): item is Folder { + return isRecord(item) && 'id' in item && 'name' in item && 'projectCount' in item; +} + +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. */ +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". */ +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". */ +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. + */ +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 ── + +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`; +} + +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`; +} + +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`; +} + +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`; +} + +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 ── + +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; +} From dd1f7c8c6239999ff83fa03bbeebe2f2ac0eab40 Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Fri, 13 Mar 2026 08:02:07 +0800 Subject: [PATCH 3/4] feat: add batch complete and delete for tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `of task complete` (alias `done`) and update `of task delete` to accept multiple task IDs/names in a single invocation. Both execute in a single JXA call, avoiding per-task process fork overhead. Examples: of task complete id1 id2 id3 of task done "task name A" "task name B" of task delete id1 id2 id3 of task rm "task name A" "task name B" This enables efficient bulk cleanup — completing or deleting 10 tasks takes ~650ms (one osascript call) instead of ~6.5s (ten calls). --- src/commands/task.ts | 27 +++++++++++++++++++++----- src/lib/omnifocus.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) 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/omnifocus.ts b/src/lib/omnifocus.ts index 65600db..145ac2b 100644 --- a/src/lib/omnifocus.ts +++ b/src/lib/omnifocus.ts @@ -509,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 = ` From 7a54607d21916508eb0e9c4559dc1b3a2c6aa91f Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Fri, 13 Mar 2026 08:13:04 +0800 Subject: [PATCH 4/4] test: add comprehensive tests for output formatting layer 44 new tests covering: - Type guards (isTask, isProject, isTag, isFolder, isPerspective, isRecord) - Table utilities (pad, relativeDate, shortDate, renderTable) - Table formatters (formatTaskTable, formatProjectTable, formatTagTable, formatFolderTable, formatPerspectiveTable, formatKeyValue) Exported pure functions from output.ts to enable direct testing. Total test count: 15 (display) + 44 (output) = 59. --- src/lib/__tests__/output.test.ts | 388 +++++++++++++++++++++++++++++++ src/lib/output.ts | 32 +-- 2 files changed, 404 insertions(+), 16 deletions(-) create mode 100644 src/lib/__tests__/output.test.ts 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/output.ts b/src/lib/output.ts index 74c3ed7..72640f5 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -52,40 +52,40 @@ export function outputJson(data: unknown, options: OutputOptions = {}): void { // ── type guards ── -function isRecord(data: unknown): data is Record { +export function isRecord(data: unknown): data is Record { return typeof data === 'object' && data !== null && !Array.isArray(data); } -function isTask(item: unknown): item is Task { +export function isTask(item: unknown): item is Task { return isRecord(item) && 'id' in item && 'name' in item && 'completed' in item && 'flagged' in item; } -function isProject(item: unknown): item is Project { +export function isProject(item: unknown): item is Project { return isRecord(item) && 'id' in item && 'name' in item && 'sequential' in item && 'taskCount' in item; } -function isTag(item: unknown): item is Tag { +export function isTag(item: unknown): item is Tag { return isRecord(item) && 'id' in item && 'name' in item && 'taskCount' in item && 'allowsNextAction' in item; } -function isFolder(item: unknown): item is Folder { +export function isFolder(item: unknown): item is Folder { return isRecord(item) && 'id' in item && 'name' in item && 'projectCount' in item; } -function isPerspective(item: unknown): item is Perspective { +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. */ -function pad(str: string, width: number): string { +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". */ -function relativeDate(isoStr: string | null): string { +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)); @@ -102,7 +102,7 @@ function relativeDate(isoStr: string | null): string { } /** Format a short date like "2024-08-01". */ -function shortDate(isoStr: string | null): string { +export function shortDate(isoStr: string | null): string { if (!isoStr) return '-'; return isoStr.slice(0, 10); } @@ -111,7 +111,7 @@ function shortDate(isoStr: string | null): string { * Render a simple table from rows of strings. * First row is treated as the header. */ -function renderTable(headers: string[], rows: string[][]): string { +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); @@ -128,7 +128,7 @@ function renderTable(headers: string[], rows: string[][]): string { // ── formatters for specific data types ── -function formatTaskTable(tasks: Task[]): string { +export function formatTaskTable(tasks: Task[]): string { if (tasks.length === 0) return 'No tasks found.'; const headers = ['FLAG', 'NAME', 'PROJECT', 'TAGS', 'DUE', 'ADDED']; @@ -144,7 +144,7 @@ function formatTaskTable(tasks: Task[]): string { return `${renderTable(headers, rows)}\n${tasks.length} tasks`; } -function formatProjectTable(projects: Project[]): string { +export function formatProjectTable(projects: Project[]): string { if (projects.length === 0) return 'No projects found.'; const headers = ['NAME', 'STATUS', 'FOLDER', 'REMAINING', 'TOTAL', 'TAGS']; @@ -160,7 +160,7 @@ function formatProjectTable(projects: Project[]): string { return `${renderTable(headers, rows)}\n${projects.length} projects`; } -function formatTagTable(tags: Tag[]): string { +export function formatTagTable(tags: Tag[]): string { if (tags.length === 0) return 'No tags found.'; const headers = ['NAME', 'TASKS', 'REMAINING', 'STATUS', 'PARENT', 'LAST ACTIVITY']; @@ -176,7 +176,7 @@ function formatTagTable(tags: Tag[]): string { return `${renderTable(headers, rows)}\n${tags.length} tags`; } -function formatFolderTable(folders: Folder[]): string { +export function formatFolderTable(folders: Folder[]): string { if (folders.length === 0) return 'No folders found.'; const headers = ['NAME', 'STATUS', 'PROJECTS', 'ACTIVE PROJECTS']; @@ -199,7 +199,7 @@ function formatFolderTable(folders: Folder[]): string { return `${renderTable(headers, result)}\n${folders.length} top-level folders`; } -function formatPerspectiveTable(perspectives: Perspective[]): string { +export function formatPerspectiveTable(perspectives: Perspective[]): string { if (perspectives.length === 0) return 'No perspectives found.'; const headers = ['NAME']; @@ -210,7 +210,7 @@ function formatPerspectiveTable(perspectives: Perspective[]): string { // ── stats formatters ── -function formatKeyValue(obj: Record, indent = 0): string { +export function formatKeyValue(obj: Record, indent = 0): string { const prefix = ' '.repeat(indent); const lines: string[] = [];