diff --git a/surfsense_backend/app/routes/obsidian_plugin_routes.py b/surfsense_backend/app/routes/obsidian_plugin_routes.py index edc99cae6..63522de74 100644 --- a/surfsense_backend/app/routes/obsidian_plugin_routes.py +++ b/surfsense_backend/app/routes/obsidian_plugin_routes.py @@ -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( diff --git a/surfsense_backend/app/schemas/obsidian_plugin.py b/surfsense_backend/app/schemas/obsidian_plugin.py index 7f77efd40..8eb863745 100644 --- a/surfsense_backend/app/schemas/obsidian_plugin.py +++ b/surfsense_backend/app/schemas/obsidian_plugin.py @@ -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 diff --git a/surfsense_obsidian/src/excludes.ts b/surfsense_obsidian/src/excludes.ts index 67a59bc50..1f47170b1 100644 --- a/surfsense_obsidian/src/excludes.ts +++ b/surfsense_obsidian/src/excludes.ts @@ -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; +} diff --git a/surfsense_obsidian/src/folder-suggest-modal.ts b/surfsense_obsidian/src/folder-suggest-modal.ts new file mode 100644 index 000000000..a037a620f --- /dev/null +++ b/surfsense_obsidian/src/folder-suggest-modal.ts @@ -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 { + private readonly onPick: (path: string) => void; + private readonly excluded: Set; + + 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; + } +} diff --git a/surfsense_obsidian/src/main.ts b/surfsense_obsidian/src/main.ts index 0aa25ae58..d8b9c8a62 100644 --- a/surfsense_obsidian/src/main.ts +++ b/surfsense_obsidian/src/main.ts @@ -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 | 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(); - } } } diff --git a/surfsense_obsidian/src/settings.ts b/surfsense_obsidian/src/settings.ts index 224959f95..0b753a2bb 100644 --- a/surfsense_obsidian/src/settings.ts +++ b/surfsense_obsidian/src/settings.ts @@ -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 { 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 => { + 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)); + }), + ); } } diff --git a/surfsense_obsidian/src/status-bar.ts b/surfsense_obsidian/src/status-bar.ts index 4dc163778..ec470978f 100644 --- a/surfsense_obsidian/src/status-bar.ts +++ b/surfsense_obsidian/src/status-bar.ts @@ -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 }); } diff --git a/surfsense_obsidian/src/status-modal.ts b/surfsense_obsidian/src/status-modal.ts new file mode 100644 index 000000000..c0ac9c8ba --- /dev/null +++ b/surfsense_obsidian/src/status-modal.ts @@ -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())); + } +} diff --git a/surfsense_obsidian/src/sync-engine.ts b/surfsense_obsidian/src/sync-engine.ts index 281b23f0f..8f5fc67f6 100644 --- a/surfsense_obsidian/src/sync-engine.ts +++ b/surfsense_obsidian/src/sync-engine.ts @@ -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; 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>(); 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); } diff --git a/surfsense_obsidian/src/types.ts b/surfsense_obsidian/src/types.ts index c37868733..d3dd2a14e 100644 --- a/surfsense_obsidian/src/types.ts +++ b/surfsense_obsidian/src/types.ts @@ -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; diff --git a/surfsense_obsidian/styles.css b/surfsense_obsidian/styles.css index 6ad450091..81b2203f3 100644 --- a/surfsense_obsidian/styles.css +++ b/surfsense_obsidian/styles.css @@ -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; -}