SurfSense/surfsense_obsidian/src/settings.ts

380 lines
10 KiB
TypeScript

import {
type App,
Notice,
Platform,
PluginSettingTab,
Setting,
setIcon,
} from "obsidian";
import { AuthError } from "./api-client";
import { normalizeFolder, parseExcludePatterns } from "./excludes";
import { FolderSuggestModal } from "./folder-suggest-modal";
import type SurfSensePlugin from "./main";
import type { SearchSpace } from "./types";
/** Plugin settings tab. */
export class SurfSenseSettingTab extends PluginSettingTab {
private readonly plugin: SurfSensePlugin;
private searchSpaces: SearchSpace[] = [];
private loadingSpaces = false;
constructor(app: App, plugin: SurfSensePlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
const settings = this.plugin.settings;
this.renderConnectionHeading(containerEl);
new Setting(containerEl)
.setName("Server URL")
.setDesc(
"https://surfsense.com for SurfSense Cloud, or your self-hosted URL.",
)
.addText((text) =>
text
.setPlaceholder("https://surfsense.com")
.setValue(settings.serverUrl)
.onChange(async (value) => {
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;
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) => {
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;
await this.plugin.saveSettings();
});
})
.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);
}
}),
);
new Setting(containerEl)
.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;
await this.plugin.saveSettings();
if (this.plugin.settings.searchSpaceId !== null) {
try {
await this.plugin.engine.ensureConnected();
await this.plugin.engine.maybeReconcile(true);
new Notice("Surfsense: vault connected.");
this.display();
} 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)
.setName("Sync interval")
.setDesc(
"How often to check for changes made outside Obsidian. Set to off to only sync manually.",
)
.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();
});
});
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;
},
);
new Setting(containerEl)
.setName("Advanced exclude patterns")
.setDesc(
"Glob fallback for power users. One pattern per line, supports * and **. Lines starting with # are comments. Applied on top of the folder lists above.",
)
.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(
"Also sync non-Markdown files such as images and PDFs.",
)
.addToggle((toggle) =>
toggle
.setValue(settings.includeAttachments)
.onChange(async (value) => {
this.plugin.settings.includeAttachments = value;
await this.plugin.saveSettings();
}),
);
if (Platform.isMobileApp) {
new Setting(containerEl)
.setName("Sync only on WiFi")
.setDesc(
"Pause automatic syncing on cellular. Note: only Android can detect network type — on iOS this toggle has no effect.",
)
.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);
}
}),
);
new Setting(containerEl)
.addButton((btn) =>
btn
.setButtonText("View sync status")
.setCta()
.onClick(() => this.plugin.openStatusModal()),
)
.addButton((btn) =>
btn.setButtonText("Open releases").onClick(() => {
window.open(
"https://github.com/MODSetter/SurfSense/releases?q=obsidian",
"_blank",
);
}),
);
}
private renderConnectionHeading(containerEl: HTMLElement): void {
const heading = new Setting(containerEl).setName("Connection").setHeading();
heading.nameEl.addClass("surfsense-connection-heading");
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") {
return { icon: "lock", label: "Token invalid or expired", tone: "err" };
}
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" };
}
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;
}
}
private renderFolderList(
containerEl: HTMLElement,
title: string,
desc: string,
current: string[],
write: (next: string[]) => void,
): void {
const setting = new Setting(containerEl).setName(title).setDesc(desc);
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
.setButtonText("Add folder")
.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));
}),
);
}
}
private handleApiError(err: unknown): void {
if (err instanceof AuthError) {
new Notice(`SurfSense: ${err.message}`);
return;
}
new Notice(
`SurfSense: request failed — ${(err as Error).message ?? "unknown error"}`,
);
}
}