mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
380 lines
10 KiB
TypeScript
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"}`,
|
|
);
|
|
}
|
|
}
|