Skip to content
← Public packages

@kentcdodds/workflowy

Thin secret-backed helpers for the WorkFlowy REST API: targets, nodes, mirrors, and outline export.

src/index.ts

620 lines · 18.0 KB · TypeScript
const API_ORIGIN = 'https://workflowy.com'
const API_BASE = `${API_ORIGIN}/api/v1`
const SECRET_API_KEY = '{{secret:WORKFLOWY_API_KEY|scope=user}}'

type JsonObject = Record<string, unknown>
type LayoutMode =
	| 'bullets'
	| 'todo'
	| 'h1'
	| 'h2'
	| 'h3'
	| 'code-block'
	| 'quote-block'
type Position = 'top' | 'bottom'

type RequestOptions = {
	method?: string
	query?: Record<string, unknown>
	body?: unknown
	headers?: Record<string, string>
	maxAttempts?: number
}

type NodeWriteInput = {
	parentId?: string
	parent_id?: string
	name?: string
	note?: string
	layoutMode?: LayoutMode
	position?: Position
}

type DispatcherInput = {
	action?: string
	path?: string
	options?: RequestOptions
	method?: string
	query?: Record<string, unknown>
	body?: unknown
	headers?: Record<string, string>
	maxAttempts?: number
	id?: string
	parentId?: string
	parent_id?: string
	name?: string
	note?: string
	layoutMode?: LayoutMode
	position?: Position
}

function sleep(ms: number): Promise<void> {
	return new Promise((resolve) => setTimeout(resolve, ms))
}

function requireString(value: unknown, name: string): string {
	if (typeof value !== 'string' || value.trim() === '') {
		throw new Error(`${name} is required.`)
	}
	return value.trim()
}

function optionalString(value: unknown): string | undefined {
	if (value === undefined || value === null) return undefined
	if (typeof value !== 'string') {
		throw new Error('Expected a string value.')
	}
	const trimmed = value.trim()
	return trimmed === '' ? undefined : trimmed
}

function validateLayoutMode(value: LayoutMode | undefined): LayoutMode | undefined {
	if (value === undefined) return undefined
	const allowed: LayoutMode[] = [
		'bullets',
		'todo',
		'h1',
		'h2',
		'h3',
		'code-block',
		'quote-block',
	]
	if (!allowed.includes(value)) {
		throw new Error(
			"layoutMode must be 'bullets', 'todo', 'h1', 'h2', 'h3', 'code-block', or 'quote-block'.",
		)
	}
	return value
}

function validatePosition(value: Position | undefined): Position | undefined {
	if (value === undefined) return undefined
	if (value !== 'top' && value !== 'bottom') {
		throw new Error("position must be 'top' or 'bottom'.")
	}
	return value
}

function parentIdOf(input: { parentId?: string; parent_id?: string }): string | undefined {
	return optionalString(input.parentId ?? input.parent_id)
}

function retryDelayMs(retryAfter: string | null, attempt: number): number {
	if (retryAfter) {
		const seconds = Number(retryAfter)
		if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
		const dateDelay = Date.parse(retryAfter) - Date.now()
		if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay)
	}
	return Math.min(5000, 500 * 2 ** (attempt - 1))
}

function parseResponse(text: string): unknown {
	if (!text) return null
	try {
		return JSON.parse(text)
	} catch {
		return text
	}
}

function safeErrorDetail(value: unknown): string {
	let detail = ''
	if (typeof value === 'string') {
		detail = value
	} else if (value && typeof value === 'object') {
		const object = value as Record<string, unknown>
		const candidate = object.message ?? object.error ?? object.detail ?? object.status
		detail = typeof candidate === 'string' ? candidate : JSON.stringify(value)
	}
	return detail
		.replaceAll(SECRET_API_KEY, '[REDACTED]')
		.replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
		.slice(0, 500)
}

function jsonBody(fields: Record<string, unknown>): Record<string, unknown> {
	return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined))
}

