mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-21 18:55:16 +02:00
feat: refine Obsidian plugin routes and schemas for improved device management and API stability
This commit is contained in:
parent
60d9e7ed8c
commit
b5c9388c8a
9 changed files with 182 additions and 385 deletions
|
|
@ -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 — <vault>" 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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue