feat: refine Obsidian plugin routes and schemas for improved device management and API stability

This commit is contained in:
Anish Sarkar 2026-04-20 18:19:30 +05:30
parent 60d9e7ed8c
commit b5c9388c8a
9 changed files with 182 additions and 385 deletions

View file

@ -1,19 +1,11 @@
"use client";
import { AlertTriangle, Check, Copy, Download, Info } from "lucide-react";
import { type FC, useCallback, useMemo, useRef, useState } from "react";
import { AlertTriangle, Download, Info } from "lucide-react";
import { type FC, useMemo } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useApiKey } from "@/hooks/use-api-key";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
import type { ConnectorConfigProps } from "../index";
export interface ObsidianConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
const PLUGIN_RELEASES_URL =
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
@ -27,55 +19,32 @@ function formatTimestamp(value: unknown): string {
/**
* Obsidian connector config view.
*
* Renders one of two modes depending on the connector's `config`:
* Read-only on purpose: the plugin owns vault identity, so the connector's
* display name is auto-derived from `payload.vault_name` server-side on
* every `/connect` (see `obsidian_plugin_routes.obsidian_connect`). The
* web UI doesn't expose a Name input or a Save button for Obsidian (the
* latter is suppressed in `connector-edit-view.tsx`).
*
* Renders one of three modes depending on the connector's `config`:
*
* 1. **Plugin connector** (`config.source === "plugin"`) read-only stats
* panel showing what the plugin most recently reported.
* 2. **Legacy server-path connector** (`config.legacy === true`, set by the
* Phase 3 alembic) migration banner plus an "Install Plugin" CTA.
* The user's existing notes stay searchable; only background sync stops.
* Phase 3 alembic) migration banner, an "Install Plugin" CTA, and a
* short "how to migrate" checklist that ends with the user pressing the
* standard Disconnect button (which deletes this connector along with
* every document it previously indexed).
* 3. **Unknown** fallback for rows that escaped the alembic; suggests a
* clean re-install.
*/
export const ObsidianConfig: FC<ObsidianConfigProps> = ({
connector,
onNameChange,
}) => {
const [name, setName] = useState<string>(connector.name || "");
export const ObsidianConfig: FC<ConnectorConfigProps> = ({ connector }) => {
const config = (connector.config ?? {}) as Record<string, unknown>;
const isLegacy = config.legacy === true;
const isPlugin = config.source === "plugin";
const handleNameChange = (value: string) => {
setName(value);
onNameChange?.(value);
};
return (
<div className="space-y-6">
{/* Connector name (always editable) */}
<div className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 space-y-3 sm:space-y-4 dark:bg-white/5">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Obsidian Vault"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
{isLegacy ? (
<LegacyBanner />
) : isPlugin ? (
<PluginStats config={config} />
) : (
<UnknownConnectorState />
)}
</div>
);
if (isLegacy) return <LegacyBanner />;
if (isPlugin) return <PluginStats config={config} />;
return <UnknownConnectorState />;
};
const LegacyBanner: FC = () => {
@ -84,14 +53,12 @@ const LegacyBanner: FC = () => {
<Alert className="border-amber-500/40 bg-amber-500/10">
<AlertTriangle className="size-4 shrink-0 text-amber-500" />
<AlertTitle className="text-xs sm:text-sm">
This connector has been migrated
Sync stopped install the plugin to migrate
</AlertTitle>
<AlertDescription className="text-[11px] sm:text-xs leading-relaxed">
This Obsidian connector used the legacy server-path method, which has
been removed. To resume syncing, install the SurfSense Obsidian
plugin and connect with this account. Your existing notes remain
searchable. After the plugin re-indexes your vault, you can delete
this connector to remove older copies.
This Obsidian connector used the legacy server-path scanner, which has been removed. The
notes already indexed remain searchable, but they no longer reflect changes made in your
vault.
</AlertDescription>
</Alert>
@ -107,7 +74,25 @@ const LegacyBanner: FC = () => {
</Button>
</a>
<ApiKeyReminder />
<div className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
<h3 className="mb-3 text-sm font-medium sm:text-base">How to migrate</h3>
<ol className="list-decimal space-y-2 pl-5 text-[11px] leading-relaxed text-muted-foreground sm:text-xs">
<li>Install the SurfSense Obsidian plugin using the button above.</li>
<li>
In Obsidian, open Settings SurfSense, sign in, pick a search space, and wait for the
first sync to finish.
</li>
<li>
Confirm the new "Obsidian — &lt;vault&gt;" connector shows your notes, then return here
and use the Disconnect button below to remove this legacy connector.
</li>
</ol>
<p className="mt-3 text-[11px] leading-relaxed text-amber-600 dark:text-amber-400 sm:text-xs">
Heads up: Disconnect also deletes every document this connector previously indexed. Make
sure the plugin has finished its first sync before you disconnect, otherwise your Obsidian
notes will disappear from search until the plugin re-indexes them.
</p>
</div>
</div>
);
};
@ -115,6 +100,14 @@ const LegacyBanner: FC = () => {
const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
const stats: { label: string; value: string }[] = useMemo(() => {
const filesSynced = config.files_synced;
// Prefer the stamped count; fall back to len(devices) for rows the
// backend hasn't re-stamped yet.
const deviceCount =
typeof config.device_count === "number"
? config.device_count
: config.devices && typeof config.devices === "object"
? Object.keys(config.devices as Record<string, unknown>).length
: null;
return [
{ label: "Vault", value: (config.vault_name as string) || "—" },
{
@ -122,11 +115,8 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
value: (config.plugin_version as string) || "—",
},
{
label: "Device",
value:
(config.device_label as string) ||
(config.device_id as string) ||
"—",
label: "Devices",
value: deviceCount !== null ? deviceCount.toLocaleString() : "—",
},
{
label: "Last sync",
@ -134,8 +124,7 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
},
{
label: "Files synced",
value:
typeof filesSynced === "number" ? filesSynced.toLocaleString() : "—",
value: typeof filesSynced === "number" ? filesSynced.toLocaleString() : "—",
},
];
}, [config]);
@ -146,8 +135,8 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
<Info className="size-4 shrink-0 text-emerald-500" />
<AlertTitle className="text-xs sm:text-sm">Plugin connected</AlertTitle>
<AlertDescription className="text-[11px] sm:text-xs">
Edits in Obsidian sync over HTTPS. To stop syncing, disable or
uninstall the plugin in Obsidian, or delete this connector.
Edits in Obsidian sync over HTTPS. To stop syncing, disable or uninstall the plugin in
Obsidian, or delete this connector.
</AlertDescription>
</Alert>
@ -162,9 +151,7 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground sm:text-xs">
{stat.label}
</dt>
<dd className="mt-1 truncate text-xs font-medium sm:text-sm">
{stat.value}
</dd>
<dd className="mt-1 truncate text-xs font-medium sm:text-sm">{stat.value}</dd>
</div>
))}
</dl>
@ -178,98 +165,8 @@ const UnknownConnectorState: FC = () => (
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">Unrecognized config</AlertTitle>
<AlertDescription className="text-[11px] sm:text-xs">
This connector has neither plugin metadata nor a legacy marker. It may
predate the migration you can safely delete it and re-install the
SurfSense Obsidian plugin to resume syncing.
This connector has neither plugin metadata nor a legacy marker. It may predate the migration
you can safely delete it and re-install the SurfSense Obsidian plugin to resume syncing.
</AlertDescription>
</Alert>
);
const ApiKeyReminder: FC = () => {
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const [copiedUrl, setCopiedUrl] = useState(false);
const urlCopyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined
);
const backendUrl =
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "https://api.surfsense.com";
const copyServerUrl = useCallback(async () => {
const ok = await copyToClipboardUtil(backendUrl);
if (!ok) return;
setCopiedUrl(true);
if (urlCopyTimerRef.current) clearTimeout(urlCopyTimerRef.current);
urlCopyTimerRef.current = setTimeout(() => setCopiedUrl(false), 2000);
}, [backendUrl]);
return (
<div className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 space-y-3 dark:bg-white/5">
<h3 className="text-sm font-medium sm:text-base">
Plugin connection details
</h3>
<p className="text-[11px] text-muted-foreground sm:text-xs">
Paste these into the plugin's settings inside Obsidian.
</p>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">API token</Label>
{isLoading ? (
<div className="h-9 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
) : (
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<p className="cursor-text select-all whitespace-nowrap font-mono text-[10px] text-muted-foreground">
{apiKey || "No API key available"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={copyToClipboard}
disabled={!apiKey}
className="size-7 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={copied ? "Copied" : "Copy API key"}
>
{copied ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
</Button>
</div>
)}
<p className="text-[10px] text-muted-foreground sm:text-xs">
Token expires after 24 hours; long-lived tokens are coming in a
future release.
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Server URL</Label>
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<p className="cursor-text select-all whitespace-nowrap font-mono text-[10px] text-muted-foreground">
{backendUrl}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={copyServerUrl}
className="size-7 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={copiedUrl ? "Copied" : "Copy server URL"}
>
{copiedUrl ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
</Button>
</div>
</div>
</div>
);
};

View file

@ -87,6 +87,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
const isAuthExpired = connector.config?.auth_expired === true;
const reauthEndpoint = REAUTH_ENDPOINTS[connector.connector_type];
const [reauthing, setReauthing] = useState(false);
// Obsidian is plugin-driven: name + config are owned by the plugin, so
// the web edit view has nothing the user can persist back. Hide Save
// (and re-auth, which Obsidian never uses) entirely for that type.
const isPluginManagedReadOnly = connector.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR;
const handleReauth = useCallback(async () => {
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
@ -412,7 +416,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
Disconnect
</Button>
)}
{isAuthExpired && reauthEndpoint ? (
{isPluginManagedReadOnly ? null : isAuthExpired && reauthEndpoint ? (
<Button
onClick={handleReauth}
disabled={reauthing || isDisconnecting}