/**
 * Make an authenticated WorkFlowy REST request when a dedicated helper does not cover an endpoint.
 *
 * @param path - A WorkFlowy URL or path whose origin is `https://workflowy.com` and pathname begins with `/api/v1`.
 * @param options - HTTP method, query values, JSON body, extra headers, and retry limit.
 * @returns The parsed JSON response body.
 *
 * @example
 * import { request } from 'kody:@kentcdodds/workflowy'
 *
 * const response = await request('/api/v1/targets')
 */
export async function request(path: string, options: RequestOptions = {}): Promise<unknown> {
	const normalizedPath = requireString(path, 'path')
	const url = new URL(normalizedPath, API_ORIGIN)
	if (
		url.origin !== API_ORIGIN ||
		!(url.pathname === '/api/v1' || url.pathname.startsWith('/api/v1/'))
	) {
		throw new Error('WorkFlowy requests must stay on https://workflowy.com/api/v1.')
	}

	for (const [key, value] of Object.entries(options.query ?? {})) {
		if (value === undefined || value === null) continue
		if (Array.isArray(value)) {
			for (const item of value) url.searchParams.append(key, String(item))
		} else {
			url.searchParams.append(key, String(value))
		}
	}

	const method = (options.method ?? 'GET').toUpperCase()
	const body = options.body === undefined ? undefined : JSON.stringify(options.body)
	const attempts = Math.max(1, Math.floor(options.maxAttempts ?? 3))
	let lastError: Error | undefined

	for (let attempt = 1; attempt <= attempts; attempt += 1) {
		const headers = new Headers(options.headers)
		headers.set('accept', 'application/json')
		headers.set('authorization', `Bearer ${SECRET_API_KEY}`)
		if (body !== undefined) headers.set('content-type', 'application/json')

		const response = await fetch(url, { method, headers, body })
		const parsed = parseResponse(await response.text())
		if (response.ok) return parsed

		const detail = safeErrorDetail(parsed)
		lastError = new Error(
			`WorkFlowy API ${response.status} for ${url.pathname}${detail ? `: ${detail}` : ''}`,
		)
		if (!(response.status === 429 || response.status >= 500) || attempt >= attempts) {
			throw lastError
		}
		await sleep(retryDelayMs(response.headers.get('retry-after'), attempt))
	}

	throw lastError ?? new Error('WorkFlowy request failed.')
}

/**
 * List WorkFlowy targets (system locations and user shortcuts) when choosing a parent or destination.
 *
 * @returns `{ targets }` with `key`, `type`, and optional `name` for each target.
 *
 * @example
 * import { listTargets } from 'kody:@kentcdodds/workflowy'
 *
 * const { targets } = await listTargets() as { targets: Array<{ key: string }> }
 */
export async function listTargets(): Promise<unknown> {
	return await request('/api/v1/targets')
}

/**
 * List child nodes under a parent when browsing an outline section.
 *
 * @param input - Optional `parentId` / `parent_id` (defaults to API root behavior when omitted).
 * @returns `{ nodes }` unordered children — sort by `priority` yourself.
 *
 * @example
 * import { listNodes } from 'kody:@kentcdodds/workflowy'
 *
 * const children = await listNodes({ parentId: 'inbox' })
 */
export async function listNodes(
	input: { parentId?: string; parent_id?: string } = {},
): Promise<unknown> {
	return await request('/api/v1/nodes', {
		query: jsonBody({ parent_id: parentIdOf(input) }),
	})
}

/**
 * Retrieve one node by id, short id, or calendar target when inspecting a bullet.
 *
 * @param input - Node `id` (full UUID, short id, or calendar key such as `today`).
 * @returns `{ node }` with WorkFlowy node fields.
 *
 * @example
 * import { getNode } from 'kody:@kentcdodds/workflowy'
 *
 * const node = await getNode({ id: 'today' })
 */
export async function getNode(input: { id: string }): Promise<unknown> {
	const id = requireString(input?.id, 'id')
	return await request(`/api/v1/nodes/${encodeURIComponent(id)}`)
}

