mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Add Bookmarks example plugin
Adds packages/plugins/examples/plugin-bookmarks-example, a new first-party "core surface" example that exercises plugin database namespaces, local folders, scoped API routes, and the page/sidebar/dashboard/settings UI slots end-to-end. Registers the example as a bundled plugin in server/src/routes/plugins.ts and excludes its workspace importer in pnpm-workspace.yaml so PRs don't have to commit lockfile entries for it. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e400315cbf
commit
3995e9fb24
14 files changed
+1422
No files matched your search
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
.paperclip-sdk
|
||||
@@ -0,0 +1,53 @@
|
||||
# @paperclipai/plugin-bookmarks-example
|
||||
|
||||
A first-party example plugin that adds a small but real product surface to Paperclip: a
|
||||
company-scoped bookmark library backed by a plugin database namespace and a local markdown
|
||||
folder.
|
||||
|
||||
The package is a follow-up to the core plugin host surface work and is intended as a
|
||||
focused reference for plugin authors. The orchestration smoke example remains the
|
||||
acceptance fixture for the orchestration APIs; this plugin is the user-facing
|
||||
counterpart that exercises the broader UI + storage surface.
|
||||
|
||||
## Surfaces exercised
|
||||
|
||||
- **Plugin database namespace.** A migration creates a `bookmarks` table inside the
|
||||
plugin's restricted Postgres schema. The worker reads and writes only that
|
||||
namespace; the restricted SQL helpers prevent cross-namespace access.
|
||||
- **Local folders.** A `bookmarks-root` folder declaration is configured by the
|
||||
operator and atomically receives one markdown file per bookmark via
|
||||
`ctx.localFolders.writeTextAtomic`. The DB row remains the source of truth so
|
||||
list and delete operations stay correct even when the folder is unconfigured
|
||||
or the disk write fails.
|
||||
- **Scoped API routes.** Three plugin-owned routes (`list`, `create`, `delete`)
|
||||
declare board-or-agent auth and resolve the company from query/body
|
||||
parameters before the worker sees them.
|
||||
- **UI extension slots.** A full plugin page, a sidebar entry, a dashboard
|
||||
widget, and a settings page are all wired up to the same worker handlers via
|
||||
`usePluginData` / `usePluginAction`.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/plugin-bookmarks-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-bookmarks-example
|
||||
```
|
||||
|
||||
After install, configure the plugin's `bookmarks-root` local folder under the
|
||||
plugin's Folders settings to enable the markdown sidecar files. The plugin works
|
||||
without it (rows live in the database namespace), but operators that want
|
||||
shareable, file-backed snapshots should point the folder at a checked-in
|
||||
directory.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/plugin-bookmarks-example test
|
||||
pnpm --filter @paperclipai/plugin-bookmarks-example typecheck
|
||||
pnpm --filter @paperclipai/plugin-bookmarks-example build
|
||||
```
|
||||
|
||||
The worker code uses module-scoped handler closures populated in `setup()`,
|
||||
mirroring the orchestration smoke example, so the handlers can be invoked from
|
||||
both `ctx.data` / `ctx.actions` (UI) and `onApiRequest` (HTTP) without
|
||||
duplicating logic.
|
||||
@@ -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,16 @@
|
||||
CREATE TABLE plugin_bookmarks_b34a9f8617.bookmarks (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL,
|
||||
slug text NOT NULL,
|
||||
url text NOT NULL,
|
||||
title text NOT NULL,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
tags text[] NOT NULL DEFAULT '{}',
|
||||
file_path text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT bookmarks_company_slug_uq UNIQUE (company_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX bookmarks_company_idx
|
||||
ON plugin_bookmarks_b34a9f8617.bookmarks (company_id, created_at DESC);
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-bookmarks-example",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "First-party example plugin: company-scoped bookmark library backed by a plugin database namespace and a local markdown folder.",
|
||||
"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 4178",
|
||||
"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",
|
||||
"bookmarks",
|
||||
"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,28 @@
|
||||
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
|
||||
function withPlugins(config) {
|
||||
if (!config) return null;
|
||||
return {
|
||||
...config,
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: "./tsconfig.json",
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
withPlugins(presets.rollup.manifest),
|
||||
withPlugins(presets.rollup.worker),
|
||||
withPlugins(presets.rollup.ui),
|
||||
].filter(Boolean);
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export const PLUGIN_ID = "paperclipai.plugin-bookmarks-example";
|
||||
export const BOOKMARKS_FOLDER_KEY = "bookmarks-root";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Bookmarks (Example)",
|
||||
description:
|
||||
"First-party example plugin: company-scoped bookmark library backed by a plugin database namespace and a local markdown folder. Demonstrates scoped API routes, local folders, dashboard widget, and a plugin page.",
|
||||
author: "Paperclip",
|
||||
categories: ["workspace", "ui"],
|
||||
capabilities: [
|
||||
"api.routes.register",
|
||||
"database.namespace.migrate",
|
||||
"database.namespace.read",
|
||||
"database.namespace.write",
|
||||
"local.folders",
|
||||
"companies.read",
|
||||
"ui.dashboardWidget.register",
|
||||
"ui.page.register",
|
||||
"ui.sidebar.register",
|
||||
"instance.settings.register",
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
database: {
|
||||
namespaceSlug: "bookmarks",
|
||||
migrationsDir: "migrations",
|
||||
},
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: BOOKMARKS_FOLDER_KEY,
|
||||
displayName: "Bookmarks root",
|
||||
description:
|
||||
"Company-scoped local folder that stores one markdown file per bookmark. Each file uses YAML frontmatter for url, title, tags, and notes.",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["bookmarks"],
|
||||
},
|
||||
],
|
||||
apiRoutes: [
|
||||
{
|
||||
routeKey: "list",
|
||||
method: "GET",
|
||||
path: "/bookmarks",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "query", key: "companyId" },
|
||||
},
|
||||
{
|
||||
routeKey: "create",
|
||||
method: "POST",
|
||||
path: "/bookmarks",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "body", key: "companyId" },
|
||||
},
|
||||
{
|
||||
routeKey: "delete",
|
||||
method: "DELETE",
|
||||
path: "/bookmarks/:slug",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "query", key: "companyId" },
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "page",
|
||||
id: "bookmarks-page",
|
||||
displayName: "Bookmarks",
|
||||
exportName: "BookmarksPage",
|
||||
routePath: "bookmarks",
|
||||
},
|
||||
{
|
||||
type: "sidebar",
|
||||
id: "bookmarks-sidebar-link",
|
||||
displayName: "Bookmarks",
|
||||
exportName: "BookmarksSidebarLink",
|
||||
},
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: "bookmarks-widget",
|
||||
displayName: "Bookmarks",
|
||||
exportName: "BookmarksDashboardWidget",
|
||||
},
|
||||
{
|
||||
type: "settingsPage",
|
||||
id: "bookmarks-settings",
|
||||
displayName: "Bookmarks",
|
||||
exportName: "BookmarksSettingsPage",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
usePluginAction,
|
||||
usePluginData,
|
||||
type PluginPageProps,
|
||||
type PluginSettingsPageProps,
|
||||
type PluginSidebarProps,
|
||||
type PluginWidgetProps,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
import { useMemo, useState, type CSSProperties, type FormEvent } from "react";
|
||||
|
||||
interface BookmarkRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
title: string;
|
||||
notes: string;
|
||||
tags: string[];
|
||||
filePath: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface BookmarkListResult {
|
||||
databaseNamespace: string;
|
||||
bookmarks: BookmarkRecord[];
|
||||
}
|
||||
|
||||
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 BookmarksList({
|
||||
result,
|
||||
onDelete,
|
||||
loading,
|
||||
}: {
|
||||
result: BookmarkListResult | null;
|
||||
onDelete: (slug: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (loading) return <div>Loading bookmarks…</div>;
|
||||
if (!result) return null;
|
||||
if (result.bookmarks.length === 0) {
|
||||
return <div style={{ color: "#6b7280" }}>No bookmarks yet.</div>;
|
||||
}
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
{result.bookmarks.map((bookmark) => (
|
||||
<div key={bookmark.id} style={cardStyle}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
||||
<a
|
||||
href={bookmark.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#1d4ed8", fontWeight: 600 }}
|
||||
>
|
||||
{bookmark.title}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
style={subtleButtonStyle}
|
||||
onClick={() => {
|
||||
void onDelete(bookmark.slug);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ color: "#6b7280", fontSize: 12, wordBreak: "break-all" }}>{bookmark.url}</div>
|
||||
{bookmark.tags.length > 0 ? (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{bookmark.tags.map((tag) => (
|
||||
<span key={tag} style={tagStyle}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{bookmark.notes ? (
|
||||
<div style={{ marginTop: 6, whiteSpace: "pre-wrap" }}>{bookmark.notes}</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: 6, color: "#9ca3af", fontSize: 11 }}>
|
||||
Added {formatDate(bookmark.createdAt)} · {bookmark.filePath}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookmarksSurface({ companyId }: { companyId: string }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [tag, setTag] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [tagsRaw, setTagsRaw] = useState("");
|
||||
const [notes, setNotes] = 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<BookmarkListResult>("list", listParams);
|
||||
const create = usePluginAction("create");
|
||||
const remove = usePluginAction("delete");
|
||||
|
||||
async function handleCreate(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await create({
|
||||
companyId,
|
||||
url,
|
||||
title: title || null,
|
||||
notes: notes || null,
|
||||
tags: parseTagInput(tagsRaw),
|
||||
});
|
||||
setUrl("");
|
||||
setTitle("");
|
||||
setNotes("");
|
||||
setTagsRaw("");
|
||||
refresh();
|
||||
} catch (cause) {
|
||||
setErrorMessage(cause instanceof Error ? cause.message : "Failed to add bookmark");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(slug: string) {
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await remove({ companyId, slug });
|
||||
refresh();
|
||||
} catch (cause) {
|
||||
setErrorMessage(cause instanceof Error ? cause.message : "Failed to delete bookmark");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<header style={{ display: "grid", gap: 4 }}>
|
||||
<h1 style={{ fontSize: 20, margin: 0 }}>Bookmarks</h1>
|
||||
<div style={{ color: "#6b7280" }}>
|
||||
Company-scoped bookmark library backed by a plugin database namespace and a local markdown
|
||||
folder.
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleCreate} style={{ ...cardStyle, display: "grid", gap: 8 }}>
|
||||
<strong>Add bookmark</strong>
|
||||
<input
|
||||
style={inputStyle}
|
||||
required
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="Title (defaults to URL)"
|
||||
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: 60, resize: "vertical" }}
|
||||
placeholder="Notes (optional)"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(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, url, or notes"
|
||||
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}
|
||||
<BookmarksList 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 — bookmarks are scoped per company.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookmarksPage({ context }: PluginPageProps) {
|
||||
if (!context.companyId) return <MissingCompanyNotice surface="Bookmarks" />;
|
||||
return <BookmarksSurface companyId={context.companyId} />;
|
||||
}
|
||||
|
||||
export function BookmarksSettingsPage({ context }: PluginSettingsPageProps) {
|
||||
const companyId = context.companyId ?? "";
|
||||
const { data, loading, error } = usePluginData<BookmarkListResult>(
|
||||
"list",
|
||||
companyId ? { companyId, limit: 1 } : undefined,
|
||||
);
|
||||
if (!companyId) return <MissingCompanyNotice surface="Bookmarks settings" />;
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>Bookmarks settings</h1>
|
||||
<div style={cardStyle}>
|
||||
<div style={{ display: "grid", gap: 4 }}>
|
||||
<div>
|
||||
<strong>Database namespace:</strong>{" "}
|
||||
<code>{loading ? "…" : (data?.databaseNamespace ?? "not configured")}</code>
|
||||
</div>
|
||||
<div>
|
||||
Configure the <code>bookmarks-root</code> local folder under the plugin Folders settings
|
||||
so new bookmarks can be persisted to disk in addition to the database namespace.
|
||||
</div>
|
||||
{error ? <div style={{ color: "#b91c1c" }}>{error.message}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookmarksDashboardWidget({ context }: PluginWidgetProps) {
|
||||
const companyId = context.companyId;
|
||||
const { data, loading, error } = usePluginData<BookmarkListResult>(
|
||||
"list",
|
||||
companyId ? { companyId, limit: 5 } : undefined,
|
||||
);
|
||||
|
||||
if (!companyId) return null;
|
||||
if (loading) return <div style={{ font: "12px system-ui, sans-serif" }}>Loading bookmarks…</div>;
|
||||
if (error) return <div style={{ color: "#b91c1c" }}>Bookmarks error: {error.message}</div>;
|
||||
const bookmarks = data?.bookmarks ?? [];
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 6, font: "13px system-ui, sans-serif" }}>
|
||||
<strong>Recent bookmarks</strong>
|
||||
{bookmarks.length === 0 ? (
|
||||
<div style={{ color: "#6b7280" }}>No bookmarks yet.</div>
|
||||
) : (
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{bookmarks.map((bookmark) => (
|
||||
<li key={bookmark.id}>
|
||||
<a
|
||||
href={bookmark.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#1d4ed8" }}
|
||||
>
|
||||
{bookmark.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookmarksSidebarLink(_props: PluginSidebarProps) {
|
||||
return <span>Bookmarks</span>;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
definePlugin,
|
||||
runWorker,
|
||||
type PluginApiRequestInput,
|
||||
type PluginApiResponse,
|
||||
type PluginContext,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { BOOKMARKS_FOLDER_KEY } from "./manifest.js";
|
||||
|
||||
export interface BookmarkRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
title: string;
|
||||
notes: string;
|
||||
tags: string[];
|
||||
filePath: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BookmarkInput {
|
||||
url: string;
|
||||
title?: string | null;
|
||||
notes?: string | null;
|
||||
tags?: string[] | null;
|
||||
slug?: string | null;
|
||||
}
|
||||
|
||||
export interface BookmarkListResult {
|
||||
databaseNamespace: string;
|
||||
bookmarks: BookmarkRecord[];
|
||||
}
|
||||
|
||||
const SLUG_RESERVED = new Set(["", ".", "..", "_"]);
|
||||
const MAX_TAGS = 16;
|
||||
const MAX_TAG_LENGTH = 32;
|
||||
const MAX_TITLE_LENGTH = 200;
|
||||
const MAX_NOTES_LENGTH = 4000;
|
||||
const MAX_BOOKMARKS_PER_LIST = 200;
|
||||
|
||||
function tableName(namespace: string): string {
|
||||
return `${namespace}.bookmarks`;
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeUrl(value: unknown): string {
|
||||
const raw = asNonEmptyString(value);
|
||||
if (!raw) {
|
||||
throw new Error("`url` is required");
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error("`url` must be a valid absolute URL");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("`url` must use http or https");
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function normalizeTitle(value: unknown, fallback: string): string {
|
||||
const raw = asNonEmptyString(value);
|
||||
const candidate = raw ?? fallback;
|
||||
return candidate.length > MAX_TITLE_LENGTH ? candidate.slice(0, MAX_TITLE_LENGTH) : candidate;
|
||||
}
|
||||
|
||||
function normalizeNotes(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("`notes` must be a string when provided");
|
||||
}
|
||||
if (value.length > MAX_NOTES_LENGTH) {
|
||||
throw new Error(`\`notes\` must be ${MAX_NOTES_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;
|
||||
seen.add(cleaned);
|
||||
tags.push(cleaned);
|
||||
if (tags.length > MAX_TAGS) {
|
||||
throw new Error(`bookmarks accept at most ${MAX_TAGS} tags`);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function deriveSlugCandidate(url: string, title: string | null): string {
|
||||
const titleSource = title?.trim();
|
||||
if (titleSource) {
|
||||
const slug = slugify(titleSource);
|
||||
if (slug) return slug;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const base = `${parsed.hostname}${parsed.pathname}`;
|
||||
const slug = slugify(base);
|
||||
if (slug) return slug;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return "bookmark";
|
||||
}
|
||||
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
function normalizeSlug(value: unknown, fallback: string): string {
|
||||
const raw = asNonEmptyString(value);
|
||||
if (!raw) {
|
||||
if (SLUG_RESERVED.has(fallback)) return "bookmark";
|
||||
return fallback;
|
||||
}
|
||||
const cleaned = slugify(raw);
|
||||
if (!cleaned || SLUG_RESERVED.has(cleaned)) {
|
||||
throw new Error("`slug` must contain at least one alphanumeric character");
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function bookmarkFilePath(slug: string): string {
|
||||
return `bookmarks/${slug}.md`;
|
||||
}
|
||||
|
||||
export function renderBookmarkMarkdown(bookmark: BookmarkRecord): string {
|
||||
const frontmatter = [
|
||||
"---",
|
||||
`slug: ${bookmark.slug}`,
|
||||
`url: ${bookmark.url}`,
|
||||
`title: ${escapeFrontmatterValue(bookmark.title)}`,
|
||||
`tags: [${bookmark.tags.map((tag) => JSON.stringify(tag)).join(", ")}]`,
|
||||
`created_at: ${bookmark.createdAt}`,
|
||||
`updated_at: ${bookmark.updatedAt}`,
|
||||
"---",
|
||||
"",
|
||||
].join("\n");
|
||||
if (bookmark.notes.length === 0) return frontmatter;
|
||||
return `${frontmatter}\n${bookmark.notes.trimEnd()}\n`;
|
||||
}
|
||||
|
||||
function escapeFrontmatterValue(value: string): string {
|
||||
if (/^[A-Za-z0-9 _.,:'/()&-]+$/.test(value) && !value.startsWith(" ")) {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
interface BookmarkRow {
|
||||
id: string;
|
||||
company_id: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
title: string;
|
||||
notes: string;
|
||||
tags: string[];
|
||||
file_path: string;
|
||||
created_at: string | Date;
|
||||
updated_at: string | Date;
|
||||
}
|
||||
|
||||
function rowToBookmark(row: BookmarkRow): BookmarkRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.company_id,
|
||||
slug: row.slug,
|
||||
url: row.url,
|
||||
title: row.title,
|
||||
notes: row.notes,
|
||||
tags: Array.isArray(row.tags) ? [...row.tags] : [],
|
||||
filePath: row.file_path,
|
||||
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 listBookmarks(
|
||||
ctx: PluginContext,
|
||||
companyId: string,
|
||||
search: string | null,
|
||||
tag: string | null,
|
||||
limit: number,
|
||||
): Promise<BookmarkRecord[]> {
|
||||
const params: unknown[] = [companyId];
|
||||
let where = "company_id = $1";
|
||||
if (search) {
|
||||
params.push(`%${search.toLowerCase()}%`);
|
||||
const idx = params.length;
|
||||
where += ` AND (lower(title) LIKE $${idx} OR lower(url) LIKE $${idx} OR lower(notes) LIKE $${idx})`;
|
||||
}
|
||||
if (tag) {
|
||||
params.push(tag);
|
||||
where += ` AND $${params.length} = ANY(tags)`;
|
||||
}
|
||||
params.push(Math.min(Math.max(limit, 1), MAX_BOOKMARKS_PER_LIST));
|
||||
const rows = await ctx.db.query<BookmarkRow>(
|
||||
`SELECT id, company_id, slug, url, title, notes, tags, file_path, created_at, updated_at
|
||||
FROM ${tableName(ctx.db.namespace)}
|
||||
WHERE ${where}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${params.length}`,
|
||||
params,
|
||||
);
|
||||
return rows.map(rowToBookmark);
|
||||
}
|
||||
|
||||
export async function createBookmark(
|
||||
ctx: PluginContext,
|
||||
companyId: string,
|
||||
input: BookmarkInput,
|
||||
): Promise<BookmarkRecord> {
|
||||
const url = normalizeUrl(input.url);
|
||||
const inputTitle = asNonEmptyString(input.title);
|
||||
const title = inputTitle ? normalizeTitle(inputTitle, url) : url;
|
||||
const notes = normalizeNotes(input.notes);
|
||||
const tags = normalizeTags(input.tags);
|
||||
const fallbackSlug = deriveSlugCandidate(url, inputTitle);
|
||||
const slug = input.slug != null
|
||||
? normalizeSlug(input.slug, fallbackSlug)
|
||||
: (SLUG_RESERVED.has(fallbackSlug) ? "bookmark" : fallbackSlug);
|
||||
const id = randomUUID();
|
||||
const filePath = bookmarkFilePath(slug);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const insert = await ctx.db.execute(
|
||||
`INSERT INTO ${tableName(ctx.db.namespace)}
|
||||
(id, company_id, slug, url, title, notes, tags, file_path, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
|
||||
ON CONFLICT (company_id, slug) DO NOTHING`,
|
||||
[id, companyId, slug, url, title, notes, tags, filePath, now],
|
||||
);
|
||||
if (insert.rowCount === 0) {
|
||||
throw new Error(`Bookmark with slug "${slug}" already exists`);
|
||||
}
|
||||
|
||||
const bookmark: BookmarkRecord = {
|
||||
id,
|
||||
companyId,
|
||||
slug,
|
||||
url,
|
||||
title,
|
||||
notes,
|
||||
tags,
|
||||
filePath,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
try {
|
||||
await ctx.localFolders.writeTextAtomic(
|
||||
companyId,
|
||||
BOOKMARKS_FOLDER_KEY,
|
||||
filePath,
|
||||
renderBookmarkMarkdown(bookmark),
|
||||
);
|
||||
} catch (error) {
|
||||
ctx.logger.warn("Failed to write bookmark markdown to local folder; row kept", {
|
||||
slug,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
export async function deleteBookmark(
|
||||
ctx: PluginContext,
|
||||
companyId: string,
|
||||
slug: string,
|
||||
): Promise<{ deleted: boolean; slug: string }> {
|
||||
const result = await ctx.db.execute(
|
||||
`DELETE FROM ${tableName(ctx.db.namespace)} WHERE company_id = $1 AND slug = $2`,
|
||||
[companyId, slug],
|
||||
);
|
||||
return { deleted: result.rowCount > 0, slug };
|
||||
}
|
||||
|
||||
let listHandler: ((companyId: string, search: string | null, tag: string | null, limit: number) => Promise<BookmarkListResult>) | null = null;
|
||||
let createHandler: ((companyId: string, input: BookmarkInput) => Promise<BookmarkRecord>) | null = null;
|
||||
let deleteHandler: ((companyId: string, slug: string) => Promise<{ deleted: boolean; slug: string }>) | null = null;
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
listHandler = (companyId, search, tag, limit) =>
|
||||
listBookmarks(ctx, companyId, search, tag, limit).then((bookmarks) => ({
|
||||
databaseNamespace: ctx.db.namespace,
|
||||
bookmarks,
|
||||
}));
|
||||
createHandler = (companyId, input) => createBookmark(ctx, companyId, input);
|
||||
deleteHandler = (companyId, slug) => deleteBookmark(ctx, companyId, slug);
|
||||
|
||||
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("Bookmarks 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("Bookmarks plugin not ready");
|
||||
return createHandler(companyId, {
|
||||
url: typeof params.url === "string" ? params.url : "",
|
||||
title: typeof params.title === "string" ? params.title : null,
|
||||
notes: typeof params.notes === "string" ? params.notes : null,
|
||||
tags: Array.isArray(params.tags) ? (params.tags as unknown[] as string[]) : null,
|
||||
slug: typeof params.slug === "string" ? params.slug : null,
|
||||
});
|
||||
});
|
||||
|
||||
ctx.actions.register("delete", async (params) => {
|
||||
const companyId = asNonEmptyString(params.companyId);
|
||||
const slug = asNonEmptyString(params.slug);
|
||||
if (!companyId || !slug) throw new Error("companyId and slug are required");
|
||||
if (!deleteHandler) throw new Error("Bookmarks plugin not ready");
|
||||
return deleteHandler(companyId, slug);
|
||||
});
|
||||
},
|
||||
|
||||
async onApiRequest(input: PluginApiRequestInput): Promise<PluginApiResponse> {
|
||||
switch (input.routeKey) {
|
||||
case "list": {
|
||||
if (!listHandler) return { status: 503, body: { error: "Bookmarks 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: "Bookmarks plugin not ready" } };
|
||||
const body = (input.body ?? {}) as Record<string, unknown>;
|
||||
try {
|
||||
const bookmark = await createHandler(input.companyId, {
|
||||
url: typeof body.url === "string" ? body.url : "",
|
||||
title: typeof body.title === "string" ? body.title : null,
|
||||
notes: typeof body.notes === "string" ? body.notes : null,
|
||||
tags: Array.isArray(body.tags) ? (body.tags as unknown[] as string[]) : null,
|
||||
slug: typeof body.slug === "string" ? body.slug : null,
|
||||
});
|
||||
return { status: 201, body: bookmark };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: error instanceof Error ? error.message : "Invalid bookmark" },
|
||||
};
|
||||
}
|
||||
}
|
||||
case "delete": {
|
||||
if (!deleteHandler) return { status: 503, body: { error: "Bookmarks plugin not ready" } };
|
||||
const slug = input.params.slug;
|
||||
if (!slug) return { status: 400, body: { error: "slug is required" } };
|
||||
const result = await deleteHandler(input.companyId, slug);
|
||||
return { status: result.deleted ? 200 : 404, body: result };
|
||||
}
|
||||
default:
|
||||
return { status: 404, body: { error: `Unknown bookmarks route: ${input.routeKey}` } };
|
||||
}
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return {
|
||||
status: "ok",
|
||||
message: "Bookmarks plugin worker is running",
|
||||
details: {
|
||||
surfaces: ["scoped-api-route", "database-namespace", "local-folder", "page", "dashboard-widget"],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,339 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pluginManifestV1Schema } from "@paperclipai/shared";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest, { BOOKMARKS_FOLDER_KEY, PLUGIN_ID } from "../src/manifest.js";
|
||||
import plugin, {
|
||||
type BookmarkListResult,
|
||||
type BookmarkRecord,
|
||||
deriveSlugCandidate,
|
||||
renderBookmarkMarkdown,
|
||||
} from "../src/worker.js";
|
||||
|
||||
interface BookmarkRow {
|
||||
id: string;
|
||||
company_id: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
title: string;
|
||||
notes: string;
|
||||
tags: string[];
|
||||
file_path: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function makeInMemoryBookmarksDb() {
|
||||
const rows: BookmarkRow[] = [];
|
||||
return {
|
||||
rows,
|
||||
namespace: "plugin_bookmarks_test",
|
||||
async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
||||
if (!sql.includes("FROM plugin_bookmarks_test.bookmarks")) 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 needle = like.replace(/%/g, "").toLowerCase();
|
||||
filtered = filtered.filter((row) =>
|
||||
row.title.toLowerCase().includes(needle) ||
|
||||
row.url.toLowerCase().includes(needle) ||
|
||||
row.notes.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_bookmarks_test.bookmarks")) {
|
||||
const [id, companyId, slug, url, title, notes, tags, filePath, createdAt] =
|
||||
params as [string, string, string, string, string, string, string[], string, string];
|
||||
if (rows.some((row) => row.company_id === companyId && row.slug === slug)) {
|
||||
return { rowCount: 0 };
|
||||
}
|
||||
rows.push({
|
||||
id,
|
||||
company_id: companyId,
|
||||
slug,
|
||||
url,
|
||||
title,
|
||||
notes,
|
||||
tags: [...tags],
|
||||
file_path: filePath,
|
||||
created_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
});
|
||||
return { rowCount: 1 };
|
||||
}
|
||||
if (sql.startsWith("DELETE FROM plugin_bookmarks_test.bookmarks")) {
|
||||
const [companyId, slug] = 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]!.slug === slug) rows.splice(i, 1);
|
||||
}
|
||||
return { rowCount: before - rows.length };
|
||||
}
|
||||
return { rowCount: 0 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeHarness() {
|
||||
const harness = createTestHarness({ manifest });
|
||||
const db = makeInMemoryBookmarksDb();
|
||||
harness.ctx.db = db;
|
||||
return { harness, db };
|
||||
}
|
||||
|
||||
describe("plugin-bookmarks-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",
|
||||
"local.folders",
|
||||
"ui.page.register",
|
||||
"ui.dashboardWidget.register",
|
||||
]),
|
||||
);
|
||||
expect(parsed.database).toMatchObject({
|
||||
namespaceSlug: "bookmarks",
|
||||
migrationsDir: "migrations",
|
||||
});
|
||||
expect(parsed.localFolders).toEqual([
|
||||
expect.objectContaining({
|
||||
folderKey: BOOKMARKS_FOLDER_KEY,
|
||||
access: "readWrite",
|
||||
}),
|
||||
]);
|
||||
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: "bookmarks-page", type: "page" }),
|
||||
expect.objectContaining({ id: "bookmarks-widget", type: "dashboardWidget" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveSlugCandidate", () => {
|
||||
it("prefers the title when one is provided", () => {
|
||||
expect(deriveSlugCandidate("https://example.com", "My Favorite Link")).toBe("my-favorite-link");
|
||||
});
|
||||
|
||||
it("falls back to host + path when title is missing", () => {
|
||||
expect(deriveSlugCandidate("https://example.com/blog/my-post", null)).toBe("example-com-blog-my-post");
|
||||
});
|
||||
|
||||
it("returns a default slug for invalid URLs without a title", () => {
|
||||
expect(deriveSlugCandidate("not-a-url", null)).toBe("bookmark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderBookmarkMarkdown", () => {
|
||||
it("emits stable YAML frontmatter and trims trailing notes whitespace", () => {
|
||||
const bookmark: BookmarkRecord = {
|
||||
id: "00000000-0000-0000-0000-000000000001",
|
||||
companyId: "11111111-1111-1111-1111-111111111111",
|
||||
slug: "example",
|
||||
url: "https://example.com/page",
|
||||
title: "Example",
|
||||
notes: "Hello\nworld\n\n",
|
||||
tags: ["docs", "demo"],
|
||||
filePath: "bookmarks/example.md",
|
||||
createdAt: "2026-05-07T10:00:00.000Z",
|
||||
updatedAt: "2026-05-07T10:00:00.000Z",
|
||||
};
|
||||
expect(renderBookmarkMarkdown(bookmark)).toBe(
|
||||
[
|
||||
"---",
|
||||
"slug: example",
|
||||
"url: https://example.com/page",
|
||||
"title: Example",
|
||||
'tags: ["docs", "demo"]',
|
||||
"created_at: 2026-05-07T10:00:00.000Z",
|
||||
"updated_at: 2026-05-07T10:00:00.000Z",
|
||||
"---",
|
||||
"",
|
||||
"Hello",
|
||||
"world\n",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits an empty body when there are no notes", () => {
|
||||
const bookmark: BookmarkRecord = {
|
||||
id: "id",
|
||||
companyId: "company",
|
||||
slug: "no-notes",
|
||||
url: "https://example.com/",
|
||||
title: "No notes",
|
||||
notes: "",
|
||||
tags: [],
|
||||
filePath: "bookmarks/no-notes.md",
|
||||
createdAt: "2026-05-07T10:00:00.000Z",
|
||||
updatedAt: "2026-05-07T10:00:00.000Z",
|
||||
};
|
||||
const rendered = renderBookmarkMarkdown(bookmark);
|
||||
expect(rendered.endsWith("---\n")).toBe(true);
|
||||
expect(rendered).toContain("tags: []");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin-bookmarks-example worker", () => {
|
||||
it("creates, lists, and deletes bookmarks 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<BookmarkRecord>("create", {
|
||||
companyId,
|
||||
url: "https://example.com/one",
|
||||
title: "Example One",
|
||||
tags: ["Docs", "Demo", "demo"],
|
||||
notes: "Initial note",
|
||||
});
|
||||
expect(created.companyId).toBe(companyId);
|
||||
expect(created.slug).toBe("example-one");
|
||||
expect(created.tags).toEqual(["docs", "demo"]);
|
||||
expect(created.filePath).toBe("bookmarks/example-one.md");
|
||||
|
||||
const second = await harness.performAction<BookmarkRecord>("create", {
|
||||
companyId,
|
||||
url: "https://example.com/two",
|
||||
title: "Example Two",
|
||||
tags: ["demo"],
|
||||
});
|
||||
|
||||
const allListed = await harness.getData<BookmarkListResult>("list", { companyId });
|
||||
expect(allListed.databaseNamespace).toBe("plugin_bookmarks_test");
|
||||
expect(allListed.bookmarks.map((b) => b.slug).sort()).toEqual(["example-one", "example-two"]);
|
||||
|
||||
const filtered = await harness.getData<BookmarkListResult>("list", {
|
||||
companyId,
|
||||
tag: "docs",
|
||||
});
|
||||
expect(filtered.bookmarks.map((b) => b.slug)).toEqual(["example-one"]);
|
||||
|
||||
const searched = await harness.getData<BookmarkListResult>("list", {
|
||||
companyId,
|
||||
search: "two",
|
||||
});
|
||||
expect(searched.bookmarks.map((b) => b.slug)).toEqual(["example-two"]);
|
||||
|
||||
const deletion = await harness.performAction<{ deleted: boolean; slug: string }>("delete", {
|
||||
companyId,
|
||||
slug: second.slug,
|
||||
});
|
||||
expect(deletion).toEqual({ deleted: true, slug: "example-two" });
|
||||
|
||||
const after = await harness.getData<BookmarkListResult>("list", { companyId });
|
||||
expect(after.bookmarks.map((b) => b.slug)).toEqual(["example-one"]);
|
||||
});
|
||||
|
||||
it("rejects URLs without an http(s) scheme", async () => {
|
||||
const { harness } = makeHarness();
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
await expect(
|
||||
harness.performAction("create", {
|
||||
companyId: "33333333-3333-3333-3333-333333333333",
|
||||
url: "ftp://example.com",
|
||||
}),
|
||||
).rejects.toThrow(/http or https/);
|
||||
});
|
||||
|
||||
it("rejects duplicate slugs for the same company", async () => {
|
||||
const companyId = "44444444-4444-4444-4444-444444444444";
|
||||
const { harness } = makeHarness();
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
await harness.performAction("create", {
|
||||
companyId,
|
||||
url: "https://example.com/one",
|
||||
title: "Example One",
|
||||
});
|
||||
await expect(
|
||||
harness.performAction("create", {
|
||||
companyId,
|
||||
url: "https://example.com/two",
|
||||
title: "Example One",
|
||||
}),
|
||||
).rejects.toThrow(/already exists/);
|
||||
});
|
||||
|
||||
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",
|
||||
url: "https://example.com",
|
||||
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: "/bookmarks",
|
||||
params: {},
|
||||
query: {},
|
||||
body: { companyId, url: "https://example.com/api" },
|
||||
actor: { actorType: "user", actorId: "board", userId: "board", agentId: null, runId: null },
|
||||
companyId,
|
||||
headers: {},
|
||||
});
|
||||
expect(created).toMatchObject({ status: 201 });
|
||||
expect(created?.body).toMatchObject({ slug: "example-com-api" });
|
||||
|
||||
const listed = await plugin.definition.onApiRequest?.({
|
||||
routeKey: "list",
|
||||
method: "GET",
|
||||
path: "/bookmarks",
|
||||
params: {},
|
||||
query: { search: "api" },
|
||||
body: null,
|
||||
actor: { actorType: "user", actorId: "board", userId: "board", agentId: null, runId: null },
|
||||
companyId,
|
||||
headers: {},
|
||||
});
|
||||
expect(listed?.body).toMatchObject({
|
||||
bookmarks: [expect.objectContaining({ slug: "example-com-api" })],
|
||||
});
|
||||
|
||||
const removed = await plugin.definition.onApiRequest?.({
|
||||
routeKey: "delete",
|
||||
method: "DELETE",
|
||||
path: "/bookmarks/example-com-api",
|
||||
params: { slug: "example-com-api" },
|
||||
query: {},
|
||||
body: null,
|
||||
actor: { actorType: "user", actorId: "board", userId: "board", agentId: null, runId: null },
|
||||
companyId,
|
||||
headers: {},
|
||||
});
|
||||
expect(removed).toMatchObject({ status: 200, body: { deleted: true } });
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,9 @@ packages:
|
||||
# Keep this smoke fixture installable as a local plugin example without
|
||||
# forcing PRs to commit pnpm-lock.yaml for a new workspace importer.
|
||||
- "!packages/plugins/examples/plugin-orchestration-smoke-example"
|
||||
# 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"
|
||||
- server
|
||||
- ui
|
||||
- cli
|
||||
@@ -180,6 +180,14 @@ const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [
|
||||
localPath: "packages/plugins/examples/plugin-orchestration-smoke-example",
|
||||
tag: "example",
|
||||
},
|
||||
{
|
||||
packageName: "@paperclipai/plugin-bookmarks-example",
|
||||
pluginKey: "paperclipai.plugin-bookmarks-example",
|
||||
displayName: "Bookmarks (Example)",
|
||||
description: "Company-scoped bookmark library backed by a plugin database namespace and a local markdown folder. Demonstrates scoped API routes, local folders, dashboard widget, and a plugin page.",
|
||||
localPath: "packages/plugins/examples/plugin-bookmarks-example",
|
||||
tag: "example",
|
||||
},
|
||||
];
|
||||
|
||||
function listBundledPluginExamples(): AvailablePluginExample[] {
|
||||
|
||||
Reference in new issue
Block a user