feat: update Obsidian connector to support plugin-based syncing and improve UI components

This commit is contained in:
Anish Sarkar 2026-04-20 04:03:45 +05:30
parent e8fc1069bc
commit ee2fb79e75
4 changed files with 415 additions and 409 deletions

View file

@ -1,314 +1,212 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { Check, Copy, Download, Info, KeyRound, Settings2 } from "lucide-react";
import { Info } from "lucide-react"; import { type FC, useCallback, useRef, useState } from "react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { import { Button } from "@/components/ui/button";
Form, import { useApiKey } from "@/hooks/use-api-key";
FormControl, import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorBenefits } from "../connector-benefits"; import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index"; import type { ConnectFormProps } from "../index";
const obsidianConnectorFormSchema = z.object({ const PLUGIN_RELEASES_URL =
name: z.string().min(3, { "https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
message: "Connector name must be at least 3 characters.",
}),
vault_path: z.string().min(1, {
message: "Vault path is required.",
}),
vault_name: z.string().min(1, {
message: "Vault name is required.",
}),
exclude_folders: z.string().optional(),
include_attachments: z.boolean(),
});
type ObsidianConnectorFormValues = z.infer<typeof obsidianConnectorFormSchema>; const BACKEND_URL =
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "https://api.surfsense.com";
export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => { /**
const isSubmittingRef = useRef(false); * Obsidian connect form for the plugin-only architecture.
const [periodicEnabled, setPeriodicEnabled] = useState(true); *
const [frequencyMinutes, setFrequencyMinutes] = useState("60"); * The legacy `vault_path` form was removed because it only worked on
const form = useForm<ObsidianConnectorFormValues>({ * self-hosted with a server-side bind mount and broke for everyone else.
resolver: zodResolver(obsidianConnectorFormSchema), * The plugin pushes data over HTTPS so this UI is purely instructional
defaultValues: { * there is no backend create call here. The connector row is created
name: "Obsidian Vault", * server-side the first time the plugin calls `POST /obsidian/connect`.
vault_path: "", *
vault_name: "", * The footer "Connect" button in `ConnectorConnectView` triggers this
exclude_folders: ".obsidian,.trash", * form's submit; we just close the dialog (`onBack()`) since there's
include_attachments: false, * nothing to validate or persist from this side.
}, */
}); export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const [copiedUrl, setCopiedUrl] = useState(false);
const urlCopyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined
);
const handleSubmit = async (values: ObsidianConnectorFormValues) => { const copyServerUrl = useCallback(async () => {
// Prevent multiple submissions const ok = await copyToClipboardUtil(BACKEND_URL);
if (isSubmittingRef.current || isSubmitting) { if (!ok) return;
return; setCopiedUrl(true);
} if (urlCopyTimerRef.current) clearTimeout(urlCopyTimerRef.current);
urlCopyTimerRef.current = setTimeout(() => setCopiedUrl(false), 2000);
}, []);
isSubmittingRef.current = true; const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
try { event.preventDefault();
// Parse exclude_folders into an array onBack();
const excludeFolders = values.exclude_folders
? values.exclude_folders
.split(",")
.map((f) => f.trim())
.filter(Boolean)
: [".obsidian", ".trash"];
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.OBSIDIAN_CONNECTOR,
config: {
vault_path: values.vault_path,
vault_name: values.vault_name,
exclude_folders: excludeFolders,
include_attachments: values.include_attachments,
},
is_indexable: true,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? Number.parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
}; };
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3"> {/* Form is intentionally empty so the footer Connect button is a no-op
that just closes the dialog (see component-level docstring). */}
<form id="obsidian-connect-form" onSubmit={handleSubmit} />
<Alert className="border-purple-500/30 bg-purple-500/10 p-2 sm:p-3 dark:bg-purple-500/10">
<Info className="size-4 shrink-0 text-purple-500" /> <Info className="size-4 shrink-0 text-purple-500" />
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle> <AlertTitle className="text-xs sm:text-sm">Plugin-based sync</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs"> <AlertDescription className="text-[10px] sm:text-xs">
This connector requires direct file system access and only works with self-hosted SurfSense now syncs Obsidian via an official plugin that runs inside
SurfSense installations. Obsidian itself. Works on desktop and mobile, in cloud and self-hosted
deployments no server-side vault mounts required.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> {/* Step 1 — Install plugin */}
<Form {...form}> <section className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
<form <header className="mb-3 flex items-center gap-2">
id="obsidian-connect-form" <div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
onSubmit={form.handleSubmit(handleSubmit)} 1
className="space-y-4 sm:space-y-6" </div>
> <h3 className="text-sm font-medium sm:text-base">Install the plugin</h3>
<FormField </header>
control={form.control} <p className="mb-3 text-[11px] text-muted-foreground sm:text-xs">
name="name" Grab the latest SurfSense plugin release. Once it's in the community
render={({ field }) => ( store, you'll also be able to install it from{" "}
<FormItem> <span className="font-medium">Settings Community plugins</span>{" "}
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel> inside Obsidian.
<FormControl> </p>
<Input <a
placeholder="My Obsidian Vault" href={PLUGIN_RELEASES_URL}
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40" target="_blank"
disabled={isSubmitting} rel="noopener noreferrer"
{...field} className="inline-flex"
/> >
</FormControl> <Button type="button" variant="outline" size="sm" className="gap-2">
<FormDescription className="text-[10px] sm:text-xs"> <Download className="size-3.5" />
A friendly name to identify this connector. Open plugin releases
</FormDescription> </Button>
<FormMessage /> </a>
</FormItem> </section>
)}
/>
<FormField {/* Step 2 — Copy API key */}
control={form.control} <section className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
name="vault_path" <header className="mb-3 flex items-center gap-2">
render={({ field }) => ( <div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
<FormItem> 2
<FormLabel className="text-xs sm:text-sm">Vault Path</FormLabel> </div>
<FormControl> <h3 className="text-sm font-medium sm:text-base">
<Input Copy your API key
placeholder="/path/to/your/obsidian/vault" </h3>
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40 font-mono" <KeyRound className="ml-auto size-4 text-muted-foreground" />
disabled={isSubmitting} </header>
{...field} <p className="mb-3 text-[11px] text-muted-foreground sm:text-xs">
/> Paste this into the plugin's <span className="font-medium">API token</span>{" "}
</FormControl> setting. The token expires after 24 hours; long-lived personal access
<FormDescription className="text-[10px] sm:text-xs"> tokens are coming in a future release.
The absolute path to your Obsidian vault on the server. This must be accessible </p>
from the SurfSense backend.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField {isLoading ? (
control={form.control} <div className="h-10 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
name="vault_name" ) : apiKey ? (
render={({ field }) => ( <div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<FormItem> <div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<FormLabel className="text-xs sm:text-sm">Vault Name</FormLabel> <p className="cursor-text select-all whitespace-nowrap font-mono text-[10px] text-muted-foreground">
<FormControl> {apiKey}
<Input </p>
placeholder="My Knowledge Base"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A display name for your vault. This will be used in search results.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="exclude_folders"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Exclude Folders</FormLabel>
<FormControl>
<Input
placeholder=".obsidian,.trash,templates"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Comma-separated list of folder names to exclude from indexing.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="include_attachments"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-slate-400/20 p-3">
<div className="space-y-0.5">
<FormLabel className="text-xs sm:text-sm">Include Attachments</FormLabel>
<FormDescription className="text-[10px] sm:text-xs">
Index attachment folders and embedded files (images, PDFs, etc.)
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-100">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div> </div>
</form> <Button
</Form> type="button"
</div> variant="ghost"
size="icon"
onClick={copyToClipboard}
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-center text-xs text-muted-foreground/60">
No API key available try refreshing the page.
</p>
)}
</section>
{/* Step 3 — Server URL */}
<section className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
<header className="mb-3 flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
3
</div>
<h3 className="text-sm font-medium sm:text-base">
Point the plugin at this server
</h3>
</header>
<p className="mb-3 text-[11px] text-muted-foreground sm:text-xs">
Paste this URL into the plugin's <span className="font-medium">Server URL</span>{" "}
setting. We auto-detect it from your current dashboard origin.
</p>
<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">
{BACKEND_URL}
</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>
</section>
{/* Step 4 — Pick search space */}
<section className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
<header className="mb-3 flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
4
</div>
<h3 className="text-sm font-medium sm:text-base">
Pick this search space
</h3>
<Settings2 className="ml-auto size-4 text-muted-foreground" />
</header>
<p className="text-[11px] text-muted-foreground sm:text-xs">
In the plugin's <span className="font-medium">Search space</span>{" "}
setting, choose the search space you want this vault to sync into.
The connector will appear here automatically once the plugin makes
its first sync.
</p>
</section>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.OBSIDIAN_CONNECTOR) && ( {getConnectorBenefits(EnumConnectorName.OBSIDIAN_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2"> <div className="space-y-2 rounded-xl border border-border bg-slate-400/5 px-3 py-4 sm:px-6 dark:bg-white/5">
<h4 className="text-xs sm:text-sm font-medium"> <h4 className="text-xs font-medium sm:text-sm">
What you get with Obsidian integration: What you get with Obsidian integration:
</h4> </h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1"> <ul className="list-disc space-y-1 pl-5 text-[10px] text-muted-foreground sm:text-xs">
{getConnectorBenefits(EnumConnectorName.OBSIDIAN_CONNECTOR)?.map((benefit) => ( {getConnectorBenefits(EnumConnectorName.OBSIDIAN_CONNECTOR)?.map(
<li key={benefit}>{benefit}</li> (benefit) => (
))} <li key={benefit}>{benefit}</li>
)
)}
</ul> </ul>
</div> </div>
)} )}

View file

@ -104,11 +104,11 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
"No manual indexing required - meetings are added automatically", "No manual indexing required - meetings are added automatically",
], ],
OBSIDIAN_CONNECTOR: [ OBSIDIAN_CONNECTOR: [
"Search through all your Obsidian notes and knowledge base", "Search through all of your Obsidian notes",
"Access note content with YAML frontmatter metadata preserved", "Realtime sync as you create, edit, rename, or delete notes",
"Wiki-style links ([[note]]) and #tags are indexed", "YAML frontmatter, [[wiki links]], and #tags are preserved and indexed",
"Connect your personal knowledge base directly to your search space", "Open any chat citation straight back in Obsidian via deep links",
"Incremental sync - only changed files are re-indexed", "Each device is identifiable, so you can revoke a vault from one machine",
"Full support for your vault's folder structure", "Full support for your vault's folder structure",
], ],
}; };