/**
 * Create a node under a parent when capturing a new bullet, todo, or header.
 *
 * @param input - Required `name` plus optional parent, note, layoutMode, and position.
 * @returns `{ item_id }` for the created node (and children when multiline `name` is used).
 *
 * @example
 * import { createNode } from 'kody:@kentcdodds/workflowy'
 *
 * const created = await createNode({
 *   parentId: 'inbox',
 *   name: 'Follow up with Pat',
 *   position: 'top',
 * })
 */
export async function createNode(
	input: NodeWriteInput & { name: string },
): Promise<unknown> {
	const name = requireString(input?.name, 'name')
	return await request('/api/v1/nodes', {
		method: 'POST',
		body: jsonBody({
			parent_id: parentIdOf(input),
			name,
			note: optionalString(input.note),
			layoutMode: validateLayoutMode(input.layoutMode),
			position: validatePosition(input.position),
		}),
	})
}

/**
 * Update selected fields on an existing node when revising name, note, or layout.
 *
 * @param input - Required `id` plus optional `name`, `note`, and `layoutMode`.
 * @returns `{ status: 'ok' }` on success.
 *
 * @example
 * import { updateNode } from 'kody:@kentcdodds/workflowy'
 *
 * const result = await updateNode({ id: '6ed4b9ca-256c-bf2e-bd70-d8754237b505', name: 'Revised title' })
 */
export async function updateNode(
	input: {
		id: string
		name?: string
		note?: string
		layoutMode?: LayoutMode
	},
): Promise<unknown> {
	const id = requireString(input?.id, 'id')
	return await request(`/api/v1/nodes/${encodeURIComponent(id)}`, {
		method: 'POST',
		body: jsonBody({
			name: optionalString(input.name),
			note: optionalString(input.note),
			layoutMode: validateLayoutMode(input.layoutMode),
		}),
	})
}

/**
 * Permanently delete a node when it should be removed (cannot be undone).
 *
 * @param input - Node `id` to delete.
 * @returns `{ status: 'ok' }` on success.
 *
 * @example
 * import { deleteNode } from 'kody:@kentcdodds/workflowy'
 *
 * const result = await deleteNode({ id: '6ed4b9ca-256c-bf2e-bd70-d8754237b505' })
 */
export async function deleteNode(input: { id: string }): Promise<unknown> {
	const id = requireString(input?.id, 'id')
	return await request(`/api/v1/nodes/${encodeURIComponent(id)}`, {
		method: 'DELETE',
	})
}

/**
 * Move a node under a new parent when reorganizing the outline.
 *
 * @param input - Node `id`, optional new `parentId` / `parent_id`, and `position`.
 * @returns `{ status: 'ok' }` on success.
 *
 * @example
 * import { moveNode } from 'kody:@kentcdodds/workflowy'
 *
 * const result = await moveNode({
 *   id: '6ed4b9ca-256c-bf2e-bd70-d8754237b505',
 *   parentId: 'today',
 *   position: 'top',
 * })
 */
export async function moveNode(
	input: {
		id: string
		parentId?: string
		parent_id?: string
		position?: Position
	},
): Promise<unknown> {
	const id = requireString(input?.id, 'id')
	return await request(`/api/v1/nodes/${encodeURIComponent(id)}/move`, {
		method: 'POST',
		body: jsonBody({
			parent_id: parentIdOf(input),
			position: validatePosition(input.position),
		}),
	})
}

/**
 * Mark a node completed when finishing a todo or checklist item.
 *
 * @param input - Node `id` to complete.
 * @returns `{ status: 'ok' }` on success.
 *
 * @example
 * import { completeNode } from 'kody:@kentcdodds/workflowy'
 *
 * const result = await completeNode({ id: '6ed4b9ca-256c-bf2e-bd70-d8754237b505' })
 */
export async function completeNode(input: { id: string }): Promise<unknown> {
	const id = requireString(input?.id, 'id')
	return await request(`/api/v1/nodes/${encodeURIComponent(id)}/complete`, {
		method: 'POST',
	})
}

