aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/admin/admin_tools/page.tsx19
-rw-r--r--apps/web/app/admin/layout.tsx7
-rw-r--r--apps/web/components/admin/BookmarkDebugger.tsx649
-rw-r--r--apps/web/components/ui/info-tooltip.tsx3
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json37
-rw-r--r--packages/trpc/models/assets.ts25
-rw-r--r--packages/trpc/models/bookmarks.ts145
-rw-r--r--packages/trpc/routers/admin.test.ts265
-rw-r--r--packages/trpc/routers/admin.ts172
9 files changed, 1304 insertions, 18 deletions
diff --git a/apps/web/app/admin/admin_tools/page.tsx b/apps/web/app/admin/admin_tools/page.tsx
new file mode 100644
index 00000000..e036c755
--- /dev/null
+++ b/apps/web/app/admin/admin_tools/page.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from "next";
+import BookmarkDebugger from "@/components/admin/BookmarkDebugger";
+import { useTranslation } from "@/lib/i18n/server";
+
+export async function generateMetadata(): Promise<Metadata> {
+ // oxlint-disable-next-line rules-of-hooks
+ const { t } = await useTranslation();
+ return {
+ title: `${t("admin.admin_tools.admin_tools")} | Karakeep`,
+ };
+}
+
+export default function AdminToolsPage() {
+ return (
+ <div className="flex flex-col gap-6">
+ <BookmarkDebugger />
+ </div>
+ );
+}
diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx
index 4b589712..03144b78 100644
--- a/apps/web/app/admin/layout.tsx
+++ b/apps/web/app/admin/layout.tsx
@@ -6,7 +6,7 @@ import Sidebar from "@/components/shared/sidebar/Sidebar";
import SidebarLayout from "@/components/shared/sidebar/SidebarLayout";
import { getServerAuthSession } from "@/server/auth";
import { TFunction } from "i18next";
-import { Activity, ArrowLeft, Settings, Users } from "lucide-react";
+import { Activity, ArrowLeft, Settings, Users, Wrench } from "lucide-react";
const adminSidebarItems = (
t: TFunction,
@@ -35,6 +35,11 @@ const adminSidebarItems = (
icon: <Settings size={18} />,
path: "/admin/background_jobs",
},
+ {
+ name: t("admin.admin_tools.admin_tools"),
+ icon: <Wrench size={18} />,
+ path: "/admin/admin_tools",
+ },
];
export default async function AdminLayout({
diff --git a/apps/web/components/admin/BookmarkDebugger.tsx b/apps/web/components/admin/BookmarkDebugger.tsx
new file mode 100644
index 00000000..1628fdcc
--- /dev/null
+++ b/apps/web/components/admin/BookmarkDebugger.tsx
@@ -0,0 +1,649 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { AdminCard } from "@/components/admin/AdminCard";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import InfoTooltip from "@/components/ui/info-tooltip";
+import { Input } from "@/components/ui/input";
+import { useTranslation } from "@/lib/i18n/client";
+import { api } from "@/lib/trpc";
+import { formatBytes } from "@/lib/utils";
+import { formatDistanceToNow } from "date-fns";
+import {
+ AlertCircle,
+ CheckCircle2,
+ ChevronDown,
+ ChevronRight,
+ Clock,
+ Database,
+ ExternalLink,
+ FileText,
+ FileType,
+ Image as ImageIcon,
+ Link as LinkIcon,
+ Loader2,
+ RefreshCw,
+ Search,
+ Sparkles,
+ Tag,
+ User,
+ XCircle,
+} from "lucide-react";
+import { parseAsString, useQueryState } from "nuqs";
+import { toast } from "sonner";
+
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+
+export default function BookmarkDebugger() {
+ const { t } = useTranslation();
+ const [inputValue, setInputValue] = useState("");
+ const [bookmarkId, setBookmarkId] = useQueryState(
+ "bookmarkId",
+ parseAsString.withDefault(""),
+ );
+ const [showHtmlPreview, setShowHtmlPreview] = useState(false);
+
+ // Sync input value with URL on mount/change
+ useEffect(() => {
+ if (bookmarkId) {
+ setInputValue(bookmarkId);
+ }
+ }, [bookmarkId]);
+
+ const {
+ data: debugInfo,
+ isLoading,
+ error,
+ } = api.admin.getBookmarkDebugInfo.useQuery(
+ { bookmarkId: bookmarkId },
+ { enabled: !!bookmarkId && bookmarkId.length > 0 },
+ );
+
+ const handleLookup = () => {
+ if (inputValue.trim()) {
+ setBookmarkId(inputValue.trim());
+ }
+ };
+
+ const recrawlMutation = api.admin.adminRecrawlBookmark.useMutation({
+ onSuccess: () => {
+ toast.success(t("admin.admin_tools.action_success"), {
+ description: t("admin.admin_tools.recrawl_queued"),
+ });
+ },
+ onError: (error) => {
+ toast.error(t("admin.admin_tools.action_failed"), {
+ description: error.message,
+ });
+ },
+ });
+
+ const reindexMutation = api.admin.adminReindexBookmark.useMutation({
+ onSuccess: () => {
+ toast.success(t("admin.admin_tools.action_success"), {
+ description: t("admin.admin_tools.reindex_queued"),
+ });
+ },
+ onError: (error) => {
+ toast.error(t("admin.admin_tools.action_failed"), {
+ description: error.message,
+ });
+ },
+ });
+
+ const retagMutation = api.admin.adminRetagBookmark.useMutation({
+ onSuccess: () => {
+ toast.success(t("admin.admin_tools.action_success"), {
+ description: t("admin.admin_tools.retag_queued"),
+ });
+ },
+ onError: (error) => {
+ toast.error(t("admin.admin_tools.action_failed"), {
+ description: error.message,
+ });
+ },
+ });
+
+ const resummarizeMutation = api.admin.adminResummarizeBookmark.useMutation({
+ onSuccess: () => {
+ toast.success(t("admin.admin_tools.action_success"), {
+ description: t("admin.admin_tools.resummarize_queued"),
+ });
+ },
+ onError: (error) => {
+ toast.error(t("admin.admin_tools.action_failed"), {
+ description: error.message,
+ });
+ },
+ });
+
+ const handleRecrawl = () => {
+ if (bookmarkId) {
+ recrawlMutation.mutate({ bookmarkId });
+ }
+ };
+
+ const handleReindex = () => {
+ if (bookmarkId) {
+ reindexMutation.mutate({ bookmarkId });
+ }
+ };
+
+ const handleRetag = () => {
+ if (bookmarkId) {
+ retagMutation.mutate({ bookmarkId });
+ }
+ };
+
+ const handleResummarize = () => {
+ if (bookmarkId) {
+ resummarizeMutation.mutate({ bookmarkId });
+ }
+ };
+
+ const getStatusBadge = (status: "pending" | "failure" | "success" | null) => {
+ if (!status) return null;
+
+ const config = {
+ success: {
+ variant: "default" as const,
+ icon: CheckCircle2,
+ },
+ failure: {
+ variant: "destructive" as const,
+ icon: XCircle,
+ },
+ pending: {
+ variant: "secondary" as const,
+ icon: AlertCircle,
+ },
+ };
+
+ const { variant, icon: Icon } = config[status];
+
+ return (
+ <Badge variant={variant}>
+ <Icon className="mr-1 h-3 w-3" />
+ {status}
+ </Badge>
+ );
+ };
+
+ return (
+ <div className="flex flex-col gap-4">
+ {/* Input Section */}
+ <AdminCard>
+ <div className="mb-3 flex items-center gap-2">
+ <Search className="h-5 w-5 text-muted-foreground" />
+ <h2 className="text-lg font-semibold">
+ {t("admin.admin_tools.bookmark_debugger")}
+ </h2>
+ <InfoTooltip className="text-muted-foreground" size={16}>
+ Some data will be redacted for privacy.
+ </InfoTooltip>
+ </div>
+ <div className="flex gap-2">
+ <div className="relative max-w-md flex-1">
+ <Database className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+ <Input
+ placeholder={t("admin.admin_tools.bookmark_id_placeholder")}
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleLookup();
+ }
+ }}
+ className="pl-9"
+ />
+ </div>
+ <Button onClick={handleLookup} disabled={!inputValue.trim()}>
+ <Search className="mr-2 h-4 w-4" />
+ {t("admin.admin_tools.lookup")}
+ </Button>
+ </div>
+ </AdminCard>
+
+ {/* Loading State */}
+ {isLoading && (
+ <AdminCard>
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-8 w-8 animate-spin text-gray-400" />
+ </div>
+ </AdminCard>
+ )}
+
+ {/* Error State */}
+ {!isLoading && error && (
+ <AdminCard>
+ <div className="flex items-center gap-3 rounded-lg border border-destructive/50 bg-destructive/10 p-4">
+ <XCircle className="h-5 w-5 flex-shrink-0 text-destructive" />
+ <div className="flex-1">
+ <h3 className="text-sm font-semibold text-destructive">
+ {t("admin.admin_tools.fetch_error")}
+ </h3>
+ <p className="mt-1 text-sm text-muted-foreground">
+ {error.message}
+ </p>
+ </div>
+ </div>
+ </AdminCard>
+ )}
+
+ {/* Debug Info Display */}
+ {!isLoading && !error && debugInfo && (
+ <AdminCard>
+ <div className="space-y-4">
+ {/* Basic Info & Status */}
+ <div className="grid gap-4 md:grid-cols-2">
+ {/* Basic Info */}
+ <div className="rounded-lg border bg-muted/30 p-4">
+ <div className="mb-3 flex items-center gap-2">
+ <Database className="h-4 w-4 text-muted-foreground" />
+ <h3 className="text-sm font-semibold">
+ {t("admin.admin_tools.basic_info")}
+ </h3>
+ </div>
+ <div className="space-y-2.5 text-sm">
+ <div className="flex items-center justify-between gap-2">
+ <span className="flex items-center gap-1.5 text-muted-foreground">
+ <Database className="h-3.5 w-3.5" />
+ {t("common.id")}
+ </span>
+ <span className="font-mono text-xs">{debugInfo.id}</span>
+ </div>
+ <div className="flex items-center justify-between gap-2">
+ <span className="flex items-center gap-1.5 text-muted-foreground">
+ <FileType className="h-3.5 w-3.5" />
+ {t("common.type")}
+ </span>
+ <Badge variant="secondary">{debugInfo.type}</Badge>
+ </div>
+ <div className="flex items-center justify-between gap-2">
+ <span className="flex items-center gap-1.5 text-muted-foreground">
+ <LinkIcon className="h-3.5 w-3.5" />
+ {t("common.source")}
+ </span>
+ <span>{debugInfo.source || "N/A"}</span>
+ </div>
+ <div className="flex items-center justify-between gap-2">
+ <span className="flex items-center gap-1.5 text-muted-foreground">
+ <User className="h-3.5 w-3.5" />
+ {t("admin.admin_tools.owner_user_id")}
+ </span>
+ <span className="font-mono text-xs">
+ {debugInfo.userId}
+ </span>
+ </div>
+ <div className="flex items-center justify-between gap-2">
+ <span className="flex items-center gap-1.5 text-muted-foreground">
+ <Clock className="h-3.5 w-3.5" />
+ {t("common.created_at")}
+ </span>
+ <span className="text-xs">
+ {formatDistanceToNow(new Date(debugInfo.createdAt), {
+ addSuffix: true,
+ })}
+ </span>
+ </div>
+ {debugInfo.modifiedAt && (
+ <div className="flex items-center justify-between gap-2">
+ <span className="flex items-center gap-1.5 text-muted-foreground">
+ <Clock className="h-3.5 w-3.5" />
+ {t("common.updated_at")}
+ </span>
+ <span className="text-xs">
+ {formatDistanceToNow(new Date(debugInfo.modifiedAt), {
+ addSuffix: true,
+ })}
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* Status */}
+ <div className="rounded-lg border bg-muted/30 p-4">
+ <div className="mb-3 flex items-center gap-2">
+ <AlertCircle className="h-4 w-4 text-muted-foreground" />
+ <h3 className="text-sm font-semibold">
+ {t("admin.admin_tools.status")}
+ </h3>
+ </div>
+ <div className="space-y-2.5 text-sm">
+ <div className="flex items-center justify-between gap-2">
+ <span className="flex items-center gap-1.5 text-muted-foreground">
+ <Tag className="h-3.5 w-3.5" />
+ {t("admin.admin_tools.tagging_status")}
+ </span>
+ {getStatusBadge(debugInfo.taggingStatus)}
+ </div>
+ <div className="flex items-center justify-between gap-2">
+ <span className="flex items-center gap-1.5 text-muted-foreground">
+ <Sparkles className="h-3.5 w-3.5" />
+ {t("admin.admin_tools.summarization_status")}
+ </span>
+ {getStatusBadge(debugInfo.summarizationStatus)}
+ </div>
+ {debugInfo.linkInfo && (
+ <>
+ <div className="flex items-center justify-between gap-2">
+ <span className="flex items-center gap-1.5 text-muted-foreground">
+ <RefreshCw className="h-3.5 w-3.5" />
+ {t("admin.admin_tools.crawl_status")}
+ </span>
+ {getStatusBadge(debugInfo.linkInfo.crawlStatus)}
+ </div>
+ <div className="flex items-center justify-between gap-2">
+ <span className="flex items-center gap-1.5 text-muted-foreground">
+ <LinkIcon className="h-3.5 w-3.5" />
+ {t("admin.admin_tools.crawl_status_code")}
+ </span>
+ <Badge
+ variant={
+ debugInfo.linkInfo.crawlStatusCode === null ||
+ (debugInfo.linkInfo.crawlStatusCode >= 200 &&
+ debugInfo.linkInfo.crawlStatusCode < 300)
+ ? "default"
+ : "destructive"
+ }
+ >
+ {debugInfo.linkInfo.crawlStatusCode}
+ </Badge>
+ </div>
+ {debugInfo.linkInfo.crawledAt && (
+ <div className="flex items-center justify-between gap-2">
+ <span className="flex items-center gap-1.5 text-muted-foreground">
+ <Clock className="h-3.5 w-3.5" />
+ {t("admin.admin_tools.crawled_at")}
+ </span>
+ <span className="text-xs">
+ {formatDistanceToNow(
+ new Date(debugInfo.linkInfo.crawledAt),
+ {
+ addSuffix: true,
+ },
+ )}
+ </span>
+ </div>
+ )}
+ </>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* Content */}
+ {(debugInfo.title ||
+ debugInfo.summary ||
+ debugInfo.linkInfo ||
+ debugInfo.textInfo?.sourceUrl ||
+ debugInfo.assetInfo) && (
+ <div className="rounded-lg border bg-muted/30 p-4">
+ <div className="mb-3 flex items-center gap-2">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <h3 className="text-sm font-semibold">
+ {t("admin.admin_tools.content")}
+ </h3>
+ </div>
+ <div className="space-y-3 text-sm">
+ {debugInfo.title && (
+ <div>
+ <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
+ <FileText className="h-3.5 w-3.5" />
+ {t("common.title")}
+ </div>
+ <div className="rounded border bg-background px-3 py-2 font-medium">
+ {debugInfo.title}
+ </div>
+ </div>
+ )}
+ {debugInfo.summary && (
+ <div>
+ <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
+ <Sparkles className="h-3.5 w-3.5" />
+ {t("admin.admin_tools.summary")}
+ </div>
+ <div className="rounded border bg-background px-3 py-2">
+ {debugInfo.summary}
+ </div>
+ </div>
+ )}
+ {debugInfo.linkInfo && (
+ <div>
+ <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
+ <LinkIcon className="h-3.5 w-3.5" />
+ {t("admin.admin_tools.url")}
+ </div>
+ <Link
+ prefetch={false}
+ href={debugInfo.linkInfo.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="flex items-center gap-1.5 rounded border bg-background px-3 py-2 text-primary hover:underline"
+ >
+ <span className="break-all">
+ {debugInfo.linkInfo.url}
+ </span>
+ <ExternalLink className="h-3.5 w-3.5 flex-shrink-0" />
+ </Link>
+ </div>
+ )}
+ {debugInfo.textInfo?.sourceUrl && (
+ <div>
+ <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
+ <LinkIcon className="h-3.5 w-3.5" />
+ {t("admin.admin_tools.source_url")}
+ </div>
+ <Link
+ prefetch={false}
+ href={debugInfo.textInfo.sourceUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="flex items-center gap-1.5 rounded border bg-background px-3 py-2 text-primary hover:underline"
+ >
+ <span className="break-all">
+ {debugInfo.textInfo.sourceUrl}
+ </span>
+ <ExternalLink className="h-3.5 w-3.5 flex-shrink-0" />
+ </Link>
+ </div>
+ )}
+ {debugInfo.assetInfo && (
+ <div>
+ <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
+ <ImageIcon className="h-3.5 w-3.5" />
+ {t("admin.admin_tools.asset_type")}
+ </div>
+ <div className="rounded border bg-background px-3 py-2">
+ <Badge variant="secondary" className="mb-1">
+ {debugInfo.assetInfo.assetType}
+ </Badge>
+ {debugInfo.assetInfo.fileName && (
+ <div className="mt-1 font-mono text-xs text-muted-foreground">
+ {debugInfo.assetInfo.fileName}
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* HTML Preview */}
+ {debugInfo.linkInfo && debugInfo.linkInfo.htmlContentPreview && (
+ <div className="rounded-lg border bg-muted/30 p-4">
+ <button
+ onClick={() => setShowHtmlPreview(!showHtmlPreview)}
+ className="flex w-full items-center gap-2 text-sm font-semibold hover:opacity-70"
+ >
+ {showHtmlPreview ? (
+ <ChevronDown className="h-4 w-4 text-muted-foreground" />
+ ) : (
+ <ChevronRight className="h-4 w-4 text-muted-foreground" />
+ )}
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ {t("admin.admin_tools.html_preview")}
+ </button>
+ {showHtmlPreview && (
+ <pre className="mt-3 max-h-60 overflow-auto rounded-md border bg-muted p-3 text-xs">
+ {debugInfo.linkInfo.htmlContentPreview}
+ </pre>
+ )}
+ </div>
+ )}
+
+ {/* Tags */}
+ {debugInfo.tags.length > 0 && (
+ <div className="rounded-lg border bg-muted/30 p-4">
+ <div className="mb-3 flex items-center gap-2">
+ <Tag className="h-4 w-4 text-muted-foreground" />
+ <h3 className="text-sm font-semibold">
+ {t("common.tags")}{" "}
+ <span className="text-muted-foreground">
+ ({debugInfo.tags.length})
+ </span>
+ </h3>
+ </div>
+ <div className="flex flex-wrap gap-2">
+ {debugInfo.tags.map((tag) => (
+ <Badge
+ key={tag.id}
+ variant={
+ tag.attachedBy === "ai" ? "default" : "secondary"
+ }
+ className="gap-1.5"
+ >
+ {tag.attachedBy === "ai" && (
+ <Sparkles className="h-3 w-3" />
+ )}
+ <span>{tag.name}</span>
+ </Badge>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Assets */}
+ {debugInfo.assets.length > 0 && (
+ <div className="rounded-lg border bg-muted/30 p-4">
+ <div className="mb-3 flex items-center gap-2">
+ <ImageIcon className="h-4 w-4 text-muted-foreground" />
+ <h3 className="text-sm font-semibold">
+ {t("common.attachments")}{" "}
+ <span className="text-muted-foreground">
+ ({debugInfo.assets.length})
+ </span>
+ </h3>
+ </div>
+ <div className="space-y-2 text-sm">
+ {debugInfo.assets.map((asset) => (
+ <div
+ key={asset.id}
+ className="flex items-center justify-between rounded-md border bg-background p-3"
+ >
+ <div className="flex items-center gap-3">
+ <ImageIcon className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <Badge variant="secondary" className="text-xs">
+ {asset.assetType}
+ </Badge>
+ <div className="mt-1 text-xs text-muted-foreground">
+ {formatBytes(asset.size)}
+ </div>
+ </div>
+ </div>
+ {asset.url && (
+ <Link
+ prefetch={false}
+ href={asset.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="flex items-center gap-1.5 text-primary hover:underline"
+ >
+ {t("admin.admin_tools.view")}
+ <ExternalLink className="h-3.5 w-3.5" />
+ </Link>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Actions */}
+ <div className="rounded-lg border border-dashed bg-muted/20 p-4">
+ <div className="mb-3 flex items-center gap-2">
+ <RefreshCw className="h-4 w-4 text-muted-foreground" />
+ <h3 className="text-sm font-semibold">{t("common.actions")}</h3>
+ </div>
+ <div className="flex flex-wrap gap-2">
+ <Button
+ onClick={handleRecrawl}
+ disabled={
+ debugInfo.type !== BookmarkTypes.LINK ||
+ recrawlMutation.isPending
+ }
+ size="sm"
+ variant="outline"
+ >
+ {recrawlMutation.isPending ? (
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ ) : (
+ <RefreshCw className="mr-2 h-4 w-4" />
+ )}
+ {t("admin.admin_tools.recrawl")}
+ </Button>
+ <Button
+ onClick={handleReindex}
+ disabled={reindexMutation.isPending}
+ size="sm"
+ variant="outline"
+ >
+ {reindexMutation.isPending ? (
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ ) : (
+ <Search className="mr-2 h-4 w-4" />
+ )}
+ {t("admin.admin_tools.reindex")}
+ </Button>
+ <Button
+ onClick={handleRetag}
+ disabled={retagMutation.isPending}
+ size="sm"
+ variant="outline"
+ >
+ {retagMutation.isPending ? (
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ ) : (
+ <Tag className="mr-2 h-4 w-4" />
+ )}
+ {t("admin.admin_tools.retag")}
+ </Button>
+ <Button
+ onClick={handleResummarize}
+ disabled={
+ debugInfo.type !== BookmarkTypes.LINK ||
+ resummarizeMutation.isPending
+ }
+ size="sm"
+ variant="outline"
+ >
+ {resummarizeMutation.isPending ? (
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ ) : (
+ <Sparkles className="mr-2 h-4 w-4" />
+ )}
+ {t("admin.admin_tools.resummarize")}
+ </Button>
+ </div>
+ </div>
+ </div>
+ </AdminCard>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/components/ui/info-tooltip.tsx b/apps/web/components/ui/info-tooltip.tsx
index 4dd97199..9d525983 100644
--- a/apps/web/components/ui/info-tooltip.tsx
+++ b/apps/web/components/ui/info-tooltip.tsx
@@ -22,8 +22,7 @@ export default function InfoTooltip({
<TooltipTrigger asChild>
{variant === "tip" ? (
<Info
- color="#494949"
- className={cn("z-10 cursor-pointer", className)}
+ className={cn("z-10 cursor-pointer text-[#494949]", className)}
size={size}
/>
) : (
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 1817db81..315564d7 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -1,6 +1,7 @@
{
"common": {
"default": "Default",
+ "id": "ID",
"url": "URL",
"name": "Name",
"email": "Email",
@@ -583,6 +584,42 @@
"local_user": "Local User",
"confirm_password": "Confirm Password",
"unlimited": "Unlimited"
+ },
+ "admin_tools": {
+ "admin_tools": "Admin Tools",
+ "bookmark_debugger": "Bookmark Debugger",
+ "bookmark_id": "Bookmark ID",
+ "bookmark_id_placeholder": "Enter bookmark ID",
+ "lookup": "Lookup",
+ "debug_info": "Debug Information",
+ "basic_info": "Basic Information",
+ "status": "Status",
+ "content": "Content",
+ "html_preview": "HTML Preview (First 1000 chars)",
+ "summary": "Summary",
+ "url": "URL",
+ "source_url": "Source URL",
+ "asset_type": "Asset Type",
+ "file_name": "File Name",
+ "owner_user_id": "Owner User ID",
+ "tagging_status": "Tagging Status",
+ "summarization_status": "Summarization Status",
+ "crawl_status": "Crawl Status",
+ "crawl_status_code": "HTTP Status Code",
+ "crawled_at": "Crawled At",
+ "recrawl": "Re-crawl",
+ "reindex": "Re-index",
+ "retag": "Re-tag",
+ "resummarize": "Re-summarize",
+ "bookmark_not_found": "Bookmark not found",
+ "action_success": "Action completed successfully",
+ "action_failed": "Action failed",
+ "recrawl_queued": "Re-crawl job has been queued",
+ "reindex_queued": "Re-index job has been queued",
+ "retag_queued": "Re-tag job has been queued",
+ "resummarize_queued": "Re-summarize job has been queued",
+ "view": "View",
+ "fetch_error": "Error fetching bookmark"
}
},
"options": {
diff --git a/packages/trpc/models/assets.ts b/packages/trpc/models/assets.ts
index ad114341..f97cfffb 100644
--- a/packages/trpc/models/assets.ts
+++ b/packages/trpc/models/assets.ts
@@ -4,7 +4,11 @@ import { z } from "zod";
import { assets } from "@karakeep/db/schema";
import { deleteAsset } from "@karakeep/shared/assetdb";
+import serverConfig from "@karakeep/shared/config";
+import { createSignedToken } from "@karakeep/shared/signedTokens";
+import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets";
import { zAssetTypesSchema } from "@karakeep/shared/types/bookmarks";
+import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
import { AuthedContext } from "..";
import {
@@ -254,4 +258,25 @@ export class Asset {
});
}
}
+
+ getUrl() {
+ return getAssetUrl(this.asset.id);
+ }
+
+ static getPublicSignedAssetUrl(
+ assetId: string,
+ assetOwnerId: string,
+ expireAt: number,
+ ) {
+ const payload: z.infer<typeof zAssetSignedTokenSchema> = {
+ assetId,
+ userId: assetOwnerId,
+ };
+ const signedToken = createSignedToken(
+ payload,
+ serverConfig.signingSecret(),
+ expireAt,
+ );
+ return `${serverConfig.publicApiUrl}/public/assets/${assetId}?token=${signedToken}`;
+ }
}
diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts
index 0700246f..c8cd1f00 100644
--- a/packages/trpc/models/bookmarks.ts
+++ b/packages/trpc/models/bookmarks.ts
@@ -31,12 +31,7 @@ import {
} from "@karakeep/db/schema";
import { SearchIndexingQueue, triggerWebhook } from "@karakeep/shared-server";
import { deleteAsset, readAsset } from "@karakeep/shared/assetdb";
-import serverConfig from "@karakeep/shared/config";
-import {
- createSignedToken,
- getAlignedExpiry,
-} from "@karakeep/shared/signedTokens";
-import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets";
+import { getAlignedExpiry } from "@karakeep/shared/signedTokens";
import {
BookmarkTypes,
DEFAULT_NUM_BOOKMARKS_PER_PAGE,
@@ -55,6 +50,7 @@ import { htmlToPlainText } from "@karakeep/shared/utils/htmlUtils";
import { AuthedContext } from "..";
import { mapDBAssetTypeToUserType } from "../lib/attachments";
+import { Asset } from "./assets";
import { List } from "./lists";
async function dummyDrizzleReturnType() {
@@ -271,6 +267,130 @@ export class Bookmark extends BareBookmark {
return new Bookmark(ctx, data);
}
+ static async buildDebugInfo(ctx: AuthedContext, bookmarkId: string) {
+ // Verify the user is an admin
+ if (ctx.user.role !== "admin") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Admin access required",
+ });
+ }
+
+ const PRIVACY_REDACTED_ASSET_TYPES = new Set<AssetTypes>([
+ AssetTypes.USER_UPLOADED,
+ AssetTypes.BOOKMARK_ASSET,
+ ]);
+
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, bookmarkId),
+ with: {
+ link: true,
+ text: true,
+ asset: true,
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ assets: true,
+ },
+ });
+
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ // Build link info
+ let linkInfo = null;
+ if (bookmark.link) {
+ const htmlContentPreview = await (async () => {
+ try {
+ const content = await Bookmark.getBookmarkHtmlContent(
+ bookmark.link!,
+ bookmark.userId,
+ );
+ return content ? content.substring(0, 1000) : null;
+ } catch {
+ return null;
+ }
+ })();
+
+ linkInfo = {
+ url: bookmark.link.url,
+ crawlStatus: bookmark.link.crawlStatus ?? "pending",
+ crawlStatusCode: bookmark.link.crawlStatusCode,
+ crawledAt: bookmark.link.crawledAt,
+ hasHtmlContent: !!bookmark.link.htmlContent,
+ hasContentAsset: !!bookmark.link.contentAssetId,
+ htmlContentPreview,
+ };
+ }
+
+ // Build text info
+ let textInfo = null;
+ if (bookmark.text) {
+ textInfo = {
+ hasText: !!bookmark.text.text,
+ sourceUrl: bookmark.text.sourceUrl,
+ };
+ }
+
+ // Build asset info
+ let assetInfo = null;
+ if (bookmark.asset) {
+ assetInfo = {
+ assetType: bookmark.asset.assetType,
+ hasContent: !!bookmark.asset.content,
+ fileName: bookmark.asset.fileName,
+ };
+ }
+
+ // Build tags
+ const tags = bookmark.tagsOnBookmarks.map((t) => ({
+ id: t.tag.id,
+ name: t.tag.name,
+ attachedBy: t.attachedBy,
+ }));
+
+ // Build assets list with signed URLs (exclude userUploaded)
+ const assetsWithUrls = bookmark.assets.map((a) => {
+ // Generate signed token with 10 mins expiry
+ const expiresAt = Date.now() + 10 * 60 * 1000; // 10 mins
+ // Exclude userUploaded assets for privacy reasons
+ const url = !PRIVACY_REDACTED_ASSET_TYPES.has(a.assetType)
+ ? Asset.getPublicSignedAssetUrl(a.id, bookmark.userId, expiresAt)
+ : null;
+
+ return {
+ id: a.id,
+ assetType: a.assetType,
+ size: a.size,
+ url,
+ };
+ });
+
+ return {
+ id: bookmark.id,
+ type: bookmark.type,
+ source: bookmark.source,
+ createdAt: bookmark.createdAt,
+ modifiedAt: bookmark.modifiedAt,
+ title: bookmark.title,
+ summary: bookmark.summary,
+ taggingStatus: bookmark.taggingStatus,
+ summarizationStatus: bookmark.summarizationStatus,
+ userId: bookmark.userId,
+ linkInfo,
+ textInfo,
+ assetInfo,
+ tags,
+ assets: assetsWithUrls,
+ };
+ }
+
static async loadMulti(
ctx: AuthedContext,
input: z.infer<typeof zGetBookmarksRequestSchema>,
@@ -641,17 +761,12 @@ export class Bookmark extends BareBookmark {
asPublicBookmark(): ZPublicBookmark {
const getPublicSignedAssetUrl = (assetId: string) => {
- const payload: z.infer<typeof zAssetSignedTokenSchema> = {
+ // Tokens will expire in 1 hour and will have a grace period of 15mins
+ return Asset.getPublicSignedAssetUrl(
assetId,
- userId: this.ctx.user.id,
- };
- const signedToken = createSignedToken(
- payload,
- serverConfig.signingSecret(),
- // Tokens will expire in 1 hour and will have a grace period of 15mins
- getAlignedExpiry(/* interval */ 3600, /* grace */ 900),
+ this.bookmark.userId,
+ getAlignedExpiry(3600, 900),
);
- return `${serverConfig.publicApiUrl}/public/assets/${assetId}?token=${signedToken}`;
};
const getContent = (
content: ZBookmarkContent,
diff --git a/packages/trpc/routers/admin.test.ts b/packages/trpc/routers/admin.test.ts
new file mode 100644
index 00000000..2f80d9c0
--- /dev/null
+++ b/packages/trpc/routers/admin.test.ts
@@ -0,0 +1,265 @@
+import { eq } from "drizzle-orm";
+import { assert, beforeEach, describe, expect, test } from "vitest";
+
+import { bookmarkLinks, users } from "@karakeep/db/schema";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+
+import type { CustomTestContext } from "../testUtils";
+import { buildTestContext, getApiCaller } from "../testUtils";
+
+beforeEach<CustomTestContext>(async (context) => {
+ const testContext = await buildTestContext(true);
+ Object.assign(context, testContext);
+});
+
+describe("Admin Routes", () => {
+ describe("getBookmarkDebugInfo", () => {
+ test<CustomTestContext>("admin can access bookmark debug info for link bookmark", async ({
+ apiCallers,
+ db,
+ }) => {
+ // Create an admin user
+ const adminUser = await db
+ .insert(users)
+ .values({
+ name: "Admin User",
+ email: "admin@test.com",
+ role: "admin",
+ })
+ .returning();
+ const adminApi = getApiCaller(
+ db,
+ adminUser[0].id,
+ adminUser[0].email,
+ "admin",
+ );
+
+ // Create a bookmark as a regular user
+ const bookmark = await apiCallers[0].bookmarks.createBookmark({
+ url: "https://example.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Update the bookmark link with some metadata
+ await db
+ .update(bookmarkLinks)
+ .set({
+ crawlStatus: "success",
+ crawlStatusCode: 200,
+ crawledAt: new Date(),
+ htmlContent: "<html><body>Test content</body></html>",
+ title: "Test Title",
+ description: "Test Description",
+ })
+ .where(eq(bookmarkLinks.id, bookmark.id));
+
+ // Admin should be able to access debug info
+ const debugInfo = await adminApi.admin.getBookmarkDebugInfo({
+ bookmarkId: bookmark.id,
+ });
+
+ expect(debugInfo.id).toEqual(bookmark.id);
+ expect(debugInfo.type).toEqual(BookmarkTypes.LINK);
+ expect(debugInfo.linkInfo).toBeDefined();
+ assert(debugInfo.linkInfo);
+ expect(debugInfo.linkInfo.url).toEqual("https://example.com");
+ expect(debugInfo.linkInfo.crawlStatus).toEqual("success");
+ expect(debugInfo.linkInfo.crawlStatusCode).toEqual(200);
+ expect(debugInfo.linkInfo.hasHtmlContent).toEqual(true);
+ expect(debugInfo.linkInfo.htmlContentPreview).toBeDefined();
+ expect(debugInfo.linkInfo.htmlContentPreview).toContain("Test content");
+ });
+
+ test<CustomTestContext>("admin can access bookmark debug info for text bookmark", async ({
+ apiCallers,
+ db,
+ }) => {
+ // Create an admin user
+ const adminUser = await db
+ .insert(users)
+ .values({
+ name: "Admin User",
+ email: "admin@test.com",
+ role: "admin",
+ })
+ .returning();
+ const adminApi = getApiCaller(
+ db,
+ adminUser[0].id,
+ adminUser[0].email,
+ "admin",
+ );
+
+ // Create a text bookmark
+ const bookmark = await apiCallers[0].bookmarks.createBookmark({
+ text: "This is a test text bookmark",
+ type: BookmarkTypes.TEXT,
+ });
+
+ // Admin should be able to access debug info
+ const debugInfo = await adminApi.admin.getBookmarkDebugInfo({
+ bookmarkId: bookmark.id,
+ });
+
+ expect(debugInfo.id).toEqual(bookmark.id);
+ expect(debugInfo.type).toEqual(BookmarkTypes.TEXT);
+ expect(debugInfo.textInfo).toBeDefined();
+ assert(debugInfo.textInfo);
+ expect(debugInfo.textInfo.hasText).toEqual(true);
+ });
+
+ test<CustomTestContext>("admin can see bookmark tags in debug info", async ({
+ apiCallers,
+ db,
+ }) => {
+ // Create an admin user
+ const adminUser = await db
+ .insert(users)
+ .values({
+ name: "Admin User",
+ email: "admin@test.com",
+ role: "admin",
+ })
+ .returning();
+ const adminApi = getApiCaller(
+ db,
+ adminUser[0].id,
+ adminUser[0].email,
+ "admin",
+ );
+
+ // Create a bookmark with tags
+ const bookmark = await apiCallers[0].bookmarks.createBookmark({
+ url: "https://example.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Add tags to the bookmark
+ await apiCallers[0].bookmarks.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [{ tagName: "test-tag-1" }, { tagName: "test-tag-2" }],
+ detach: [],
+ });
+
+ // Admin should be able to see tags in debug info
+ const debugInfo = await adminApi.admin.getBookmarkDebugInfo({
+ bookmarkId: bookmark.id,
+ });
+
+ expect(debugInfo.tags).toHaveLength(2);
+ expect(debugInfo.tags.map((t) => t.name).sort()).toEqual([
+ "test-tag-1",
+ "test-tag-2",
+ ]);
+ expect(debugInfo.tags[0].attachedBy).toEqual("human");
+ });
+
+ test<CustomTestContext>("non-admin user cannot access bookmark debug info", async ({
+ apiCallers,
+ }) => {
+ // Create a bookmark
+ const bookmark = await apiCallers[0].bookmarks.createBookmark({
+ url: "https://example.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Non-admin user should not be able to access debug info
+ // The admin procedure itself will throw FORBIDDEN
+ await expect(() =>
+ apiCallers[0].admin.getBookmarkDebugInfo({ bookmarkId: bookmark.id }),
+ ).rejects.toThrow(/FORBIDDEN/);
+ });
+
+ test<CustomTestContext>("debug info includes asset URLs with signed tokens", async ({
+ apiCallers,
+ db,
+ }) => {
+ // Create an admin user
+ const adminUser = await db
+ .insert(users)
+ .values({
+ name: "Admin User",
+ email: "admin@test.com",
+ role: "admin",
+ })
+ .returning();
+ const adminApi = getApiCaller(
+ db,
+ adminUser[0].id,
+ adminUser[0].email,
+ "admin",
+ );
+
+ // Create a bookmark
+ const bookmark = await apiCallers[0].bookmarks.createBookmark({
+ url: "https://example.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Get debug info
+ const debugInfo = await adminApi.admin.getBookmarkDebugInfo({
+ bookmarkId: bookmark.id,
+ });
+
+ // Check that assets array is present
+ expect(debugInfo.assets).toBeDefined();
+ expect(Array.isArray(debugInfo.assets)).toBe(true);
+
+ // If there are assets, check that they have signed URLs
+ if (debugInfo.assets.length > 0) {
+ const asset = debugInfo.assets[0];
+ expect(asset.url).toBeDefined();
+ expect(asset.url).toContain("/api/public/assets/");
+ expect(asset.url).toContain("token=");
+ }
+ });
+
+ test<CustomTestContext>("debug info truncates HTML content preview", async ({
+ apiCallers,
+ db,
+ }) => {
+ // Create an admin user
+ const adminUser = await db
+ .insert(users)
+ .values({
+ name: "Admin User",
+ email: "admin@test.com",
+ role: "admin",
+ })
+ .returning();
+ const adminApi = getApiCaller(
+ db,
+ adminUser[0].id,
+ adminUser[0].email,
+ "admin",
+ );
+
+ // Create a bookmark
+ const bookmark = await apiCallers[0].bookmarks.createBookmark({
+ url: "https://example.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Create a large HTML content
+ const largeContent = "<html><body>" + "x".repeat(2000) + "</body></html>";
+ await db
+ .update(bookmarkLinks)
+ .set({
+ htmlContent: largeContent,
+ })
+ .where(eq(bookmarkLinks.id, bookmark.id));
+
+ // Get debug info
+ const debugInfo = await adminApi.admin.getBookmarkDebugInfo({
+ bookmarkId: bookmark.id,
+ });
+
+ // Check that HTML preview is truncated to 1000 characters
+ assert(debugInfo.linkInfo);
+ expect(debugInfo.linkInfo.htmlContentPreview).toBeDefined();
+ expect(debugInfo.linkInfo.htmlContentPreview!.length).toBeLessThanOrEqual(
+ 1000,
+ );
+ });
+ });
+});
diff --git a/packages/trpc/routers/admin.ts b/packages/trpc/routers/admin.ts
index 44a51cad..3236529d 100644
--- a/packages/trpc/routers/admin.ts
+++ b/packages/trpc/routers/admin.ts
@@ -17,6 +17,7 @@ import {
zAdminMaintenanceTaskSchema,
} from "@karakeep/shared-server";
import serverConfig from "@karakeep/shared/config";
+import logger from "@karakeep/shared/logger";
import { PluginManager, PluginType } from "@karakeep/shared/plugins";
import { getSearchClient } from "@karakeep/shared/search";
import {
@@ -24,9 +25,11 @@ import {
updateUserSchema,
zAdminCreateUserSchema,
} from "@karakeep/shared/types/admin";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import { generatePasswordSalt, hashPassword } from "../auth";
import { adminProcedure, router } from "../index";
+import { Bookmark } from "../models/bookmarks";
import { User } from "../models/users";
export const adminAppRouter = router({
@@ -558,4 +561,173 @@ export const adminAppRouter = router({
queue: queueStatus,
};
}),
+ getBookmarkDebugInfo: adminProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .output(
+ z.object({
+ id: z.string(),
+ type: z.enum([
+ BookmarkTypes.LINK,
+ BookmarkTypes.TEXT,
+ BookmarkTypes.ASSET,
+ ]),
+ source: z
+ .enum([
+ "api",
+ "web",
+ "extension",
+ "cli",
+ "mobile",
+ "singlefile",
+ "rss",
+ "import",
+ ])
+ .nullable(),
+ createdAt: z.date(),
+ modifiedAt: z.date().nullable(),
+ title: z.string().nullable(),
+ summary: z.string().nullable(),
+ taggingStatus: z.enum(["pending", "failure", "success"]).nullable(),
+ summarizationStatus: z
+ .enum(["pending", "failure", "success"])
+ .nullable(),
+ userId: z.string(),
+ linkInfo: z
+ .object({
+ url: z.string(),
+ crawlStatus: z.enum(["pending", "failure", "success"]),
+ crawlStatusCode: z.number().nullable(),
+ crawledAt: z.date().nullable(),
+ hasHtmlContent: z.boolean(),
+ hasContentAsset: z.boolean(),
+ htmlContentPreview: z.string().nullable(),
+ })
+ .nullable(),
+ textInfo: z
+ .object({
+ hasText: z.boolean(),
+ sourceUrl: z.string().nullable(),
+ })
+ .nullable(),
+ assetInfo: z
+ .object({
+ assetType: z.enum(["image", "pdf"]),
+ hasContent: z.boolean(),
+ fileName: z.string().nullable(),
+ })
+ .nullable(),
+ tags: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ attachedBy: z.enum(["ai", "human"]),
+ }),
+ ),
+ assets: z.array(
+ z.object({
+ id: z.string(),
+ assetType: z.string(),
+ size: z.number(),
+ url: z.string().nullable(),
+ }),
+ ),
+ }),
+ )
+ .query(async ({ input, ctx }) => {
+ logger.info(
+ `[admin] Admin ${ctx.user.id} accessed debug info for bookmark ${input.bookmarkId}`,
+ );
+
+ return await Bookmark.buildDebugInfo(ctx, input.bookmarkId);
+ }),
+ adminRecrawlBookmark: adminProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ // Verify bookmark exists and is a link
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, input.bookmarkId),
+ });
+
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ if (bookmark.type !== BookmarkTypes.LINK) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only link bookmarks can be recrawled",
+ });
+ }
+
+ await LinkCrawlerQueue.enqueue({
+ bookmarkId: input.bookmarkId,
+ });
+ }),
+ adminReindexBookmark: adminProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ // Verify bookmark exists
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, input.bookmarkId),
+ });
+
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ await triggerSearchReindex(input.bookmarkId);
+ }),
+ adminRetagBookmark: adminProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ // Verify bookmark exists
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, input.bookmarkId),
+ });
+
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ await OpenAIQueue.enqueue({
+ bookmarkId: input.bookmarkId,
+ type: "tag",
+ });
+ }),
+ adminResummarizeBookmark: adminProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ // Verify bookmark exists and is a link
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, input.bookmarkId),
+ });
+
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ if (bookmark.type !== BookmarkTypes.LINK) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only link bookmarks can be summarized",
+ });
+ }
+
+ await OpenAIQueue.enqueue({
+ bookmarkId: input.bookmarkId,
+ type: "summarize",
+ });
+ }),
});