2026-04-20 04:04:19 +05:30
import {
type App ,
Notice ,
2026-04-20 23:48:51 +05:30
Platform ,
2026-04-20 04:04:19 +05:30
PluginSettingTab ,
Setting ,
2026-04-22 06:13:01 +05:30
setIcon ,
2026-04-20 04:04:19 +05:30
} from "obsidian" ;
import { AuthError } from "./api-client" ;
2026-04-22 22:01:14 +05:30
import { AttachmentsConfirmModal } from "./attachments-confirm-modal" ;
2026-04-20 23:13:49 +05:30
import { normalizeFolder , parseExcludePatterns } from "./excludes" ;
import { FolderSuggestModal } from "./folder-suggest-modal" ;
2026-04-20 04:04:19 +05:30
import type SurfSensePlugin from "./main" ;
import type { SearchSpace } from "./types" ;
2026-04-19 23:48:18 +05:30
2026-04-20 18:19:30 +05:30
/** Plugin settings tab. */
2026-04-19 23:48:18 +05:30
2026-04-20 04:04:19 +05:30
export class SurfSenseSettingTab extends PluginSettingTab {
private readonly plugin : SurfSensePlugin ;
private searchSpaces : SearchSpace [ ] = [ ] ;
private loadingSpaces = false ;
2026-04-19 23:48:18 +05:30
2026-04-20 04:04:19 +05:30
constructor ( app : App , plugin : SurfSensePlugin ) {
2026-04-19 23:48:18 +05:30
super ( app , plugin ) ;
this . plugin = plugin ;
}
display ( ) : void {
2026-04-20 04:04:19 +05:30
const { containerEl } = this ;
2026-04-19 23:48:18 +05:30
containerEl . empty ( ) ;
2026-04-20 04:04:19 +05:30
const settings = this . plugin . settings ;
2026-04-22 06:13:01 +05:30
this . renderConnectionHeading ( containerEl ) ;
2026-04-20 04:04:19 +05:30
new Setting ( containerEl )
. setName ( "Server URL" )
. setDesc (
2026-04-21 10:53:55 +05:30
"https://surfsense.com for SurfSense Cloud, or your self-hosted URL." ,
2026-04-20 04:04:19 +05:30
)
. addText ( ( text ) = >
text
2026-04-21 10:53:55 +05:30
. setPlaceholder ( "https://surfsense.com" )
2026-04-20 04:04:19 +05:30
. setValue ( settings . serverUrl )
. onChange ( async ( value ) = > {
2026-04-21 11:08:36 +05:30
const next = value . trim ( ) ;
const previous = this . plugin . settings . serverUrl ;
if ( previous !== "" && next !== previous ) {
this . plugin . settings . searchSpaceId = null ;
this . plugin . settings . connectorId = null ;
}
this . plugin . settings . serverUrl = next ;
2026-04-20 04:04:19 +05:30
await this . plugin . saveSettings ( ) ;
} ) ,
) ;
new Setting ( containerEl )
. setName ( "API token" )
. setDesc (
"Paste your Surfsense API token (expires after 24 hours; re-paste when you see an auth error)." ,
)
. addText ( ( text ) = > {
text . inputEl . type = "password" ;
text . inputEl . autocomplete = "off" ;
text . inputEl . spellcheck = false ;
text
. setPlaceholder ( "Paste token" )
. setValue ( settings . apiToken )
. onChange ( async ( value ) = > {
2026-04-21 11:08:36 +05:30
const next = value . trim ( ) ;
const previous = this . plugin . settings . apiToken ;
if ( previous !== "" && next !== previous ) {
this . plugin . settings . searchSpaceId = null ;
this . plugin . settings . connectorId = null ;
}
this . plugin . settings . apiToken = next ;
2026-04-20 04:04:19 +05:30
await this . plugin . saveSettings ( ) ;
2026-04-25 01:07:02 +05:30
this . plugin . api . resetAuthBlock ( ) ;
2026-04-20 04:04:19 +05:30
} ) ;
} )
. addButton ( ( btn ) = >
btn
. setButtonText ( "Verify" )
. setCta ( )
. onClick ( async ( ) = > {
btn . setDisabled ( true ) ;
try {
await this . plugin . api . verifyToken ( ) ;
new Notice ( "Surfsense: token verified." ) ;
await this . refreshSearchSpaces ( ) ;
this . display ( ) ;
} catch ( err ) {
this . handleApiError ( err ) ;
} finally {
btn . setDisabled ( false ) ;
}
} ) ,
) ;
2026-04-19 23:48:18 +05:30
new Setting ( containerEl )
2026-04-20 04:04:19 +05:30
. setName ( "Search space" )
. setDesc (
"Which Surfsense search space this vault syncs into. Reload after changing your token." ,
)
. addDropdown ( ( drop ) = > {
drop . addOption ( "" , this . loadingSpaces ? "Loading…" : "Select a search space" ) ;
for ( const space of this . searchSpaces ) {
drop . addOption ( String ( space . id ) , space . name ) ;
}
if ( settings . searchSpaceId !== null ) {
drop . setValue ( String ( settings . searchSpaceId ) ) ;
}
drop . onChange ( async ( value ) = > {
this . plugin . settings . searchSpaceId = value ? Number ( value ) : null ;
this . plugin . settings . connectorId = null ;
2026-04-19 23:48:18 +05:30
await this . plugin . saveSettings ( ) ;
2026-04-20 04:04:19 +05:30
if ( this . plugin . settings . searchSpaceId !== null ) {
try {
await this . plugin . engine . ensureConnected ( ) ;
2026-04-22 06:26:49 +05:30
await this . plugin . engine . maybeReconcile ( true ) ;
2026-04-20 04:04:19 +05:30
new Notice ( "Surfsense: vault connected." ) ;
2026-04-22 06:16:38 +05:30
this . display ( ) ;
2026-04-20 04:04:19 +05:30
} catch ( err ) {
this . handleApiError ( err ) ;
}
}
} ) ;
} )
. addExtraButton ( ( btn ) = >
btn
. setIcon ( "refresh-ccw" )
. setTooltip ( "Reload search spaces" )
. onClick ( async ( ) = > {
await this . refreshSearchSpaces ( ) ;
this . display ( ) ;
} ) ,
) ;
new Setting ( containerEl ) . setName ( "Vault" ) . setHeading ( ) ;
new Setting ( containerEl )
2026-04-20 23:48:51 +05:30
. setName ( "Sync interval" )
. setDesc (
2026-04-25 00:23:17 +05:30
"How often to check for changes made outside Obsidian." ,
2026-04-20 23:48:51 +05:30
)
. addDropdown ( ( drop ) = > {
const options : Array < [ number , string ] > = [
[ 0 , "Off" ] ,
[ 5 , "5 minutes" ] ,
[ 10 , "10 minutes" ] ,
[ 15 , "15 minutes" ] ,
[ 30 , "30 minutes" ] ,
[ 60 , "60 minutes" ] ,
[ 120 , "2 hours" ] ,
[ 360 , "6 hours" ] ,
[ 720 , "12 hours" ] ,
[ 1440 , "24 hours" ] ,
] ;
for ( const [ value , label ] of options ) {
drop . addOption ( String ( value ) , label ) ;
}
drop . setValue ( String ( settings . syncIntervalMinutes ) ) ;
drop . onChange ( async ( value ) = > {
this . plugin . settings . syncIntervalMinutes = Number ( value ) ;
await this . plugin . saveSettings ( ) ;
this . plugin . restartReconcileTimer ( ) ;
} ) ;
} ) ;
2026-04-20 04:04:19 +05:30
2026-04-20 23:13:49 +05:30
this . renderFolderList (
containerEl ,
"Include folders" ,
"Folders to sync (leave empty to sync entire vault)." ,
settings . includeFolders ,
( next ) = > {
this . plugin . settings . includeFolders = next ;
} ,
) ;
this . renderFolderList (
containerEl ,
"Exclude folders" ,
"Folders to exclude from sync (takes precedence over includes)." ,
settings . excludeFolders ,
( next ) = > {
this . plugin . settings . excludeFolders = next ;
} ,
) ;
2026-04-20 04:04:19 +05:30
new Setting ( containerEl )
2026-04-20 23:13:49 +05:30
. setName ( "Advanced exclude patterns" )
2026-04-20 04:04:19 +05:30
. setDesc (
2026-04-20 23:13:49 +05:30
"Glob fallback for power users. One pattern per line, supports * and **. Lines starting with # are comments. Applied on top of the folder lists above." ,
2026-04-20 04:04:19 +05:30
)
. addTextArea ( ( area ) = > {
area . inputEl . rows = 4 ;
area
. setPlaceholder ( ".trash\n_attachments\ntemplates/**" )
. setValue ( settings . excludePatterns . join ( "\n" ) )
. onChange ( async ( value ) = > {
this . plugin . settings . excludePatterns = parseExcludePatterns ( value ) ;
await this . plugin . saveSettings ( ) ;
} ) ;
} ) ;
new Setting ( containerEl )
. setName ( "Include attachments" )
. setDesc (
2026-04-25 00:23:17 +05:30
"Also sync non-Markdown files such as images and PDFs. Other file types are skipped." ,
2026-04-20 04:04:19 +05:30
)
. addToggle ( ( toggle ) = >
toggle
. setValue ( settings . includeAttachments )
. onChange ( async ( value ) = > {
2026-04-22 22:01:14 +05:30
const isEnabling =
value && ! this . plugin . settings . includeAttachments ;
if ( isEnabling ) {
const confirmed = await new AttachmentsConfirmModal (
this . app ,
) . waitForConfirmation ( ) ;
if ( ! confirmed ) {
this . display ( ) ;
return ;
}
}
2026-04-20 04:04:19 +05:30
this . plugin . settings . includeAttachments = value ;
await this . plugin . saveSettings ( ) ;
} ) ,
) ;
2026-04-20 23:48:51 +05:30
if ( Platform . isMobileApp ) {
new Setting ( containerEl )
. setName ( "Sync only on WiFi" )
. setDesc (
2026-04-25 00:23:17 +05:30
"Pause automatic syncing on cellular. Note: only Android can detect network type, on iOS this toggle has no effect." ,
2026-04-20 23:48:51 +05:30
)
. addToggle ( ( toggle ) = >
toggle
. setValue ( settings . wifiOnly )
. onChange ( async ( value ) = > {
this . plugin . settings . wifiOnly = value ;
await this . plugin . saveSettings ( ) ;
} ) ,
) ;
}
new Setting ( containerEl )
. setName ( "Force sync" )
. setDesc ( "Manually re-index the entire vault now." )
. addButton ( ( btn ) = >
btn . setButtonText ( "Update" ) . onClick ( async ( ) = > {
btn . setDisabled ( true ) ;
try {
await this . plugin . engine . maybeReconcile ( true ) ;
new Notice ( "Surfsense: re-sync requested." ) ;
} catch ( err ) {
this . handleApiError ( err ) ;
} finally {
btn . setDisabled ( false ) ;
}
} ) ,
) ;
2026-04-20 04:04:19 +05:30
new Setting ( containerEl )
. addButton ( ( btn ) = >
btn
2026-04-20 23:13:49 +05:30
. setButtonText ( "View sync status" )
. setCta ( )
. onClick ( ( ) = > this . plugin . openStatusModal ( ) ) ,
2026-04-20 04:04:19 +05:30
)
. addButton ( ( btn ) = >
btn . setButtonText ( "Open releases" ) . onClick ( ( ) = > {
window . open (
"https://github.com/MODSetter/SurfSense/releases?q=obsidian" ,
"_blank" ,
) ;
} ) ,
) ;
}
2026-04-22 06:13:01 +05:30
private renderConnectionHeading ( containerEl : HTMLElement ) : void {
const heading = new Setting ( containerEl ) . setName ( "Connection" ) . setHeading ( ) ;
2026-04-22 06:26:49 +05:30
heading . nameEl . addClass ( "surfsense-connection-heading" ) ;
2026-04-22 06:13:01 +05:30
const indicator = heading . nameEl . createSpan ( {
cls : "surfsense-connection-indicator" ,
} ) ;
const visual = this . getConnectionVisual ( ) ;
indicator . addClass ( ` surfsense-connection-indicator-- ${ visual . tone } ` ) ;
setIcon ( indicator , visual . icon ) ;
indicator . setAttr ( "aria-label" , visual . label ) ;
indicator . setAttr ( "title" , visual . label ) ;
}
private getConnectionVisual ( ) : {
icon : string ;
label : string ;
tone : "ok" | "syncing" | "warn" | "err" | "muted" ;
} {
const settings = this . plugin . settings ;
const kind = this . plugin . lastStatus . kind ;
if ( kind === "auth-error" ) {
2026-04-25 01:07:02 +05:30
return { icon : "lock" , label : "API token invalid or expired" , tone : "err" } ;
2026-04-22 06:13:01 +05:30
}
if ( kind === "error" ) {
return { icon : "alert-circle" , label : "Connection error" , tone : "err" } ;
}
if ( kind === "offline" ) {
return { icon : "wifi-off" , label : "Server unreachable" , tone : "warn" } ;
}
if ( ! settings . apiToken ) {
return { icon : "circle" , label : "Missing API token" , tone : "muted" } ;
}
if ( ! settings . searchSpaceId ) {
return { icon : "circle" , label : "Pick a search space" , tone : "muted" } ;
}
if ( ! settings . connectorId ) {
return { icon : "circle" , label : "Not connected yet" , tone : "muted" } ;
}
if ( kind === "syncing" || kind === "queued" ) {
return { icon : "refresh-ccw" , label : "Connected and syncing" , tone : "syncing" } ;
}
return { icon : "check-circle" , label : "Connected" , tone : "ok" } ;
}
2026-04-20 04:04:19 +05:30
private async refreshSearchSpaces ( ) : Promise < void > {
this . loadingSpaces = true ;
try {
this . searchSpaces = await this . plugin . api . listSearchSpaces ( ) ;
} catch ( err ) {
this . handleApiError ( err ) ;
this . searchSpaces = [ ] ;
} finally {
this . loadingSpaces = false ;
}
}
2026-04-20 23:13:49 +05:30
private renderFolderList (
containerEl : HTMLElement ,
title : string ,
desc : string ,
current : string [ ] ,
write : ( next : string [ ] ) = > void ,
) : void {
const setting = new Setting ( containerEl ) . setName ( title ) . setDesc ( desc ) ;
2026-04-20 04:04:19 +05:30
2026-04-20 23:13:49 +05:30
const persist = async ( next : string [ ] ) : Promise < void > = > {
const dedup = Array . from ( new Set ( next . map ( normalizeFolder ) ) ) ;
write ( dedup ) ;
await this . plugin . saveSettings ( ) ;
this . display ( ) ;
} ;
setting . addButton ( ( btn ) = >
btn
2026-04-20 23:48:51 +05:30
. setButtonText ( "Add folder" )
2026-04-20 23:13:49 +05:30
. setCta ( )
. onClick ( ( ) = > {
new FolderSuggestModal (
this . app ,
( picked ) = > {
void persist ( [ . . . current , picked ] ) ;
} ,
current ,
) . open ( ) ;
} ) ,
) ;
for ( const folder of current ) {
new Setting ( containerEl ) . setName ( folder || "/" ) . addExtraButton ( ( btn ) = >
btn
. setIcon ( "cross" )
. setTooltip ( "Remove" )
. onClick ( ( ) = > {
void persist ( current . filter ( ( f ) = > f !== folder ) ) ;
} ) ,
) ;
2026-04-20 04:04:19 +05:30
}
}
private handleApiError ( err : unknown ) : void {
2026-04-25 01:07:02 +05:30
if ( err instanceof AuthError ) return ;
2026-04-20 04:04:19 +05:30
new Notice (
` SurfSense: request failed — ${ ( err as Error ) . message ? ? "unknown error" } ` ,
) ;
2026-04-19 23:48:18 +05:30
}
}