View file

@ -1,94 +1,58 @@
"use client"; "use client";
import type { FC } from "react"; import { AlertTriangle, Check, Copy, Download, Info } from "lucide-react";
import { useState } from "react"; import { type FC, useCallback, useMemo, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { useApiKey } from "@/hooks/use-api-key";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
import type { ConnectorConfigProps } from "../index"; import type { ConnectorConfigProps } from "../index";
export interface ObsidianConfigProps extends ConnectorConfigProps { export interface ObsidianConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void; onNameChange?: (name: string) => void;
} }
const PLUGIN_RELEASES_URL =
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
function formatTimestamp(value: unknown): string {
if (typeof value !== "string" || !value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleString();
}
/**
* Obsidian connector config view.
*
* Renders one of two 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.
*/
export const ObsidianConfig: FC<ObsidianConfigProps> = ({ export const ObsidianConfig: FC<ObsidianConfigProps> = ({
connector, connector,
onConfigChange,
onNameChange, onNameChange,
}) => { }) => {
const [vaultPath, setVaultPath] = useState<string>(
(connector.config?.vault_path as string) || ""
);
const [vaultName, setVaultName] = useState<string>(
(connector.config?.vault_name as string) || ""
);
const [excludeFolders, setExcludeFolders] = useState<string>(() => {
const folders = connector.config?.exclude_folders;
if (Array.isArray(folders)) {
return folders.join(", ");
}
return (folders as string) || ".obsidian, .trash";
});
const [includeAttachments, setIncludeAttachments] = useState<boolean>(
(connector.config?.include_attachments as boolean) || false
);
const [name, setName] = useState<string>(connector.name || ""); const [name, setName] = useState<string>(connector.name || "");
const config = (connector.config ?? {}) as Record<string, unknown>;
const handleVaultPathChange = (value: string) => { const isLegacy = config.legacy === true;
setVaultPath(value); const isPlugin = config.source === "plugin";
if (onConfigChange) {
onConfigChange({
...connector.config,
vault_path: value,
});
}
};
const handleVaultNameChange = (value: string) => {
setVaultName(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
vault_name: value,
});
}
};
const handleExcludeFoldersChange = (value: string) => {
setExcludeFolders(value);
const foldersArray = value
.split(",")
.map((f) => f.trim())
.filter(Boolean);
if (onConfigChange) {
onConfigChange({
...connector.config,
exclude_folders: foldersArray,
});
}
};
const handleIncludeAttachmentsChange = (value: boolean) => {
setIncludeAttachments(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
include_attachments: value,
});
}
};
const handleNameChange = (value: string) => { const handleNameChange = (value: string) => {
setName(value); setName(value);
if (onNameChange) { onNameChange?.(value);
onNameChange(value);
}
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Connector Name */} {/* Connector name (always editable) */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <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"> <div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label> <Label className="text-xs sm:text-sm">Connector Name</Label>
<Input <Input
@ -103,63 +67,207 @@ export const ObsidianConfig: FC<ObsidianConfigProps> = ({
</div> </div>
</div> </div>
{/* Configuration */} {isLegacy ? (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <LegacyBanner />
<div className="space-y-1 sm:space-y-2"> ) : isPlugin ? (
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2"> <PluginStats config={config} />
Vault Configuration ) : (
</h3> <UnknownConnectorState />
</div> )}
</div>
);
};
<div className="space-y-4"> const LegacyBanner: FC = () => {
<div className="space-y-2"> return (
<Label className="text-xs sm:text-sm">Vault Path</Label> <div className="space-y-4">
<Input <Alert className="border-amber-500/40 bg-amber-500/10">
value={vaultPath} <AlertTriangle className="size-4 shrink-0 text-amber-500" />
onChange={(e) => handleVaultPathChange(e.target.value)} <AlertTitle className="text-xs sm:text-sm">
placeholder="/path/to/your/obsidian/vault" This connector has been migrated
className="border-slate-400/20 focus-visible:border-slate-400/40 font-mono" </AlertTitle>
/> <AlertDescription className="text-[11px] sm:text-xs leading-relaxed">
<p className="text-[10px] sm:text-xs text-muted-foreground"> This Obsidian connector used the legacy server-path method, which has
The absolute path to your Obsidian vault on the server. been removed. To resume syncing, install the SurfSense Obsidian
</p> plugin and connect with this account. Your existing notes remain
</div> searchable. After the plugin re-indexes your vault, you can delete
this connector to remove older copies.
</AlertDescription>
</Alert>
<div className="space-y-2"> <a
<Label className="text-xs sm:text-sm">Vault Name</Label> href={PLUGIN_RELEASES_URL}
<Input target="_blank"
value={vaultName} rel="noopener noreferrer"
onChange={(e) => handleVaultNameChange(e.target.value)} className="inline-flex"
placeholder="My Knowledge Base" >
className="border-slate-400/20 focus-visible:border-slate-400/40" <Button type="button" variant="outline" size="sm" className="gap-2">
/> <Download className="size-3.5" />
<p className="text-[10px] sm:text-xs text-muted-foreground"> Install the plugin
A display name for your vault in search results. </Button>
</p> </a>
</div>
<div className="space-y-2"> <ApiKeyReminder />
<Label className="text-xs sm:text-sm">Exclude Folders</Label> </div>
<Input );
value={excludeFolders} };
onChange={(e) => handleExcludeFoldersChange(e.target.value)}
placeholder=".obsidian, .trash, templates"
className="border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Comma-separated list of folder names to exclude from indexing.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border border-slate-400/20 p-3"> const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
<div className="space-y-0.5"> const stats: { label: string; value: string }[] = useMemo(() => {
<Label className="text-xs sm:text-sm">Include Attachments</Label> const filesSynced = config.files_synced;
<p className="text-[10px] sm:text-xs text-muted-foreground"> return [
Index attachment folders and embedded files { label: "Vault", value: (config.vault_name as string) || "—" },
{
label: "Plugin version",
value: (config.plugin_version as string) || "—",
},
{
label: "Device",
value:
(config.device_label as string) ||
(config.device_id as string) ||
"—",
},
{
label: "Last sync",
value: formatTimestamp(config.last_sync_at),
},
{
label: "Files synced",
value:
typeof filesSynced === "number" ? filesSynced.toLocaleString() : "—",
},
];
}, [config]);
return (
<div className="space-y-4">
<Alert className="border-emerald-500/30 bg-emerald-500/10">
<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.
</AlertDescription>
</Alert>
<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">Vault status</h3>
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{stats.map((stat) => (
<div
key={stat.label}
className="rounded-lg border border-slate-400/20 bg-background/50 p-3"
>
<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>
</div>
))}
</dl>
</div>
</div>
);
};
const UnknownConnectorState: FC = () => (
<Alert>
<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.
</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> </p>
</div> </div>
<Switch checked={includeAttachments} onCheckedChange={handleIncludeAttachmentsChange} /> <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> </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> </div>
</div> </div>

View file

@ -180,7 +180,7 @@ export const OTHER_CONNECTORS = [
{ {
id: "obsidian-connector", id: "obsidian-connector",
title: "Obsidian", title: "Obsidian",
description: "Index your Obsidian vault (Local folder scan on Desktop)", description: "Sync your Obsidian vault on desktop or mobile via the SurfSense plugin",
connectorType: EnumConnectorName.OBSIDIAN_CONNECTOR, connectorType: EnumConnectorName.OBSIDIAN_CONNECTOR,
}, },
] as const; ] as const;