feat: enhance Obsidian plugin with folder management features, including inclusion/exclusion settings and a status modal for real-time updates

This commit is contained in:
Anish Sarkar 2026-04-20 23:13:49 +05:30
parent 2251e464c7
commit 28d3c628f1
11 changed files with 267 additions and 154 deletions

View file

@ -1,8 +1,8 @@
"""Obsidian plugin ingestion routes (``/api/v1/obsidian/*``).
Wire surface for the ``surfsense_obsidian/`` plugin. API stability is the
``/api/v1/`` prefix plus the additive ``capabilities`` array on /health;
no plugin-version gate.
Wire surface for the ``surfsense_obsidian/`` plugin. Versioning anchor is
the ``/api/v1/`` URL prefix; additive feature detection rides the
``capabilities`` array on /health and /connect.
"""
from __future__ import annotations
@ -46,9 +46,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/obsidian", tags=["obsidian-plugin"])
# Bumped only on non-additive wire changes; additive ones ride extra='ignore'.
OBSIDIAN_API_VERSION = "1"
# Plugins feature-gate on these. Add entries, never rename or remove.
OBSIDIAN_CAPABILITIES: list[str] = ["sync", "rename", "delete", "manifest", "stats"]
@ -59,10 +56,7 @@ OBSIDIAN_CAPABILITIES: list[str] = ["sync", "rename", "delete", "manifest", "sta
def _build_handshake() -> dict[str, object]:
return {
"api_version": OBSIDIAN_API_VERSION,
"capabilities": list(OBSIDIAN_CAPABILITIES),
}
return {"capabilities": list(OBSIDIAN_CAPABILITIES)}
async def _resolve_vault_connector(

View file

@ -92,13 +92,11 @@ class ConnectResponse(_PluginBase):
connector_id: int
vault_id: str
search_space_id: int
api_version: str
capabilities: list[str]
class HealthResponse(_PluginBase):
"""API contract handshake. ``capabilities`` is additive-only string list."""
api_version: str
capabilities: list[str]
server_time_utc: datetime

View file

@ -64,3 +64,31 @@ export function parseExcludePatterns(raw: string): string[] {
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"));
}
/** Normalize a folder path: strip leading/trailing slashes; "" or "/" means vault root. */
export function normalizeFolder(folder: string): string {
return folder.replace(/^\/+|\/+$/g, "");
}
/** True if `path` lives inside `folder` (or `folder` is the vault root). */
export function isInFolder(path: string, folder: string): boolean {
const f = normalizeFolder(folder);
if (f === "") return true;
return path === f || path.startsWith(`${f}/`);
}
/** Exclude wins over include. Empty includeFolders means "include everything". */
export function isFolderFiltered(
path: string,
includeFolders: string[],
excludeFolders: string[],
): boolean {
for (const f of excludeFolders) {
if (isInFolder(path, f)) return true;
}
if (includeFolders.length === 0) return false;
for (const f of includeFolders) {
if (isInFolder(path, f)) return false;
}
return true;
}

View file

@ -0,0 +1,32 @@
import { type App, FuzzySuggestModal, type TFolder } from "obsidian";
/** Folder picker built on Obsidian's stock {@link FuzzySuggestModal}. */
export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
private readonly onPick: (path: string) => void;
private readonly excluded: Set<string>;
constructor(app: App, onPick: (path: string) => void, excluded: string[] = []) {
super(app);
this.onPick = onPick;
this.excluded = new Set(excluded.map((p) => p.replace(/^\/+|\/+$/g, "")));
this.setPlaceholder("Type to filter folders…");
}
getItems(): TFolder[] {
return this.app.vault
.getAllFolders(true)
.filter((f) => !this.excluded.has(this.toPath(f)));
}
getItemText(folder: TFolder): string {
return this.toPath(folder) || "/";
}
onChooseItem(folder: TFolder): void {
this.onPick(this.toPath(folder));
}
private toPath(folder: TFolder): string {
return folder.isRoot() ? "" : folder.path;
}
}

View file

@ -3,6 +3,7 @@ import { SurfSenseApiClient } from "./api-client";
import { PersistentQueue } from "./queue";
import { SurfSenseSettingTab } from "./settings";
import { StatusBar } from "./status-bar";
import { StatusModal } from "./status-modal";
import { SyncEngine } from "./sync-engine";
import {
DEFAULT_SETTINGS,
@ -20,8 +21,8 @@ export default class SurfSensePlugin extends Plugin {
private statusBar: StatusBar | null = null;
lastStatus: StatusState = { kind: "idle", queueDepth: 0 };
serverCapabilities: string[] = [];
serverApiVersion: string | null = null;
private settingTab: SurfSenseSettingTab | null = null;
private statusListeners = new Set<() => void>();
async onload() {
await this.loadSettings();
@ -48,17 +49,16 @@ export default class SurfSensePlugin extends Plugin {
saveSettings: async (mut) => {
mut(this.settings);
await this.saveSettings();
this.settingTab?.renderStatus();
this.notifyStatusChange();
},
setStatus: (s) => {
this.lastStatus = s;
this.statusBar?.update(s);
this.settingTab?.renderStatus();
this.notifyStatusChange();
},
onCapabilities: (caps, apiVersion) => {
onCapabilities: (caps) => {
this.serverCapabilities = [...caps];
this.serverApiVersion = apiVersion;
this.settingTab?.renderStatus();
this.notifyStatusChange();
},
});
@ -71,7 +71,7 @@ export default class SurfSensePlugin extends Plugin {
this.addSettingTab(this.settingTab);
const statusHost = this.addStatusBarItem();
this.statusBar = new StatusBar(statusHost);
this.statusBar = new StatusBar(statusHost, () => this.openStatusModal());
this.statusBar.update(this.lastStatus);
this.registerEvent(
@ -120,6 +120,12 @@ export default class SurfSensePlugin extends Plugin {
},
});
this.addCommand({
id: "open-status",
name: "Open sync status",
callback: () => this.openStatusModal(),
});
this.addCommand({
id: "open-settings",
name: "Open settings",
@ -150,6 +156,22 @@ export default class SurfSensePlugin extends Plugin {
return this.queue?.size ?? 0;
}
openStatusModal(): void {
new StatusModal(this.app, this).open();
}
onStatusChange(listener: () => void): void {
this.statusListeners.add(listener);
}
offStatusChange(listener: () => void): void {
this.statusListeners.delete(listener);
}
private notifyStatusChange(): void {
for (const fn of this.statusListeners) fn();
}
async loadSettings() {
const data = (await this.loadData()) as Partial<SurfsensePluginSettings> | null;
this.settings = {
@ -157,6 +179,8 @@ export default class SurfSensePlugin extends Plugin {
...(data ?? {}),
queue: (data?.queue ?? []).map((i: QueueItem) => ({ ...i })),
tombstones: { ...(data?.tombstones ?? {}) },
includeFolders: [...(data?.includeFolders ?? [])],
excludeFolders: [...(data?.excludeFolders ?? [])],
excludePatterns: data?.excludePatterns?.length
? [...data.excludePatterns]
: [...DEFAULT_SETTINGS.excludePatterns],
@ -172,9 +196,6 @@ export default class SurfSensePlugin extends Plugin {
if (!this.settings.vaultId) {
this.settings.vaultId = generateUuid();
}
if (!this.settings.vaultName) {
this.settings.vaultName = this.app.vault.getName();
}
}
}

View file

@ -5,7 +5,8 @@ import {
Setting,
} from "obsidian";
import { AuthError } from "./api-client";
import { parseExcludePatterns } from "./excludes";
import { normalizeFolder, parseExcludePatterns } from "./excludes";
import { FolderSuggestModal } from "./folder-suggest-modal";
import type SurfSensePlugin from "./main";
import type { SearchSpace } from "./types";
@ -15,7 +16,6 @@ export class SurfSenseSettingTab extends PluginSettingTab {
private readonly plugin: SurfSensePlugin;
private searchSpaces: SearchSpace[] = [];
private loadingSpaces = false;
private statusEl: HTMLElement | null = null;
constructor(app: App, plugin: SurfSensePlugin) {
super(app, plugin);
@ -25,7 +25,6 @@ export class SurfSenseSettingTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.addClass("surfsense-settings");
const settings = this.plugin.settings;
@ -107,7 +106,6 @@ export class SurfSenseSettingTab extends PluginSettingTab {
this.handleApiError(err);
}
}
this.renderStatus();
});
})
.addExtraButton((btn) =>
@ -122,20 +120,6 @@ export class SurfSenseSettingTab extends PluginSettingTab {
new Setting(containerEl).setName("Vault").setHeading();
new Setting(containerEl)
.setName("Vault name")
.setDesc(
"Friendly name for this vault. Defaults to your Obsidian vault folder name.",
)
.addText((text) =>
text
.setValue(settings.vaultName)
.onChange(async (value) => {
this.plugin.settings.vaultName = value.trim() || this.app.vault.getName();
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Sync mode")
.setDesc("Auto syncs on every edit. Manual only syncs when you trigger it via the command palette.")
@ -150,10 +134,30 @@ export class SurfSenseSettingTab extends PluginSettingTab {
}),
);
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("Exclude patterns")
.setName("Advanced exclude patterns")
.setDesc(
"One pattern per line. Supports * and **. Lines starting with # are comments. Files matching any pattern are skipped.",
"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;
@ -180,41 +184,12 @@ export class SurfSenseSettingTab extends PluginSettingTab {
}),
);
new Setting(containerEl).setName("Identity").setHeading();
new Setting(containerEl)
.setName("Vault ID")
.setDesc(
"Stable identifier for this vault. Used by the backend to keep separate vaults distinct even if their folder names change.",
)
.addText((text) => {
text.inputEl.disabled = true;
text.setValue(settings.vaultId);
});
// Device ID is deliberately not exposed: it's an opaque per-install UUID
// (see seedIdentity in main.ts) and the web UI only shows a device count.
new Setting(containerEl).setName("Status").setHeading();
this.statusEl = containerEl.createDiv({ cls: "surfsense-settings__status" });
this.renderStatus();
new Setting(containerEl)
.addButton((btn) =>
btn
.setButtonText("Re-sync entire vault")
.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);
this.renderStatus();
}
}),
.setButtonText("View sync status")
.setCta()
.onClick(() => this.plugin.openStatusModal()),
)
.addButton((btn) =>
btn.setButtonText("Open releases").onClick(() => {
@ -226,10 +201,6 @@ export class SurfSenseSettingTab extends PluginSettingTab {
);
}
hide(): void {
this.statusEl = null;
}
private async refreshSearchSpaces(): Promise<void> {
this.loadingSpaces = true;
try {
@ -242,38 +213,46 @@ export class SurfSenseSettingTab extends PluginSettingTab {
}
}
renderStatus(): void {
if (!this.statusEl) return;
const s = this.plugin.settings;
this.statusEl.empty();
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 rows: { label: string; value: string }[] = [
{ label: "Status", value: this.plugin.lastStatus.kind },
{
label: "Last sync",
value: s.lastSyncAt ? new Date(s.lastSyncAt).toLocaleString() : "—",
},
{
label: "Last reconcile",
value: s.lastReconcileAt ? new Date(s.lastReconcileAt).toLocaleString() : "—",
},
{ label: "Files synced", value: String(s.filesSynced ?? 0) },
{ label: "Queue depth", value: String(this.plugin.queueDepth) },
{
label: "API version",
value: this.plugin.serverApiVersion ?? "(not yet handshaken)",
},
{
label: "Capabilities",
value: this.plugin.serverCapabilities.length
? this.plugin.serverCapabilities.join(", ")
: "(not yet handshaken)",
},
];
for (const row of rows) {
const wrap = this.statusEl.createDiv({ cls: "surfsense-settings__status-row" });
wrap.createSpan({ cls: "surfsense-settings__status-label", text: row.label });
wrap.createSpan({ cls: "surfsense-settings__status-value", text: row.value });
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));
}),
);
}
}

View file

@ -28,11 +28,15 @@ export class StatusBar {
private readonly icon: HTMLElement;
private readonly text: HTMLElement;
constructor(host: HTMLElement) {
constructor(host: HTMLElement, onClick?: () => void) {
this.el = host;
this.el.addClass("surfsense-status");
this.icon = this.el.createSpan({ cls: "surfsense-status__icon" });
this.text = this.el.createSpan({ cls: "surfsense-status__text" });
if (onClick) {
this.el.addClass("surfsense-status--clickable");
this.el.addEventListener("click", onClick);
}
this.update({ kind: "idle", queueDepth: 0 });
}

View file

@ -0,0 +1,82 @@
import { type App, Modal, Notice, Setting } from "obsidian";
import type SurfSensePlugin from "./main";
/**
* Read-only status panel. Mirrors what the settings tab used to embed inline,
* but as a modal so it's reachable from the status bar / command palette.
*
* Subscribes to plugin status changes while open so the numbers stay live;
* unsubscribes on close.
*/
export class StatusModal extends Modal {
private readonly plugin: SurfSensePlugin;
private readonly onChange = (): void => this.render();
constructor(app: App, plugin: SurfSensePlugin) {
super(app);
this.plugin = plugin;
}
onOpen(): void {
this.titleEl.setText("SurfSense status");
this.plugin.onStatusChange(this.onChange);
this.render();
}
onClose(): void {
this.plugin.offStatusChange(this.onChange);
this.contentEl.empty();
}
private render(): void {
const { contentEl, plugin } = this;
contentEl.empty();
const s = plugin.settings;
const rows: Array<[string, string]> = [
["Status", plugin.lastStatus.kind],
[
"Last sync",
s.lastSyncAt ? new Date(s.lastSyncAt).toLocaleString() : "—",
],
[
"Last reconcile",
s.lastReconcileAt
? new Date(s.lastReconcileAt).toLocaleString()
: "—",
],
["Files synced", String(s.filesSynced ?? 0)],
["Queue depth", String(plugin.queueDepth)],
[
"Capabilities",
plugin.serverCapabilities.length
? plugin.serverCapabilities.join(", ")
: "(not yet handshaken)",
],
];
for (const [label, value] of rows) {
new Setting(contentEl).setName(label).setDesc(value);
}
new Setting(contentEl)
.addButton((btn) =>
btn
.setButtonText("Re-sync entire vault")
.setCta()
.onClick(async () => {
btn.setDisabled(true);
try {
await plugin.engine.maybeReconcile(true);
new Notice("SurfSense: re-sync requested.");
} catch (err) {
new Notice(
`SurfSense: re-sync failed — ${(err as Error).message}`,
);
} finally {
btn.setDisabled(false);
}
}),
)
.addButton((btn) => btn.setButtonText("Close").onClick(() => this.close()));
}
}

View file

@ -6,7 +6,7 @@ import {
TransientError,
VaultNotRegisteredError,
} from "./api-client";
import { isExcluded } from "./excludes";
import { isExcluded, isFolderFiltered } from "./excludes";
import { buildNotePayload, computeContentHash } from "./payload";
import { type BatchResult, PersistentQueue } from "./queue";
import type {
@ -31,14 +31,15 @@ export interface SyncEngineDeps {
getSettings: () => SyncEngineSettings;
saveSettings: (mut: (s: SyncEngineSettings) => void) => Promise<void>;
setStatus: (s: StatusState) => void;
onCapabilities: (caps: string[], apiVersion: string) => void;
onCapabilities: (caps: string[]) => void;
}
export interface SyncEngineSettings {
vaultId: string;
vaultName: string;
connectorId: number | null;
searchSpaceId: number | null;
includeFolders: string[];
excludeFolders: string[];
excludePatterns: string[];
includeAttachments: boolean;
syncMode: "auto" | "manual";
@ -55,7 +56,6 @@ const PENDING_DEBOUNCE_MS = 1500;
export class SyncEngine {
private readonly deps: SyncEngineDeps;
private capabilities: string[] = [];
private apiVersion: string | null = null;
private pendingMdEdits = new Map<string, ReturnType<typeof setTimeout>>();
constructor(deps: SyncEngineDeps) {
@ -109,7 +109,7 @@ export class SyncEngine {
const resp = await this.deps.apiClient.connect({
searchSpaceId: settings.searchSpaceId,
vaultId: settings.vaultId,
vaultName: settings.vaultName,
vaultName: this.deps.app.vault.getName(),
});
this.applyHealth(resp);
await this.deps.saveSettings((s) => {
@ -122,8 +122,7 @@ export class SyncEngine {
applyHealth(h: HealthResponse): void {
this.capabilities = Array.isArray(h.capabilities) ? [...h.capabilities] : [];
this.apiVersion = h.api_version ?? null;
this.deps.onCapabilities(this.capabilities, this.apiVersion ?? "?");
this.deps.onCapabilities(this.capabilities);
}
// ---- vault event handlers --------------------------------------------
@ -485,6 +484,9 @@ export class SyncEngine {
}
private isExcluded(path: string, settings: SyncEngineSettings): boolean {
if (isFolderFiltered(path, settings.includeFolders, settings.excludeFolders)) {
return true;
}
return isExcluded(path, settings.excludePatterns);
}

View file

@ -7,8 +7,9 @@ export interface SurfsensePluginSettings {
connectorId: number | null;
/** UUID for the vault — lives here so Obsidian Sync replicates it across devices. */
vaultId: string;
vaultName: string;
syncMode: "auto" | "manual";
includeFolders: string[];
excludeFolders: string[];
excludePatterns: string[];
includeAttachments: boolean;
lastSyncAt: number | null;
@ -24,8 +25,9 @@ export const DEFAULT_SETTINGS: SurfsensePluginSettings = {
searchSpaceId: null,
connectorId: null,
vaultId: "",
vaultName: "",
syncMode: "auto",
includeFolders: [],
excludeFolders: [],
excludePatterns: [".trash", "_attachments", "templates"],
includeAttachments: false,
lastSyncAt: null,
@ -96,14 +98,12 @@ export interface ConnectResponse {
connector_id: number;
vault_id: string;
search_space_id: number;
api_version: string;
capabilities: string[];
server_time_utc: string;
[key: string]: unknown;
}
export interface HealthResponse {
api_version: string;
capabilities: string[];
server_time_utc: string;
[key: string]: unknown;

View file

@ -1,15 +1,14 @@
/*
* SurfSense Obsidian plugin styles. Kept tiny on purpose Obsidian
* theming should drive most of the look; we only add the bits we
* cannot express via the standard PluginSettingTab/Setting components.
* SurfSense Obsidian plugin styles. Status-bar widget only the settings
* tab uses Obsidian's stock Setting rows, no custom CSS needed.
*/
.surfsense-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 6px;
cursor: default;
}
.surfsense-status--clickable {
cursor: pointer;
}
.surfsense-status__icon {
@ -23,10 +22,6 @@
height: 14px;
}
.surfsense-status__text {
font-size: var(--font-ui-smaller);
}
.surfsense-status--ok .surfsense-status__icon {
color: var(--color-green);
}
@ -42,25 +37,3 @@
.surfsense-status--err .surfsense-status__icon {
color: var(--color-red);
}
.surfsense-settings__status {
display: grid;
grid-template-columns: minmax(120px, max-content) 1fr;
row-gap: 4px;
column-gap: 12px;
margin: 8px 0 16px;
}
.surfsense-settings__status-row {
display: contents;
}
.surfsense-settings__status-label {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
.surfsense-settings__status-value {
font-size: var(--font-ui-smaller);
word-break: break-word;
}