Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/stash

src/lib/notes.ts

296 lines · 7.8 KB · TypeScript
import { extractEntities, extractTags, inferKind, scoreHaystack, summarize } from './kinds.ts'
import { ensureSchema, sql, toNote, type StashNote } from './db.ts'
import {
	createId,
	inputRecord,
	normalizeLimit,
	normalizeText,
	nowIso,
	optionalBoolean,
} from './util.ts'

export const defaultLimit = 10
export const maxLimit = 50

function scoreNote(note: StashNote, query: string): number {
	const haystack = [
		note.rawText,
		note.summary,
		note.kind,
		note.tags.join(' '),
		note.entities.join(' '),
	].join(' ')
	const base = scoreHaystack(haystack, query)
	const normalizedQuery = query.trim().toLowerCase()
	const tagBoost = note.tags.some(
		(tag) => normalizedQuery.includes(tag) || tag.includes(normalizedQuery),
	)
		? 0.2
		: 0
	return Math.min(1, base + tagBoost)
}

function dueAtFrom(body: Record<string, unknown>, fallback: string | null = null) {
	if (!('dueAt' in body)) return fallback
	return typeof body.dueAt === 'string' || body.dueAt === null ? body.dueAt : fallback
}

function previewNote(input: {
	id: string
	text: string
	kind: string
	tags: string[]
	entities: string[]
	dueAt: string | null
	archivedAt: string | null
	createdAt: string
	updatedAt: string
}): StashNote {
	return {
		id: input.id,
		rawText: input.text,
		summary: summarize(input.text),
		kind: input.kind,
		tags: input.tags,
		entities: input.entities,
		dueAt: input.dueAt,
		archivedAt: input.archivedAt,
		createdAt: input.createdAt,
		updatedAt: input.updatedAt,
	}
}

export async function addNote(input?: unknown) {
	const body = inputRecord(input ?? {})
	const text = normalizeText(body.text)
	if (!text) throw new Error('addNote requires non-empty text.')
	const kind = normalizeText(body.kind) || inferKind(text)
	const tags = extractTags(text, body.tags, kind)
	const entities = extractEntities(text)
	const id = createId()
	const timestamp = nowIso()
	const dueAt = dueAtFrom(body, null)
	const note = previewNote({
		id,
		text,
		kind,
		tags,
		entities,
		dueAt,
		archivedAt: null,
		createdAt: timestamp,
		updatedAt: timestamp,
	})
	if (optionalBoolean(body, 'dryRun')) {
		return { ok: true as const, dryRun: true as const, note }
	}
	await ensureSchema()
	await sql(
		'INSERT INTO notes (id, raw_text, summary, kind, tags_json, entities_json, due_at, archived_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)',
		[
			note.id,
			note.rawText,
			note.summary,
			note.kind,
			JSON.stringify(note.tags),
			JSON.stringify(note.entities),
			note.dueAt,
			note.createdAt,
			note.updatedAt,
		],
	)
	return { ok: true as const, dryRun: false as const, note }
}

export async function getNote(input?: unknown): Promise<StashNote | null> {
	const body = inputRecord(input ?? {})
	const id = normalizeText(body.id)
	if (!id) throw new Error('getNote requires an id.')
	await ensureSchema()
	const result = await sql('SELECT * FROM notes WHERE id = ? LIMIT 1', [id])
	const [row] = result.rows ?? []
	return row ? toNote(row) : null
}

export async function updateNote(input?: unknown) {
	const body = inputRecord(input ?? {})
	const id = normalizeText(body.id)
	if (!id) throw new Error('updateNote requires an id.')
	const existing = await getNote({ id })
	if (!existing) throw new Error(`Note ${id} was not found.`)
	const text = normalizeText(body.text) || existing.rawText
	const kind = normalizeText(body.kind) || inferKind(text)
	const tags = extractTags(
		text,
		Array.isArray(body.tags) ? body.tags : existing.tags,
		kind,
	)
	const entities = extractEntities(text)
	const dueAt = dueAtFrom(body, existing.dueAt)
	const updatedAt = nowIso()
	const note = previewNote({
		id,
		text,
		kind,
		tags,
		entities,
		dueAt,
		archivedAt: existing.archivedAt,
		createdAt: existing.createdAt,
		updatedAt,
	})
	if (optionalBoolean(body, 'dryRun')) {
		return { ok: true as const, dryRun: true as const, note }
	}
	await sql(
		'UPDATE notes SET raw_text = ?, summary = ?, kind = ?, tags_json = ?, entities_json = ?, due_at = ?, updated_at = ? WHERE id = ?',
		[
			note.rawText,
			note.summary,
			note.kind,
			JSON.stringify(note.tags),
			JSON.stringify(note.entities),
			note.dueAt,
			note.updatedAt,
			id,
		],
	)
	return { ok: true as const, dryRun: false as const, note: await getNote({ id }) }
}

