mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Add Quick Notes example plugin demonstrating core plugin surfaces
Mirrors the Bookmarks example (PAP-3575) with a company-scoped note library: database namespace, scoped API routes, page/sidebar/widget/ settings UI slots. All 7 tests pass. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d981d6bd08
commit
571fbac248
10 files changed
+1103
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import esbuild from "esbuild";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const workerCtx = await esbuild.context(presets.esbuild.worker);
|
||||
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
|
||||
const uiCtx = await esbuild.context(presets.esbuild.ui);
|
||||
|
||||
if (watch) {
|
||||
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
|
||||
console.log("esbuild watch mode enabled for worker, manifest, and ui");
|
||||
} else {
|
||||
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
|
||||
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE plugin_quick_notes_b34a9f8617.notes (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL,
|
||||
title text NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
tags text[] NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX notes_company_idx
|
||||
ON plugin_quick_notes_b34a9f8617.notes (company_id, created_at DESC);
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-quick-notes-example",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "First-party example plugin: company-scoped note library backed by a plugin database namespace.",
|
||||
"scripts": {
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "node ./esbuild.config.mjs",
|
||||
"build:rollup": "rollup -c",
|
||||
"dev": "node ./esbuild.config.mjs --watch",
|
||||
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4179",
|
||||
"test": "vitest run --config ./vitest.config.ts",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"example",
|
||||
"notes",
|
||||
"knowledge"
|
||||
],
|
||||
"author": "Paperclip",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"esbuild": "^0.27.3",
|
||||
"rollup": "^4.38.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export const PLUGIN_ID = "paperclipai.plugin-quick-notes-example";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Quick Notes (Example)",
|
||||
description:
|
||||
"First-party example plugin: company-scoped note library backed by a plugin database namespace. Demonstrates scoped API routes, a database namespace, dashboard widget, and a plugin page.",
|
||||
author: "Paperclip",
|
||||
categories: ["workspace", "ui"],
|
||||
capabilities: [
|
||||
"api.routes.register",
|
||||
"database.namespace.migrate",
|
||||
"database.namespace.read",
|
||||
"database.namespace.write",
|
||||
"companies.read",
|
||||
"ui.dashboardWidget.register",
|
||||
"ui.page.register",
|
||||
"ui.sidebar.register",
|
||||
"instance.settings.register",
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
database: {
|
||||
namespaceSlug: "quick_notes",
|
||||
migrationsDir: "migrations",
|
||||
},
|
||||
apiRoutes: [
|
||||
{
|
||||
routeKey: "list",
|
||||
method: "GET",
|
||||
path: "/notes",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "query", key: "companyId" },
|
||||
},
|
||||
{
|
||||
routeKey: "create",
|
||||
method: "POST",
|
||||
path: "/notes",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "body", key: "companyId" },
|
||||
},
|
||||
{
|
||||
routeKey: "delete",
|
||||
method: "DELETE",
|
||||
path: "/notes/:id",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "query", key: "companyId" },
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "page",
|
||||
id: "quick-notes-page",
|
||||
displayName: "Quick Notes",
|
||||
exportName: "QuickNotesPage",
|
||||
routePath: "quick-notes",
|
||||
},
|
||||
{
|
||||
type: "sidebar",
|
||||
id: "quick-notes-sidebar-link",
|
||||
displayName: "Quick Notes",
|
||||
exportName: "QuickNotesSidebarLink",
|
||||
},
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: "quick-notes-widget",
|
||||
displayName: "Quick Notes",
|
||||
exportName: "QuickNotesDashboardWidget",
|
||||
},
|
||||
{
|
||||
type: "settingsPage",
|
||||
id: "quick-notes-settings",
|
||||
displayName: "Quick Notes",
|
||||
exportName: "QuickNotesSettingsPage",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,325 @@
|
||||
import {
|
||||
usePluginAction,
|
||||
usePluginData,
|
||||
type PluginPageProps,
|
||||
type PluginSettingsPageProps,
|
||||
type PluginSidebarProps,
|
||||
type PluginWidgetProps,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
import { useState, useMemo, type CSSProperties, type FormEvent } from "react";
|
||||
|
||||
interface NoteRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface NoteListResult {
|
||||
databaseNamespace: string;
|
||||
notes: NoteRecord[];
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
display: "grid",
|
||||
gap: 16,
|
||||
font: "13px system-ui, sans-serif",
|
||||
color: "#111827",
|
||||
padding: 16,
|
||||
maxWidth: 760,
|
||||
};
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
background: "#fff",
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
padding: "6px 8px",
|
||||
font: "inherit",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const buttonStyle: CSSProperties = {
|
||||
border: "1px solid #1f2937",
|
||||
background: "#111827",
|
||||
color: "#fff",
|
||||
borderRadius: 6,
|
||||
padding: "6px 10px",
|
||||
font: "inherit",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const subtleButtonStyle: CSSProperties = {
|
||||
border: "1px solid #d1d5db",
|
||||
background: "#fff",
|
||||
color: "#111827",
|
||||
borderRadius: 6,
|
||||
padding: "4px 8px",
|
||||
font: "inherit",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const tagStyle: CSSProperties = {
|
||||
display: "inline-block",
|
||||
border: "1px solid #cbd5f5",
|
||||
background: "#eef2ff",
|
||||
color: "#3730a3",
|
||||
borderRadius: 999,
|
||||
padding: "1px 8px",
|
||||
marginRight: 4,
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
function parseTagInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function NotesList({
|
||||
result,
|
||||
onDelete,
|
||||
loading,
|
||||
}: {
|
||||
result: NoteListResult | null;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (loading) return <div>Loading notes…</div>;
|
||||
if (!result) return null;
|
||||
if (result.notes.length === 0) {
|
||||
return <div style={{ color: "#6b7280" }}>No notes yet.</div>;
|
||||
}
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
{result.notes.map((note) => (
|
||||
<div key={note.id} style={cardStyle}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
||||
<strong>{note.title}</strong>
|
||||
<button
|
||||
type="button"
|
||||
style={subtleButtonStyle}
|
||||
onClick={() => {
|
||||
void onDelete(note.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{note.tags.length > 0 ? (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{note.tags.map((tag) => (
|
||||
<span key={tag} style={tagStyle}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{note.body ? (
|
||||
<div style={{ marginTop: 6, whiteSpace: "pre-wrap" }}>{note.body}</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: 6, color: "#9ca3af", fontSize: 11 }}>
|
||||
{formatDate(note.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesSurface({ companyId }: { companyId: string }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [tag, setTag] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [tagsRaw, setTagsRaw] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({ companyId, search: search.trim() || null, tag: tag.trim() || null }),
|
||||
[companyId, search, tag],
|
||||
);
|
||||
|
||||
const { data, loading, error, refresh } = usePluginData<NoteListResult>("list", listParams);
|
||||
const create = usePluginAction("create");
|
||||
const remove = usePluginAction("delete");
|
||||
|
||||
async function handleCreate(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await create({
|
||||
companyId,
|
||||
title,
|
||||
body: body || null,
|
||||
tags: parseTagInput(tagsRaw),
|
||||
});
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setTagsRaw("");
|
||||
refresh();
|
||||
} catch (cause) {
|
||||
setErrorMessage(cause instanceof Error ? cause.message : "Failed to add note");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await remove({ companyId, id });
|
||||
refresh();
|
||||
} catch (cause) {
|
||||
setErrorMessage(cause instanceof Error ? cause.message : "Failed to delete note");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<header style={{ display: "grid", gap: 4 }}>
|
||||
<h1 style={{ fontSize: 20, margin: 0 }}>Quick Notes</h1>
|
||||
<div style={{ color: "#6b7280" }}>
|
||||
Company-scoped note library backed by a plugin database namespace.
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleCreate} style={{ ...cardStyle, display: "grid", gap: 8 }}>
|
||||
<strong>Add note</strong>
|
||||
<input
|
||||
style={inputStyle}
|
||||
required
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="Tags (comma or space separated)"
|
||||
value={tagsRaw}
|
||||
onChange={(event) => setTagsRaw(event.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
style={{ ...inputStyle, minHeight: 80, resize: "vertical" }}
|
||||
placeholder="Body (optional)"
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<button type="submit" style={buttonStyle}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{errorMessage ? <div style={{ color: "#b91c1c" }}>{errorMessage}</div> : null}
|
||||
</form>
|
||||
|
||||
<div style={{ ...cardStyle, display: "grid", gap: 8 }}>
|
||||
<strong>Search</strong>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="Search title or body"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
style={{ ...inputStyle, maxWidth: 180 }}
|
||||
placeholder="Filter by tag"
|
||||
value={tag}
|
||||
onChange={(event) => setTag(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div style={{ color: "#b91c1c" }}>{error.message}</div> : null}
|
||||
<NotesList result={data ?? null} onDelete={handleDelete} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissingCompanyNotice({ surface }: { surface: string }) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={cardStyle}>
|
||||
<strong>{surface}</strong>
|
||||
<div style={{ color: "#6b7280", marginTop: 4 }}>
|
||||
Open this plugin from a company workspace — notes are scoped per company.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickNotesPage({ context }: PluginPageProps) {
|
||||
if (!context.companyId) return <MissingCompanyNotice surface="Quick Notes" />;
|
||||
return <NotesSurface companyId={context.companyId} />;
|
||||
}
|
||||
|
||||
export function QuickNotesSettingsPage({ context }: PluginSettingsPageProps) {
|
||||
const companyId = context.companyId ?? "";
|
||||
const { data, loading, error } = usePluginData<NoteListResult>(
|
||||
"list",
|
||||
companyId ? { companyId, limit: 1 } : undefined,
|
||||
);
|
||||
if (!companyId) return <MissingCompanyNotice surface="Quick Notes settings" />;
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>Quick Notes settings</h1>
|
||||
<div style={cardStyle}>
|
||||
<div style={{ display: "grid", gap: 4 }}>
|
||||
<div>
|
||||
<strong>Database namespace:</strong>{" "}
|
||||
<code>{loading ? "…" : (data?.databaseNamespace ?? "not configured")}</code>
|
||||
</div>
|
||||
{error ? <div style={{ color: "#b91c1c" }}>{error.message}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickNotesDashboardWidget({ context }: PluginWidgetProps) {
|
||||
const companyId = context.companyId;
|
||||
const { data, loading, error } = usePluginData<NoteListResult>(
|
||||
"list",
|
||||
companyId ? { companyId, limit: 5 } : undefined,
|
||||
);
|
||||
|
||||
if (!companyId) return null;
|
||||
if (loading) return <div style={{ font: "12px system-ui, sans-serif" }}>Loading notes…</div>;
|
||||
if (error) return <div style={{ color: "#b91c1c" }}>Notes error: {error.message}</div>;
|
||||
const notes = data?.notes ?? [];
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 6, font: "13px system-ui, sans-serif" }}>
|
||||
<strong>Recent notes</strong>
|
||||
{notes.length === 0 ? (
|
||||
<div style={{ color: "#6b7280" }}>No notes yet.</div>
|
||||
) : (
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{notes.map((note) => (
|
||||
<li key={note.id}>{note.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickNotesSidebarLink(_props: PluginSidebarProps) {
|
||||
return <span>Quick Notes</span>;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
definePlugin,
|
||||
runWorker,
|
||||
type PluginApiRequestInput,
|
||||
type PluginApiResponse,
|
||||
type PluginContext,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
|
||||
export interface NoteRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface NoteInput {
|
||||
title: string;
|
||||
body?: string | null;
|
||||
tags?: string[] | null;
|
||||
}
|
||||
|
||||
export interface NoteListResult {
|
||||
databaseNamespace: string;
|
||||
notes: NoteRecord[];
|
||||
}
|
||||
|
||||
const MAX_TAGS = 16;
|
||||
const MAX_TAG_LENGTH = 32;
|
||||
const MAX_TITLE_LENGTH = 200;
|
||||
const MAX_BODY_LENGTH = 10_000;
|
||||
const MAX_NOTES_PER_LIST = 200;
|
||||
|
||||
function tableName(namespace: string): string {
|
||||
return `${namespace}.notes`;
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeTitle(value: unknown): string {
|
||||
const raw = asNonEmptyString(value);
|
||||
if (!raw) throw new Error("`title` is required");
|
||||
return raw.length > MAX_TITLE_LENGTH ? raw.slice(0, MAX_TITLE_LENGTH) : raw;
|
||||
}
|
||||
|
||||
function normalizeBody(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value !== "string") throw new Error("`body` must be a string when provided");
|
||||
if (value.length > MAX_BODY_LENGTH) {
|
||||
throw new Error(`\`body\` must be ${MAX_BODY_LENGTH} characters or fewer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (value == null) return [];
|
||||
if (!Array.isArray(value)) throw new Error("`tags` must be an array of strings when provided");
|
||||
const seen = new Set<string>();
|
||||
const tags: string[] = [];
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== "string") throw new Error("`tags` must contain only strings");
|
||||
const cleaned = entry.trim().toLowerCase();
|
||||
if (!cleaned) continue;
|
||||
if (cleaned.length > MAX_TAG_LENGTH) {
|
||||
throw new Error(`tag "${entry}" exceeds ${MAX_TAG_LENGTH} characters`);
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9_-]*$/.test(cleaned)) {
|
||||
throw new Error(
|
||||
`tag "${entry}" must use lowercase letters, digits, hyphens, or underscores`,
|
||||
);
|
||||
}
|
||||
if (seen.has(cleaned)) continue;
|
||||
if (tags.length >= MAX_TAGS) throw new Error(`notes accept at most ${MAX_TAGS} tags`);
|
||||
seen.add(cleaned);
|
||||
tags.push(cleaned);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function escapeLikePattern(value: string): string {
|
||||
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
||||
}
|
||||
|
||||
interface NoteRow {
|
||||
id: string;
|
||||
company_id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
created_at: string | Date;
|
||||
updated_at: string | Date;
|
||||
}
|
||||
|
||||
function rowToNote(row: NoteRow): NoteRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.company_id,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
tags: Array.isArray(row.tags) ? [...row.tags] : [],
|
||||
createdAt: typeof row.created_at === "string" ? row.created_at : row.created_at.toISOString(),
|
||||
updatedAt: typeof row.updated_at === "string" ? row.updated_at : row.updated_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function listNotes(
|
||||
ctx: PluginContext,
|
||||
companyId: string,
|
||||
search: string | null,
|
||||
tag: string | null,
|
||||
limit: number,
|
||||
): Promise<NoteRecord[]> {
|
||||
const params: unknown[] = [companyId];
|
||||
let where = "company_id = $1";
|
||||
if (search) {
|
||||
params.push(`%${escapeLikePattern(search.toLowerCase())}%`);
|
||||
const idx = params.length;
|
||||
where +=
|
||||
` AND (lower(title) LIKE $${idx} ESCAPE '\\'` +
|
||||
` OR lower(body) LIKE $${idx} ESCAPE '\\')`;
|
||||
}
|
||||
if (tag) {
|
||||
params.push(tag);
|
||||
where += ` AND $${params.length} = ANY(tags)`;
|
||||
}
|
||||
params.push(Math.min(Math.max(limit, 1), MAX_NOTES_PER_LIST));
|
||||
const rows = await ctx.db.query<NoteRow>(
|
||||
`SELECT id, company_id, title, body, tags, created_at, updated_at
|
||||
FROM ${tableName(ctx.db.namespace)}
|
||||
WHERE ${where}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${params.length}`,
|
||||
params,
|
||||
);
|
||||
return rows.map(rowToNote);
|
||||
}
|
||||
|
||||
export async function createNote(
|
||||
ctx: PluginContext,
|
||||
companyId: string,
|
||||
input: NoteInput,
|
||||
): Promise<NoteRecord> {
|
||||
const title = normalizeTitle(input.title);
|
||||
const body = normalizeBody(input.body);
|
||||
const tags = normalizeTags(input.tags);
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await ctx.db.execute(
|
||||
`INSERT INTO ${tableName(ctx.db.namespace)}
|
||||
(id, company_id, title, body, tags, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6)`,
|
||||
[id, companyId, title, body, tags, now],
|
||||
);
|
||||
|
||||
return { id, companyId, title, body, tags, createdAt: now, updatedAt: now };
|
||||
}
|
||||
|
||||
export async function deleteNote(
|
||||
ctx: PluginContext,
|
||||
companyId: string,
|
||||
id: string,
|
||||
): Promise<{ deleted: boolean; id: string }> {
|
||||
const result = await ctx.db.execute(
|
||||
`DELETE FROM ${tableName(ctx.db.namespace)} WHERE company_id = $1 AND id = $2`,
|
||||
[companyId, id],
|
||||
);
|
||||
return { deleted: result.rowCount > 0, id };
|
||||
}
|
||||
|
||||
let listHandler:
|
||||
| ((
|
||||
companyId: string,
|
||||
search: string | null,
|
||||
tag: string | null,
|
||||
limit: number,
|
||||
) => Promise<NoteListResult>)
|
||||
| null = null;
|
||||
let createHandler: ((companyId: string, input: NoteInput) => Promise<NoteRecord>) | null = null;
|
||||
let deleteHandler:
|
||||
| ((companyId: string, id: string) => Promise<{ deleted: boolean; id: string }>)
|
||||
| null = null;
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
listHandler = (companyId, search, tag, limit) =>
|
||||
listNotes(ctx, companyId, search, tag, limit).then((notes) => ({
|
||||
databaseNamespace: ctx.db.namespace,
|
||||
notes,
|
||||
}));
|
||||
createHandler = (companyId, input) => createNote(ctx, companyId, input);
|
||||
deleteHandler = (companyId, id) => deleteNote(ctx, companyId, id);
|
||||
|
||||
ctx.data.register("list", async (params) => {
|
||||
const companyId = asNonEmptyString(params.companyId);
|
||||
if (!companyId) throw new Error("companyId is required");
|
||||
const search = asNonEmptyString(params.search)?.toLowerCase() ?? null;
|
||||
const tag = asNonEmptyString(params.tag)?.toLowerCase() ?? null;
|
||||
const limit = typeof params.limit === "number" ? params.limit : 50;
|
||||
if (!listHandler) throw new Error("Quick Notes plugin not ready");
|
||||
return listHandler(companyId, search, tag, limit);
|
||||
});
|
||||
|
||||
ctx.actions.register("create", async (params) => {
|
||||
const companyId = asNonEmptyString(params.companyId);
|
||||
if (!companyId) throw new Error("companyId is required");
|
||||
if (!createHandler) throw new Error("Quick Notes plugin not ready");
|
||||
return createHandler(companyId, {
|
||||
title: typeof params.title === "string" ? params.title : "",
|
||||
body: typeof params.body === "string" ? params.body : null,
|
||||
tags: Array.isArray(params.tags) ? (params.tags as unknown[] as string[]) : null,
|
||||
});
|
||||
});
|
||||
|
||||
ctx.actions.register("delete", async (params) => {
|
||||
const companyId = asNonEmptyString(params.companyId);
|
||||
const id = asNonEmptyString(params.id);
|
||||
if (!companyId || !id) throw new Error("companyId and id are required");
|
||||
if (!deleteHandler) throw new Error("Quick Notes plugin not ready");
|
||||
return deleteHandler(companyId, id);
|
||||
});
|
||||
},
|
||||
|
||||
async onApiRequest(input: PluginApiRequestInput): Promise<PluginApiResponse> {
|
||||
switch (input.routeKey) {
|
||||
case "list": {
|
||||
if (!listHandler) return { status: 503, body: { error: "Quick Notes plugin not ready" } };
|
||||
const search = asNonEmptyString(input.query.search)?.toLowerCase() ?? null;
|
||||
const tag = asNonEmptyString(input.query.tag)?.toLowerCase() ?? null;
|
||||
const limitRaw = asNonEmptyString(input.query.limit);
|
||||
const parsedLimit = limitRaw ? Number.parseInt(limitRaw, 10) : 50;
|
||||
const limit = Number.isFinite(parsedLimit) ? parsedLimit : 50;
|
||||
return { body: await listHandler(input.companyId, search, tag, limit) };
|
||||
}
|
||||
case "create": {
|
||||
if (!createHandler) return { status: 503, body: { error: "Quick Notes plugin not ready" } };
|
||||
const body = (input.body ?? {}) as Record<string, unknown>;
|
||||
try {
|
||||
const note = await createHandler(input.companyId, {
|
||||
title: typeof body.title === "string" ? body.title : "",
|
||||
body: typeof body.body === "string" ? body.body : null,
|
||||
tags: Array.isArray(body.tags) ? (body.tags as unknown[] as string[]) : null,
|
||||
});
|
||||
return { status: 201, body: note };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: error instanceof Error ? error.message : "Invalid note" },
|
||||
};
|
||||
}
|
||||
}
|
||||
case "delete": {
|
||||
if (!deleteHandler) return { status: 503, body: { error: "Quick Notes plugin not ready" } };
|
||||
const id = input.params.id;
|
||||
if (!id) return { status: 400, body: { error: "id is required" } };
|
||||
const companyId = asNonEmptyString(input.query.companyId) ?? input.companyId;
|
||||
const result = await deleteHandler(companyId, id);
|
||||
return { status: result.deleted ? 200 : 404, body: result };
|
||||
}
|
||||
default:
|
||||
return { status: 404, body: { error: `Unknown quick-notes route: ${input.routeKey}` } };
|
||||
}
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return {
|
||||
status: "ok",
|
||||
message: "Quick Notes plugin worker is running",
|
||||
details: {
|
||||
surfaces: ["scoped-api-route", "database-namespace", "page", "dashboard-widget"],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,290 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pluginManifestV1Schema } from "@paperclipai/shared";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest, { PLUGIN_ID } from "../src/manifest.js";
|
||||
import plugin, {
|
||||
type NoteListResult,
|
||||
type NoteRecord,
|
||||
createNote,
|
||||
deleteNote,
|
||||
} from "../src/worker.js";
|
||||
|
||||
interface NoteRow {
|
||||
id: string;
|
||||
company_id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function makeInMemoryNotesDb() {
|
||||
const rows: NoteRow[] = [];
|
||||
return {
|
||||
rows,
|
||||
namespace: "plugin_quick_notes_test",
|
||||
async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
||||
if (!sql.includes("FROM plugin_quick_notes_test.notes")) return [];
|
||||
const companyId = params?.[0] as string;
|
||||
let filtered = rows.filter((row) => row.company_id === companyId);
|
||||
if (sql.includes("LIKE $2")) {
|
||||
const like = (params?.[1] as string) ?? "";
|
||||
const inner = like.startsWith("%") && like.endsWith("%") ? like.slice(1, -1) : like;
|
||||
const needle = inner.replace(/\\(.)/g, "$1").toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(row) =>
|
||||
row.title.toLowerCase().includes(needle) || row.body.toLowerCase().includes(needle),
|
||||
);
|
||||
}
|
||||
const tagParamIndex = sql.match(/\$(\d+) = ANY\(tags\)/)?.[1];
|
||||
if (tagParamIndex) {
|
||||
const tag = params?.[Number(tagParamIndex) - 1] as string;
|
||||
filtered = filtered.filter((row) => row.tags.includes(tag));
|
||||
}
|
||||
filtered = [...filtered].sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||
const limitParamIndex = sql.match(/LIMIT \$(\d+)/)?.[1];
|
||||
if (limitParamIndex) {
|
||||
const limit = params?.[Number(limitParamIndex) - 1] as number;
|
||||
filtered = filtered.slice(0, limit);
|
||||
}
|
||||
return filtered as unknown as T[];
|
||||
},
|
||||
async execute(sql: string, params?: unknown[]): Promise<{ rowCount: number }> {
|
||||
if (sql.startsWith("INSERT INTO plugin_quick_notes_test.notes")) {
|
||||
const [id, companyId, title, body, tags, createdAt] = params as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string[],
|
||||
string,
|
||||
];
|
||||
rows.push({
|
||||
id,
|
||||
company_id: companyId,
|
||||
title,
|
||||
body,
|
||||
tags: [...tags],
|
||||
created_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
});
|
||||
return { rowCount: 1 };
|
||||
}
|
||||
if (sql.startsWith("DELETE FROM plugin_quick_notes_test.notes")) {
|
||||
const [companyId, id] = params as [string, string];
|
||||
const before = rows.length;
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
if (rows[i]!.company_id === companyId && rows[i]!.id === id) rows.splice(i, 1);
|
||||
}
|
||||
return { rowCount: before - rows.length };
|
||||
}
|
||||
return { rowCount: 0 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeHarness() {
|
||||
const harness = createTestHarness({ manifest });
|
||||
const db = makeInMemoryNotesDb();
|
||||
harness.ctx.db = db;
|
||||
return { harness, db };
|
||||
}
|
||||
|
||||
describe("plugin-quick-notes-example manifest", () => {
|
||||
it("declares the expected core surface capabilities", () => {
|
||||
const parsed = pluginManifestV1Schema.parse(manifest);
|
||||
expect(parsed.id).toBe(PLUGIN_ID);
|
||||
expect(parsed.capabilities).toEqual(
|
||||
expect.arrayContaining([
|
||||
"api.routes.register",
|
||||
"database.namespace.migrate",
|
||||
"database.namespace.read",
|
||||
"database.namespace.write",
|
||||
"ui.page.register",
|
||||
"ui.dashboardWidget.register",
|
||||
]),
|
||||
);
|
||||
expect(parsed.database).toMatchObject({
|
||||
namespaceSlug: "quick_notes",
|
||||
migrationsDir: "migrations",
|
||||
});
|
||||
expect(parsed.apiRoutes).toEqual([
|
||||
expect.objectContaining({ routeKey: "list", method: "GET" }),
|
||||
expect.objectContaining({ routeKey: "create", method: "POST" }),
|
||||
expect.objectContaining({ routeKey: "delete", method: "DELETE" }),
|
||||
]);
|
||||
expect(parsed.ui?.slots ?? []).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "quick-notes-page", type: "page" }),
|
||||
expect.objectContaining({ id: "quick-notes-widget", type: "dashboardWidget" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin-quick-notes-example worker", () => {
|
||||
it("creates, lists, and deletes notes via registered handlers", async () => {
|
||||
const companyId = "22222222-2222-2222-2222-222222222222";
|
||||
const { harness } = makeHarness();
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const created = await harness.performAction<NoteRecord>("create", {
|
||||
companyId,
|
||||
title: "Hello World",
|
||||
body: "This is my first note.",
|
||||
tags: ["Docs", "Demo", "demo"],
|
||||
});
|
||||
expect(created.companyId).toBe(companyId);
|
||||
expect(created.title).toBe("Hello World");
|
||||
expect(created.tags).toEqual(["docs", "demo"]);
|
||||
|
||||
const second = await harness.performAction<NoteRecord>("create", {
|
||||
companyId,
|
||||
title: "Second Note",
|
||||
tags: ["demo"],
|
||||
});
|
||||
|
||||
const allListed = await harness.getData<NoteListResult>("list", { companyId });
|
||||
expect(allListed.databaseNamespace).toBe("plugin_quick_notes_test");
|
||||
expect(allListed.notes.map((n) => n.title).sort()).toEqual(["Hello World", "Second Note"]);
|
||||
|
||||
const filtered = await harness.getData<NoteListResult>("list", {
|
||||
companyId,
|
||||
tag: "docs",
|
||||
});
|
||||
expect(filtered.notes.map((n) => n.title)).toEqual(["Hello World"]);
|
||||
|
||||
const searched = await harness.getData<NoteListResult>("list", {
|
||||
companyId,
|
||||
search: "second",
|
||||
});
|
||||
expect(searched.notes.map((n) => n.title)).toEqual(["Second Note"]);
|
||||
|
||||
const deletion = await harness.performAction<{ deleted: boolean; id: string }>("delete", {
|
||||
companyId,
|
||||
id: second.id,
|
||||
});
|
||||
expect(deletion).toEqual({ deleted: true, id: second.id });
|
||||
|
||||
const after = await harness.getData<NoteListResult>("list", { companyId });
|
||||
expect(after.notes.map((n) => n.title)).toEqual(["Hello World"]);
|
||||
});
|
||||
|
||||
it("caps tag count exactly at MAX_TAGS", async () => {
|
||||
const companyId = "88888888-8888-8888-8888-888888888888";
|
||||
const { harness, db } = makeHarness();
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const sixteenTags = Array.from({ length: 16 }, (_, i) => `t${i}`);
|
||||
const created = await harness.performAction<NoteRecord>("create", {
|
||||
companyId,
|
||||
title: "Tagged note",
|
||||
tags: sixteenTags,
|
||||
});
|
||||
expect(created.tags).toHaveLength(16);
|
||||
|
||||
await expect(
|
||||
harness.performAction("create", {
|
||||
companyId,
|
||||
title: "Too many tags",
|
||||
tags: [...sixteenTags, "extra"],
|
||||
}),
|
||||
).rejects.toThrow(/at most 16 tags/);
|
||||
|
||||
expect(db.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("requires a title", async () => {
|
||||
const { harness } = makeHarness();
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
await expect(
|
||||
harness.performAction("create", {
|
||||
companyId: "33333333-3333-3333-3333-333333333333",
|
||||
title: "",
|
||||
}),
|
||||
).rejects.toThrow(/title.*required/);
|
||||
});
|
||||
|
||||
it("validates malformed tags before touching the database", async () => {
|
||||
const { harness, db } = makeHarness();
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
await expect(
|
||||
harness.performAction("create", {
|
||||
companyId: "55555555-5555-5555-5555-555555555555",
|
||||
title: "Bad tags note",
|
||||
tags: ["good", "Bad Tag!!!"],
|
||||
}),
|
||||
).rejects.toThrow(/lowercase letters, digits, hyphens, or underscores/);
|
||||
expect(db.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("dispatches the scoped API routes through the same handlers", async () => {
|
||||
const companyId = "66666666-6666-6666-6666-666666666666";
|
||||
const { harness } = makeHarness();
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const created = await plugin.definition.onApiRequest?.({
|
||||
routeKey: "create",
|
||||
method: "POST",
|
||||
path: "/notes",
|
||||
params: {},
|
||||
query: {},
|
||||
body: { companyId, title: "API Note", body: "Created via API" },
|
||||
actor: { actorType: "user", actorId: "board", userId: "board", agentId: null, runId: null },
|
||||
companyId,
|
||||
headers: {},
|
||||
});
|
||||
expect(created).toMatchObject({ status: 201 });
|
||||
expect(created?.body).toMatchObject({ title: "API Note" });
|
||||
|
||||
const noteId = (created?.body as NoteRecord).id;
|
||||
|
||||
const listed = await plugin.definition.onApiRequest?.({
|
||||
routeKey: "list",
|
||||
method: "GET",
|
||||
path: "/notes",
|
||||
params: {},
|
||||
query: { search: "API" },
|
||||
body: null,
|
||||
actor: { actorType: "user", actorId: "board", userId: "board", agentId: null, runId: null },
|
||||
companyId,
|
||||
headers: {},
|
||||
});
|
||||
expect(listed?.body).toMatchObject({
|
||||
notes: [expect.objectContaining({ title: "API Note" })],
|
||||
});
|
||||
|
||||
const removed = await plugin.definition.onApiRequest?.({
|
||||
routeKey: "delete",
|
||||
method: "DELETE",
|
||||
path: `/notes/${noteId}`,
|
||||
params: { id: noteId },
|
||||
query: { companyId },
|
||||
body: null,
|
||||
actor: { actorType: "user", actorId: "board", userId: "board", agentId: null, runId: null },
|
||||
companyId,
|
||||
headers: {},
|
||||
});
|
||||
expect(removed).toMatchObject({ status: 200, body: { deleted: true } });
|
||||
});
|
||||
|
||||
it("returns 404 when deleting a non-existent note", async () => {
|
||||
const companyId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
const { harness } = makeHarness();
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await plugin.definition.onApiRequest?.({
|
||||
routeKey: "delete",
|
||||
method: "DELETE",
|
||||
path: "/notes/00000000-0000-0000-0000-000000000000",
|
||||
params: { id: "00000000-0000-0000-0000-000000000000" },
|
||||
query: { companyId },
|
||||
body: null,
|
||||
actor: { actorType: "user", actorId: "board", userId: "board", agentId: null, runId: null },
|
||||
companyId,
|
||||
headers: {},
|
||||
});
|
||||
expect(result?.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -12,6 +12,8 @@ packages:
|
||||
# Same treatment for the bookmarks example: published as a bundled plugin,
|
||||
# not a workspace importer, so its pnpm-lock.yaml entries are managed by CI.
|
||||
- "!packages/plugins/examples/plugin-bookmarks-example"
|
||||
# Same treatment for the quick-notes example.
|
||||
- "!packages/plugins/examples/plugin-quick-notes-example"
|
||||
- server
|
||||
- ui
|
||||
- cli
|
||||
Reference in new issue
Block a user