mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: update Obsidian connector to support plugin-based syncing and improve UI components
This commit is contained in:
parent
e8fc1069bc
commit
ee2fb79e75
4 changed files with 415 additions and 409 deletions
|
|
@ -1,314 +1,212 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Check, Copy, Download, Info, KeyRound, Settings2 } from "lucide-react";
|
||||
import { type FC, useCallback, useRef, useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { useApiKey } from "@/hooks/use-api-key";
|
||||
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
|
||||
const obsidianConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
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(),
|
||||
});
|
||||
const PLUGIN_RELEASES_URL =
|
||||
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
|
||||
|
||||
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);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(true);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("60");
|
||||
const form = useForm<ObsidianConnectorFormValues>({
|
||||
resolver: zodResolver(obsidianConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Obsidian Vault",
|
||||
vault_path: "",
|
||||
vault_name: "",
|
||||
exclude_folders: ".obsidian,.trash",
|
||||
include_attachments: false,
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Obsidian connect form for the plugin-only architecture.
|
||||
*
|
||||
* The legacy `vault_path` form was removed because it only worked on
|
||||
* self-hosted with a server-side bind mount and broke for everyone else.
|
||||
* The plugin pushes data over HTTPS so this UI is purely instructional —
|
||||
* there is no backend create call here. The connector row is created
|
||||
* server-side the first time the plugin calls `POST /obsidian/connect`.
|
||||
*
|
||||
* The footer "Connect" button in `ConnectorConnectView` triggers this
|
||||
* form's submit; we just close the dialog (`onBack()`) since there's
|
||||
* 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) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
const copyServerUrl = useCallback(async () => {
|
||||
const ok = await copyToClipboardUtil(BACKEND_URL);
|
||||
if (!ok) return;
|
||||
setCopiedUrl(true);
|
||||
if (urlCopyTimerRef.current) clearTimeout(urlCopyTimerRef.current);
|
||||
urlCopyTimerRef.current = setTimeout(() => setCopiedUrl(false), 2000);
|
||||
}, []);
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
// Parse exclude_folders into an array
|
||||
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;
|
||||
}
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<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">
|
||||
This connector requires direct file system access and only works with self-hosted
|
||||
SurfSense installations.
|
||||
SurfSense now syncs Obsidian via an official plugin that runs inside
|
||||
Obsidian itself. Works on desktop and mobile, in cloud and self-hosted
|
||||
deployments — no server-side vault mounts required.
|
||||
</AlertDescription>
|
||||
</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">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="obsidian-connect-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4 sm:space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Obsidian Vault"
|
||||
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 friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Step 1 — Install plugin */}
|
||||
<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">
|
||||
1
|
||||
</div>
|
||||
<h3 className="text-sm font-medium sm:text-base">Install the plugin</h3>
|
||||
</header>
|
||||
<p className="mb-3 text-[11px] text-muted-foreground sm:text-xs">
|
||||
Grab the latest SurfSense plugin release. Once it's in the community
|
||||
store, you'll also be able to install it from{" "}
|
||||
<span className="font-medium">Settings → Community plugins</span>{" "}
|
||||
inside Obsidian.
|
||||
</p>
|
||||
<a
|
||||
href={PLUGIN_RELEASES_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex"
|
||||
>
|
||||
<Button type="button" variant="outline" size="sm" className="gap-2">
|
||||
<Download className="size-3.5" />
|
||||
Open plugin releases
|
||||
</Button>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vault_path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Vault Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="/path/to/your/obsidian/vault"
|
||||
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">
|
||||
The absolute path to your Obsidian vault on the server. This must be accessible
|
||||
from the SurfSense backend.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Step 2 — Copy API key */}
|
||||
<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">
|
||||
2
|
||||
</div>
|
||||
<h3 className="text-sm font-medium sm:text-base">
|
||||
Copy your API key
|
||||
</h3>
|
||||
<KeyRound className="ml-auto size-4 text-muted-foreground" />
|
||||
</header>
|
||||
<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>{" "}
|
||||
setting. The token expires after 24 hours; long-lived personal access
|
||||
tokens are coming in a future release.
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vault_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Vault Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
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>
|
||||
{isLoading ? (
|
||||
<div className="h-10 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
|
||||
) : apiKey ? (
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
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) && (
|
||||
<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">
|
||||
<h4 className="text-xs sm:text-sm font-medium">
|
||||
<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 font-medium sm:text-sm">
|
||||
What you get with Obsidian integration:
|
||||
</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.OBSIDIAN_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
<ul className="list-disc space-y-1 pl-5 text-[10px] text-muted-foreground sm:text-xs">
|
||||
{getConnectorBenefits(EnumConnectorName.OBSIDIAN_CONNECTOR)?.map(
|
||||
(benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -104,11 +104,11 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
|
|||
"No manual indexing required - meetings are added automatically",
|
||||
],
|
||||
OBSIDIAN_CONNECTOR: [
|
||||
"Search through all your Obsidian notes and knowledge base",
|
||||
"Access note content with YAML frontmatter metadata preserved",
|
||||
"Wiki-style links ([[note]]) and #tags are indexed",
|
||||
"Connect your personal knowledge base directly to your search space",
|
||||
"Incremental sync - only changed files are re-indexed",
|
||||
"Search through all of your Obsidian notes",
|
||||
"Realtime sync as you create, edit, rename, or delete notes",
|
||||
"YAML frontmatter, [[wiki links]], and #tags are preserved and indexed",
|
||||
"Open any chat citation straight back in Obsidian via deep links",
|
||||
"Each device is identifiable, so you can revoke a vault from one machine",
|
||||
"Full support for your vault's folder structure",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,94 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Check, Copy, Download, Info } from "lucide-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 { 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";
|
||||
|
||||
export interface ObsidianConfigProps extends ConnectorConfigProps {
|
||||
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> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
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 handleVaultPathChange = (value: string) => {
|
||||
setVaultPath(value);
|
||||
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 config = (connector.config ?? {}) as Record<string, unknown>;
|
||||
const isLegacy = config.legacy === true;
|
||||
const isPlugin = config.source === "plugin";
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
onNameChange?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<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">
|
||||
{/* 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
|
||||
|
|
@ -103,63 +67,207 @@ export const ObsidianConfig: FC<ObsidianConfigProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<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="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
|
||||
Vault Configuration
|
||||
</h3>
|
||||
</div>
|
||||
{isLegacy ? (
|
||||
<LegacyBanner />
|
||||
) : isPlugin ? (
|
||||
<PluginStats config={config} />
|
||||
) : (
|
||||
<UnknownConnectorState />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Vault Path</Label>
|
||||
<Input
|
||||
value={vaultPath}
|
||||
onChange={(e) => handleVaultPathChange(e.target.value)}
|
||||
placeholder="/path/to/your/obsidian/vault"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The absolute path to your Obsidian vault on the server.
|
||||
</p>
|
||||
</div>
|
||||
const LegacyBanner: FC = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
</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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Vault Name</Label>
|
||||
<Input
|
||||
value={vaultName}
|
||||
onChange={(e) => handleVaultNameChange(e.target.value)}
|
||||
placeholder="My Knowledge Base"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A display name for your vault in search results.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={PLUGIN_RELEASES_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex"
|
||||
>
|
||||
<Button type="button" variant="outline" size="sm" className="gap-2">
|
||||
<Download className="size-3.5" />
|
||||
Install the plugin
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Exclude Folders</Label>
|
||||
<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>
|
||||
<ApiKeyReminder />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-400/20 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs sm:text-sm">Include Attachments</Label>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Index attachment folders and embedded files
|
||||
const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
|
||||
const stats: { label: string; value: string }[] = useMemo(() => {
|
||||
const filesSynced = config.files_synced;
|
||||
return [
|
||||
{ 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>
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ export const OTHER_CONNECTORS = [
|
|||
{
|
||||
id: "obsidian-connector",
|
||||
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,
|
||||
},
|
||||
] as const;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue