mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +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,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);
|
||||
Reference in new issue
Block a user