Skip to content
← Public packages

@kody/personal-capture

Quick onboarding example: stash notes/links in packageStorage; list recent captures.

src/index.ts

155 lines · 4.3 KB · TypeScript
import { kody, packageStorage } from 'kody:runtime'

const INDEX_KEY = 'captures:index'
const ITEM_PREFIX = 'captures:item:'

export type CaptureInput = {
	/** Note text to stash. */
	text: string
	/** Optional related URL. */
	url?: string
	/** When true, email yourself a short confirmation via kody.email_send. */
	notify?: boolean
}

export type ListCapturesInput = {
	/** Max items to return (1–50, default 20). */
	limit?: number
}

export type CaptureItem = {
	id: string
	text: string
	url: string | null
	createdAt: string
}

function requireText(text: unknown): string {
	if (typeof text !== 'string' || text.trim() === '') {
		throw new Error('text is required.')
	}
	return text.trim()
}

function clampLimit(limit: unknown, fallback: number): number {
	const n = Number(limit)
	if (!Number.isFinite(n)) return fallback
	return Math.min(50, Math.max(1, Math.floor(n)))
}

function newId(): string {
	return (
		Date.now().toString(36) +
		'-' +
		Math.random().toString(36).slice(2, 10)
	)
}

async function readIndex(store: ReturnType<typeof packageStorage>): Promise<string[]> {
	const raw = await store.get(INDEX_KEY)
	if (!raw) return []
	try {
		const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
		return Array.isArray(parsed) ? parsed.map(String) : []
	} catch {
		return []
	}
}

/**
 * Save a note/link into this package's packageStorage bucket.
 * Prefer the `./capture` export from callers; this named helper is shared with the root dispatcher.
 *
 * @param input.text - Note text to stash (required).
 * @param input.url - Optional related URL.
 * @param input.notify - When true, email a short confirmation.
 * @returns The stored capture item.
 *
 * @example
 * import { capture } from 'kody:@kody/personal-capture/capture'
 * const item = await capture({ text: 'Follow up on Sentry triage' })
 */
export async function capture(input: CaptureInput): Promise<CaptureItem> {
	const text = requireText(input?.text)
	const url =
		typeof input?.url === 'string' && input.url.trim()
			? input.url.trim()
			: null
	const store = packageStorage()
	const item: CaptureItem = {
		id: newId(),
		text,
		url,
		createdAt: new Date().toISOString(),
	}

	const index = await readIndex(store)
	index.unshift(item.id)
	// Keep the index bounded so the example stays tiny.
	const trimmed = index.slice(0, 200)
	await store.set(ITEM_PREFIX + item.id, JSON.stringify(item))
	await store.set(INDEX_KEY, JSON.stringify(trimmed))

	if (input?.notify === true) {
		const subject = 'Captured: ' + item.text.slice(0, 80)
		const lines = [
			item.text,
			item.url ? 'URL: ' + item.url : null,
			'id: ' + item.id,
			'at: ' + item.createdAt,
		].filter(Boolean)
		await kody.emailSend({ subject, text: lines.join('\n') })
	}

	return item
}

/**
 * List recent captures newest first from packageStorage.
 * Prefer the `./listCaptures` export from callers.
 *
 * @param input.limit - Max items to return (1–50, default 20).
 * @returns Recent capture items newest first.
 *
 * @example
 * import { listCaptures } from 'kody:@kody/personal-capture/listCaptures'
 * const items = await listCaptures({ limit: 10 })
 */
export async function listCaptures(
	input: ListCapturesInput = {},
): Promise<CaptureItem[]> {
	const limit = clampLimit(input?.limit, 20)
	const store = packageStorage()
	const index = await readIndex(store)
	const ids = index.slice(0, limit)
	const items: CaptureItem[] = []
	for (const id of ids) {
		const raw = await store.get(ITEM_PREFIX + id)
		if (!raw) continue
		try {
			const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
			if (parsed && typeof parsed === 'object' && parsed.id) {
				items.push({
					id: String(parsed.id),
					text: String(parsed.text || ''),
					url: parsed.url ? String(parsed.url) : null,
					createdAt: String(parsed.createdAt || ''),
				})
			}
		} catch {
			// skip corrupt rows in this tiny example
		}
	}
	return items
}

/** Root dispatcher for packages.invoke({ exportName: '.', params }). */
export default async function personalCapture(
	input: (CaptureInput & { action?: string }) | (ListCapturesInput & { action?: string }) = { text: '' },
) {
	const action = String((input as { action?: string })?.action || 'capture')
	if (action === 'listCaptures' || action === 'list') {
		return listCaptures(input as ListCapturesInput)
	}
	return capture(input as CaptureInput)
}