/**
 * Clear completion on a node when reopening a finished item.
 *
 * @param input - Node `id` to uncomplete.
 * @returns `{ status: 'ok' }` on success.
 *
 * @example
 * import { uncompleteNode } from 'kody:@kentcdodds/workflowy'
 *
 * const result = await uncompleteNode({ id: '6ed4b9ca-256c-bf2e-bd70-d8754237b505' })
 */
export async function uncompleteNode(input: { id: string }): Promise<unknown> {
	const id = requireString(input?.id, 'id')
	return await request(`/api/v1/nodes/${encodeURIComponent(id)}/uncomplete`, {
		method: 'POST',
	})
}

/**
 * Create a live mirror of a node under another parent when the same content should appear in two places.
 *
 * @param input - Origin node `id`, destination full-node `parentId`, and optional `position`.
 * @returns `{ item_id, origin_id }` for the new mirror and its origin.
 *
 * @example
 * import { mirrorNode } from 'kody:@kentcdodds/workflowy'
 *
 * const mirror = await mirrorNode({
 *   id: '6ed4b9ca-256c-bf2e-bd70-d8754237b505',
 *   parentId: '5b401959-4740-4e1a-905a-62a961daa8c9',
 * })
 */
export async function mirrorNode(
	input: {
		id: string
		parentId?: string
		parent_id?: string
		position?: Position
	},
): Promise<unknown> {
	const id = requireString(input?.id, 'id')
	const parentId = requireString(parentIdOf(input), 'parentId')
	return await request(`/api/v1/nodes/${encodeURIComponent(id)}/mirror`, {
		method: 'POST',
		body: jsonBody({
			parent_id: parentId,
			position: validatePosition(input.position),
		}),
	})
}

/**
 * Delete a mirror root when removing a mirrored appearance without deleting the origin.
 *
 * @param input - Mirror node `id` (errors if the node is not a mirror).
 * @returns `{ status: 'ok' }` on success.
 *
 * @example
 * import { deleteMirror } from 'kody:@kentcdodds/workflowy'
 *
 * const result = await deleteMirror({ id: 'ee1ac4c4-775e-1983-ae98-a8eeb92b1aca' })
 */
export async function deleteMirror(input: { id: string }): Promise<unknown> {
	const id = requireString(input?.id, 'id')
	return await request(`/api/v1/nodes/${encodeURIComponent(id)}/mirror`, {
		method: 'DELETE',
	})
}

/**
 * Export all nodes as a flat list when reconstructing the full outline tree.
 * Rate-limited to 1 request per minute — use sparingly and project large results.
 *
 * @returns `{ nodes }` unordered flat list — rebuild hierarchy via `parent_id` and `priority`.
 *
 * @example
 * import { exportNodes } from 'kody:@kentcdodds/workflowy'
 *
 * const { nodes } = await exportNodes() as { nodes: unknown[] }
 * return { count: nodes.length }
 */
export async function exportNodes(): Promise<unknown> {
	return await request('/api/v1/nodes-export')
}

/**
 * Verify WorkFlowy authentication with one read-only targets request when checking package readiness.
 *
 * @returns Compact summary: `ok`, `targetCount`, and up to eight target `keys` (no huge trees).
 *
 * @example
 * import { smokeTest } from 'kody:@kentcdodds/workflowy'
 *
 * const result = await smokeTest()
 */
export async function smokeTest(): Promise<{
	ok: true
	targetCount: number
	keys: string[]
	systemCount: number
	shortcutCount: number
}> {
	const response = (await listTargets()) as {
		targets?: Array<{ key?: unknown; type?: unknown }>
	}
	const targets = Array.isArray(response.targets) ? response.targets : []
	const keys = targets
		.map((target) => (typeof target.key === 'string' ? target.key : null))
		.filter((key): key is string => Boolean(key))
		.slice(0, 8)
	const systemCount = targets.filter((target) => target.type === 'system').length
	const shortcutCount = targets.filter((target) => target.type === 'shortcut').length
	return {
		ok: true,
		targetCount: targets.length,
		keys,
		systemCount,
		shortcutCount,
	}
}