async function selectNotes(includeArchived: boolean): Promise<StashNote[]> {
	try {
		await ensureSchema()
		const result = await sql(
			includeArchived
				? 'SELECT * FROM notes ORDER BY created_at DESC LIMIT 500'
				: 'SELECT * FROM notes WHERE archived_at IS NULL ORDER BY created_at DESC LIMIT 500',
		)
		return (result.rows ?? []).map(toNote)
	} catch (error) {
		if (error instanceof Error && /no such table/i.test(error.message)) return []
		throw error
	}
}

export async function searchNotes(input?: unknown) {
	const body = inputRecord(input ?? {})
	const query = normalizeText(body.query)
	const limit = normalizeLimit(body.limit, defaultLimit, maxLimit)
	const notes = await selectNotes(body.includeArchived === true)
	const matches = notes
		.map((note) => ({ note, score: scoreNote(note, query) }))
		.filter((entry) => !query || entry.score > 0)
		.sort(
			(left, right) =>
				right.score - left.score ||
				right.note.createdAt.localeCompare(left.note.createdAt),
		)
		.slice(0, limit)
	return { query, matches }
}

export async function listRecent(input?: unknown) {
	const body = inputRecord(input ?? {})
	const limit = normalizeLimit(body.limit, 20, maxLimit)
	const notes = await selectNotes(body.includeArchived === true)
	return { notes: notes.slice(0, limit) }
}

export async function listNotesByTag(input?: unknown) {
	const body = inputRecord(input ?? {})
	const tag = normalizeText(body.tag).toLowerCase()
	if (!tag) throw new Error('listNotesByTag requires a tag.')
	const limit = normalizeLimit(body.limit, 100, 500)
	const notes = await selectNotes(body.includeArchived === true)
	const matches = notes
		.filter((note) => note.tags.includes(tag))
		.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
		.slice(0, limit)
	return { tag, count: matches.length, notes: matches }
}

export async function archiveNote(input?: unknown) {
	const body = inputRecord(input ?? {})
	const id = normalizeText(body.id)
	if (!id) throw new Error('archiveNote requires an id.')
	await ensureSchema()
	const existing = await sql('SELECT * FROM notes WHERE id = ? LIMIT 1', [id])
	const [row] = existing.rows ?? []
	if (!row) {
		return {
			ok: false as const,
			dryRun: optionalBoolean(body, 'dryRun'),
			id,
			error: 'not-found' as const,
			message: `Note ${id} was not found.`,
		}
	}
	const timestamp = nowIso()
	if (optionalBoolean(body, 'dryRun')) {
		return {
			ok: true as const,
			dryRun: true as const,
			id,
			archivedAt: timestamp,
			note: toNote(row),
		}
	}
	await sql('UPDATE notes SET archived_at = ?, updated_at = ? WHERE id = ?', [
		timestamp,
		timestamp,
		id,
	])
	return { ok: true as const, dryRun: false as const, id, archivedAt: timestamp }
}

export async function retrieve(input?: unknown) {
	const body = inputRecord(input ?? {})
	const limit = normalizeLimit(body.limit, 5, maxLimit)
	const result = await searchNotes({
		query: normalizeText(body.query),
		limit,
	})
	return {
		results: result.matches.map((match) => ({
			id: match.note.id,
			title: match.note.summary,
			summary: match.note.summary,
			details: match.note.rawText,
			score: match.score,
			source: 'stash',
			metadata: {
				kind: match.note.kind,
				tags: match.note.tags,
				entities: match.note.entities,
				dueAt: match.note.dueAt,
				archivedAt: match.note.archivedAt,
			},
		})),
	}
}

export function overview() {
	return {
		auth: 'none',
		storage: 'packageStorage',
		exports: [
			'add',
			'search',
			'recent',
			'by-tag',
			'get',
			'update',
			'archive',
			'retriever',
			'app',
		],
		safety: {
			oauthRequired: false,
			secretsRequired: false,
			hostsRequired: [],
			mutationsSupportDryRun: true,
		},
		forkUrl: 'https://kody.codes/@kody/stash',
	}
}