/**
 * Describe common WorkFlowy actions and operational limits when choosing how to use the dispatcher.
 *
 * @returns Package identity, common actions, and outline/mirror/export guidance.
 *
 * @example
 * import { getOverview } from 'kody:@kentcdodds/workflowy'
 *
 * const overview = getOverview()
 */
export function getOverview(): {
	name: string
	description: string
	baseUrl: string
	commonActions: string[]
	notes: string[]
} {
	return {
		name: '@kentcdodds/workflowy',
		description:
			'Secret-backed WorkFlowy REST helpers for targets, nodes, mirrors, and exports.',
		baseUrl: API_BASE,
		commonActions: [
			'smokeTest',
			'listTargets',
			'listNodes',
			'getNode',
			'createNode',
			'updateNode',
			'moveNode',
			'completeNode',
			'uncompleteNode',
			'mirrorNode',
			'exportNodes',
		],
		notes: [
			'Auth uses user secret WORKFLOWY_API_KEY as Authorization: Bearer.',
			'parent_id accepts node ids, short ids, URLs, shortcuts, and calendar keys on most endpoints.',
			'mirrorNode parent_id must be a full existing node id (not inbox/today/None).',
			'exportNodes is limited to 1 request per minute.',
			'deleteNode is permanent; prefer completeNode when marking work done.',
		],
	}
}

/**
 * Dispatch WorkFlowy helpers by action name when an agent prefers one default callable export.
 *
 * @param input - An action plus that helper's parameters; defaults to `overview`.
 * @returns The selected helper result or package overview.
 *
 * @example
 * import workflowy from 'kody:@kentcdodds/workflowy'
 *
 * const targets = await workflowy({ action: 'listTargets' })
 */
export default async function workflowy(input: DispatcherInput = {}): Promise<unknown> {
	const action = input.action ?? 'overview'
	switch (action) {
		case 'overview':
		case 'getOverview':
			return getOverview()
		case 'smokeTest':
			return await smokeTest()
		case 'request':
			return await request(requireString(input.path, 'path'), {
				method: input.options?.method ?? input.method,
				query: input.options?.query ?? input.query,
				body: input.options?.body ?? input.body,
				headers: input.options?.headers ?? input.headers,
				maxAttempts: input.options?.maxAttempts ?? input.maxAttempts,
			})
		case 'listTargets':
			return await listTargets()
		case 'listNodes':
			return await listNodes(input)
		case 'getNode':
			return await getNode({ id: requireString(input.id, 'id') })
		case 'createNode':
			return await createNode({
				parentId: input.parentId,
				parent_id: input.parent_id,
				name: requireString(input.name, 'name'),
				note: input.note,
				layoutMode: input.layoutMode,
				position: input.position,
			})
		case 'updateNode':
			return await updateNode({
				id: requireString(input.id, 'id'),
				name: input.name,
				note: input.note,
				layoutMode: input.layoutMode,
			})
		case 'deleteNode':
			return await deleteNode({ id: requireString(input.id, 'id') })
		case 'moveNode':
			return await moveNode({
				id: requireString(input.id, 'id'),
				parentId: input.parentId,
				parent_id: input.parent_id,
				position: input.position,
			})
		case 'completeNode':
			return await completeNode({ id: requireString(input.id, 'id') })
		case 'uncompleteNode':
			return await uncompleteNode({ id: requireString(input.id, 'id') })
		case 'mirrorNode':
			return await mirrorNode({
				id: requireString(input.id, 'id'),
				parentId: input.parentId,
				parent_id: input.parent_id,
				position: input.position,
			})
		case 'deleteMirror':
			return await deleteMirror({ id: requireString(input.id, 'id') })
		case 'exportNodes':
			return await exportNodes()
		default:
			throw new Error(`Unknown workflowy action: ${action}`)
	}
}