mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-03 12:52:39 +02:00
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:
parent
2251e464c7
commit
28d3c628f1
11 changed files with 267 additions and 154 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
"""Obsidian plugin ingestion routes (``/api/v1/obsidian/*``).
|
"""Obsidian plugin ingestion routes (``/api/v1/obsidian/*``).
|
||||||
|
|
||||||
Wire surface for the ``surfsense_obsidian/`` plugin. API stability is the
|
Wire surface for the ``surfsense_obsidian/`` plugin. Versioning anchor is
|
||||||
``/api/v1/`` prefix plus the additive ``capabilities`` array on /health;
|
the ``/api/v1/`` URL prefix; additive feature detection rides the
|
||||||
no plugin-version gate.
|
``capabilities`` array on /health and /connect.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -46,9 +46,6 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/obsidian", tags=["obsidian-plugin"])
|
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.
|
# Plugins feature-gate on these. Add entries, never rename or remove.
|
||||||
OBSIDIAN_CAPABILITIES: list[str] = ["sync", "rename", "delete", "manifest", "stats"]
|
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]:
|
def _build_handshake() -> dict[str, object]:
|
||||||
return {
|
return {"capabilities": list(OBSIDIAN_CAPABILITIES)}
|
||||||
"api_version": OBSIDIAN_API_VERSION,
|
|
||||||
"capabilities": list(OBSIDIAN_CAPABILITIES),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_vault_connector(
|
async def _resolve_vault_connector(
|
||||||
|
|
|
||||||
|
|
@ -92,13 +92,11 @@ class ConnectResponse(_PluginBase):
|
||||||
connector_id: int
|
connector_id: int
|
||||||
vault_id: str
|
vault_id: str
|
||||||
search_space_id: int
|
search_space_id: int
|
||||||
api_version: str
|
|
||||||
capabilities: list[str]
|
capabilities: list[str]
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(_PluginBase):
|
class HealthResponse(_PluginBase):
|
||||||
"""API contract handshake. ``capabilities`` is additive-only string list."""
|
"""API contract handshake. ``capabilities`` is additive-only string list."""
|
||||||
|
|
||||||
api_version: str
|
|
||||||
capabilities: list[str]
|
capabilities: list[str]
|
||||||
server_time_utc: datetime
|
server_time_utc: datetime
|
||||||
|
|
|
||||||
|
|
@ -64,3 +64,31 @@ export function parseExcludePatterns(raw: string): string[] {
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
.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;
|
||||||
|
}
|
||||||
|
|
|
||||||
32
surfsense_obsidian/src/folder-suggest-modal.ts
Normal file
32
surfsense_obsidian/src/folder-suggest-modal.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { SurfSenseApiClient } from "./api-client";
|
||||||
import { PersistentQueue } from "./queue";
|
import { PersistentQueue } from "./queue";
|
||||||
import { SurfSenseSettingTab } from "./settings";
|
import { SurfSenseSettingTab } from "./settings";
|
||||||
import { StatusBar } from "./status-bar";
|
import { StatusBar } from "./status-bar";
|
||||||
|
import { StatusModal } from "./status-modal";
|
||||||
import { SyncEngine } from "./sync-engine";
|
import { SyncEngine } from "./sync-engine";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
|
|
@ -20,8 +21,8 @@ export default class SurfSensePlugin extends Plugin {
|
||||||
private statusBar: StatusBar | null = null;
|
private statusBar: StatusBar | null = null;
|
||||||
lastStatus: StatusState = { kind: "idle", queueDepth: 0 };
|
lastStatus: StatusState = { kind: "idle", queueDepth: 0 };
|
||||||
serverCapabilities: string[] = [];
|
serverCapabilities: string[] = [];
|
||||||
serverApiVersion: string | null = null;
|
|
||||||
private settingTab: SurfSenseSettingTab | null = null;
|
private settingTab: SurfSenseSettingTab | null = null;
|
||||||
|
private statusListeners = new Set<() => void>();
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
|
|
@ -48,17 +49,16 @@ export default class SurfSensePlugin extends Plugin {
|
||||||
saveSettings: async (mut) => {
|
saveSettings: async (mut) => {
|
||||||
mut(this.settings);
|
mut(this.settings);
|
||||||
await this.saveSettings();
|
await this.saveSettings();
|
||||||
this.settingTab?.renderStatus();
|
this.notifyStatusChange();
|
||||||
},
|
},
|
||||||
setStatus: (s) => {
|
setStatus: (s) => {
|
||||||
this.lastStatus = s;
|
this.lastStatus = s;
|
||||||
this.statusBar?.update(s);
|
this.statusBar?.update(s);
|
||||||
this.settingTab?.renderStatus();
|
this.notifyStatusChange();
|
||||||
},
|
},
|
||||||
onCapabilities: (caps, apiVersion) => {
|
onCapabilities: (caps) => {
|
||||||
this.serverCapabilities = [...caps];
|
this.serverCapabilities = [...caps];
|
||||||
this.serverApiVersion = apiVersion;
|
this.notifyStatusChange();
|
||||||
this.settingTab?.renderStatus();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ export default class SurfSensePlugin extends Plugin {
|
||||||
this.addSettingTab(this.settingTab);
|
this.addSettingTab(this.settingTab);
|
||||||
|
|
||||||
const statusHost = this.addStatusBarItem();
|
const statusHost = this.addStatusBarItem();
|
||||||
this.statusBar = new StatusBar(statusHost);
|
this.statusBar = new StatusBar(statusHost, () => this.openStatusModal());
|
||||||
this.statusBar.update(this.lastStatus);
|
this.statusBar.update(this.lastStatus);
|
||||||
|
|
||||||
this.registerEvent(
|
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({
|
this.addCommand({
|
||||||
id: "open-settings",
|
id: "open-settings",
|
||||||
name: "Open settings",
|
name: "Open settings",
|
||||||
|
|
@ -150,6 +156,22 @@ export default class SurfSensePlugin extends Plugin {
|
||||||
return this.queue?.size ?? 0;
|
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() {
|
async loadSettings() {
|
||||||
const data = (await this.loadData()) as Partial<SurfsensePluginSettings> | null;
|
const data = (await this.loadData()) as Partial<SurfsensePluginSettings> | null;
|
||||||
this.settings = {
|
this.settings = {
|
||||||
|
|
@ -157,6 +179,8 @@ export default class SurfSensePlugin extends Plugin {
|
||||||
...(data ?? {}),
|
...(data ?? {}),
|
||||||
queue: (data?.queue ?? []).map((i: QueueItem) => ({ ...i })),
|
queue: (data?.queue ?? []).map((i: QueueItem) => ({ ...i })),
|
||||||
tombstones: { ...(data?.tombstones ?? {}) },
|
tombstones: { ...(data?.tombstones ?? {}) },
|
||||||
|
includeFolders: [...(data?.includeFolders ?? [])],
|
||||||
|
excludeFolders: [...(data?.excludeFolders ?? [])],
|
||||||
excludePatterns: data?.excludePatterns?.length
|
excludePatterns: data?.excludePatterns?.length
|
||||||
? [...data.excludePatterns]
|
? [...data.excludePatterns]
|
||||||
: [...DEFAULT_SETTINGS.excludePatterns],
|
: [...DEFAULT_SETTINGS.excludePatterns],
|
||||||
|
|
@ -172,9 +196,6 @@ export default class SurfSensePlugin extends Plugin {
|
||||||
if (!this.settings.vaultId) {
|
if (!this.settings.vaultId) {
|
||||||
this.settings.vaultId = generateUuid();
|
this.settings.vaultId = generateUuid();
|
||||||
}
|
}
|
||||||
if (!this.settings.vaultName) {
|
|
||||||
this.settings.vaultName = this.app.vault.getName();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import {
|
||||||
Setting,
|
Setting,
|
||||||
} from "obsidian";
|
} from "obsidian";
|
||||||
import { AuthError } from "./api-client";
|
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 SurfSensePlugin from "./main";
|
||||||
import type { SearchSpace } from "./types";
|
import type { SearchSpace } from "./types";
|
||||||
|
|
||||||
|
|
@ -15,7 +16,6 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
private readonly plugin: SurfSensePlugin;
|
private readonly plugin: SurfSensePlugin;
|
||||||
private searchSpaces: SearchSpace[] = [];
|
private searchSpaces: SearchSpace[] = [];
|
||||||
private loadingSpaces = false;
|
private loadingSpaces = false;
|
||||||
private statusEl: HTMLElement | null = null;
|
|
||||||
|
|
||||||
constructor(app: App, plugin: SurfSensePlugin) {
|
constructor(app: App, plugin: SurfSensePlugin) {
|
||||||
super(app, plugin);
|
super(app, plugin);
|
||||||
|
|
@ -25,7 +25,6 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
display(): void {
|
display(): void {
|
||||||
const { containerEl } = this;
|
const { containerEl } = this;
|
||||||
containerEl.empty();
|
containerEl.empty();
|
||||||
containerEl.addClass("surfsense-settings");
|
|
||||||
|
|
||||||
const settings = this.plugin.settings;
|
const settings = this.plugin.settings;
|
||||||
|
|
||||||
|
|
@ -107,7 +106,6 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
this.handleApiError(err);
|
this.handleApiError(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.renderStatus();
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.addExtraButton((btn) =>
|
.addExtraButton((btn) =>
|
||||||
|
|
@ -122,20 +120,6 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
|
|
||||||
new Setting(containerEl).setName("Vault").setHeading();
|
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)
|
new Setting(containerEl)
|
||||||
.setName("Sync mode")
|
.setName("Sync mode")
|
||||||
.setDesc("Auto syncs on every edit. Manual only syncs when you trigger it via the command palette.")
|
.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)
|
new Setting(containerEl)
|
||||||
.setName("Exclude patterns")
|
.setName("Advanced exclude patterns")
|
||||||
.setDesc(
|
.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) => {
|
.addTextArea((area) => {
|
||||||
area.inputEl.rows = 4;
|
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)
|
new Setting(containerEl)
|
||||||
.addButton((btn) =>
|
.addButton((btn) =>
|
||||||
btn
|
btn
|
||||||
.setButtonText("Re-sync entire vault")
|
.setButtonText("View sync status")
|
||||||
.onClick(async () => {
|
.setCta()
|
||||||
btn.setDisabled(true);
|
.onClick(() => this.plugin.openStatusModal()),
|
||||||
try {
|
|
||||||
await this.plugin.engine.maybeReconcile(true);
|
|
||||||
new Notice("Surfsense: re-sync requested.");
|
|
||||||
} catch (err) {
|
|
||||||
this.handleApiError(err);
|
|
||||||
} finally {
|
|
||||||
btn.setDisabled(false);
|
|
||||||
this.renderStatus();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.addButton((btn) =>
|
.addButton((btn) =>
|
||||||
btn.setButtonText("Open releases").onClick(() => {
|
btn.setButtonText("Open releases").onClick(() => {
|
||||||
|
|
@ -226,10 +201,6 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
hide(): void {
|
|
||||||
this.statusEl = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshSearchSpaces(): Promise<void> {
|
private async refreshSearchSpaces(): Promise<void> {
|
||||||
this.loadingSpaces = true;
|
this.loadingSpaces = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -242,38 +213,46 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStatus(): void {
|
private renderFolderList(
|
||||||
if (!this.statusEl) return;
|
containerEl: HTMLElement,
|
||||||
const s = this.plugin.settings;
|
title: string,
|
||||||
this.statusEl.empty();
|
desc: string,
|
||||||
|
current: string[],
|
||||||
|
write: (next: string[]) => void,
|
||||||
|
): void {
|
||||||
|
const setting = new Setting(containerEl).setName(title).setDesc(desc);
|
||||||
|
|
||||||
const rows: { label: string; value: string }[] = [
|
const persist = async (next: string[]): Promise<void> => {
|
||||||
{ label: "Status", value: this.plugin.lastStatus.kind },
|
const dedup = Array.from(new Set(next.map(normalizeFolder)));
|
||||||
{
|
write(dedup);
|
||||||
label: "Last sync",
|
await this.plugin.saveSettings();
|
||||||
value: s.lastSyncAt ? new Date(s.lastSyncAt).toLocaleString() : "—",
|
this.display();
|
||||||
|
};
|
||||||
|
|
||||||
|
setting.addButton((btn) =>
|
||||||
|
btn
|
||||||
|
.setButtonText("Add Folder")
|
||||||
|
.setCta()
|
||||||
|
.onClick(() => {
|
||||||
|
new FolderSuggestModal(
|
||||||
|
this.app,
|
||||||
|
(picked) => {
|
||||||
|
void persist([...current, picked]);
|
||||||
},
|
},
|
||||||
{
|
current,
|
||||||
label: "Last reconcile",
|
).open();
|
||||||
value: s.lastReconcileAt ? new Date(s.lastReconcileAt).toLocaleString() : "—",
|
}),
|
||||||
},
|
);
|
||||||
{ label: "Files synced", value: String(s.filesSynced ?? 0) },
|
|
||||||
{ label: "Queue depth", value: String(this.plugin.queueDepth) },
|
for (const folder of current) {
|
||||||
{
|
new Setting(containerEl).setName(folder || "/").addExtraButton((btn) =>
|
||||||
label: "API version",
|
btn
|
||||||
value: this.plugin.serverApiVersion ?? "(not yet handshaken)",
|
.setIcon("cross")
|
||||||
},
|
.setTooltip("Remove")
|
||||||
{
|
.onClick(() => {
|
||||||
label: "Capabilities",
|
void persist(current.filter((f) => f !== folder));
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,15 @@ export class StatusBar {
|
||||||
private readonly icon: HTMLElement;
|
private readonly icon: HTMLElement;
|
||||||
private readonly text: HTMLElement;
|
private readonly text: HTMLElement;
|
||||||
|
|
||||||
constructor(host: HTMLElement) {
|
constructor(host: HTMLElement, onClick?: () => void) {
|
||||||
this.el = host;
|
this.el = host;
|
||||||
this.el.addClass("surfsense-status");
|
this.el.addClass("surfsense-status");
|
||||||
this.icon = this.el.createSpan({ cls: "surfsense-status__icon" });
|
this.icon = this.el.createSpan({ cls: "surfsense-status__icon" });
|
||||||
this.text = this.el.createSpan({ cls: "surfsense-status__text" });
|
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 });
|
this.update({ kind: "idle", queueDepth: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
82
surfsense_obsidian/src/status-modal.ts
Normal file
82
surfsense_obsidian/src/status-modal.ts
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
TransientError,
|
TransientError,
|
||||||
VaultNotRegisteredError,
|
VaultNotRegisteredError,
|
||||||
} from "./api-client";
|
} from "./api-client";
|
||||||
import { isExcluded } from "./excludes";
|
import { isExcluded, isFolderFiltered } from "./excludes";
|
||||||
import { buildNotePayload, computeContentHash } from "./payload";
|
import { buildNotePayload, computeContentHash } from "./payload";
|
||||||
import { type BatchResult, PersistentQueue } from "./queue";
|
import { type BatchResult, PersistentQueue } from "./queue";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -31,14 +31,15 @@ export interface SyncEngineDeps {
|
||||||
getSettings: () => SyncEngineSettings;
|
getSettings: () => SyncEngineSettings;
|
||||||
saveSettings: (mut: (s: SyncEngineSettings) => void) => Promise<void>;
|
saveSettings: (mut: (s: SyncEngineSettings) => void) => Promise<void>;
|
||||||
setStatus: (s: StatusState) => void;
|
setStatus: (s: StatusState) => void;
|
||||||
onCapabilities: (caps: string[], apiVersion: string) => void;
|
onCapabilities: (caps: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncEngineSettings {
|
export interface SyncEngineSettings {
|
||||||
vaultId: string;
|
vaultId: string;
|
||||||
vaultName: string;
|
|
||||||
connectorId: number | null;
|
connectorId: number | null;
|
||||||
searchSpaceId: number | null;
|
searchSpaceId: number | null;
|
||||||
|
includeFolders: string[];
|
||||||
|
excludeFolders: string[];
|
||||||
excludePatterns: string[];
|
excludePatterns: string[];
|
||||||
includeAttachments: boolean;
|
includeAttachments: boolean;
|
||||||
syncMode: "auto" | "manual";
|
syncMode: "auto" | "manual";
|
||||||
|
|
@ -55,7 +56,6 @@ const PENDING_DEBOUNCE_MS = 1500;
|
||||||
export class SyncEngine {
|
export class SyncEngine {
|
||||||
private readonly deps: SyncEngineDeps;
|
private readonly deps: SyncEngineDeps;
|
||||||
private capabilities: string[] = [];
|
private capabilities: string[] = [];
|
||||||
private apiVersion: string | null = null;
|
|
||||||
private pendingMdEdits = new Map<string, ReturnType<typeof setTimeout>>();
|
private pendingMdEdits = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
constructor(deps: SyncEngineDeps) {
|
constructor(deps: SyncEngineDeps) {
|
||||||
|
|
@ -109,7 +109,7 @@ export class SyncEngine {
|
||||||
const resp = await this.deps.apiClient.connect({
|
const resp = await this.deps.apiClient.connect({
|
||||||
searchSpaceId: settings.searchSpaceId,
|
searchSpaceId: settings.searchSpaceId,
|
||||||
vaultId: settings.vaultId,
|
vaultId: settings.vaultId,
|
||||||
vaultName: settings.vaultName,
|
vaultName: this.deps.app.vault.getName(),
|
||||||
});
|
});
|
||||||
this.applyHealth(resp);
|
this.applyHealth(resp);
|
||||||
await this.deps.saveSettings((s) => {
|
await this.deps.saveSettings((s) => {
|
||||||
|
|
@ -122,8 +122,7 @@ export class SyncEngine {
|
||||||
|
|
||||||
applyHealth(h: HealthResponse): void {
|
applyHealth(h: HealthResponse): void {
|
||||||
this.capabilities = Array.isArray(h.capabilities) ? [...h.capabilities] : [];
|
this.capabilities = Array.isArray(h.capabilities) ? [...h.capabilities] : [];
|
||||||
this.apiVersion = h.api_version ?? null;
|
this.deps.onCapabilities(this.capabilities);
|
||||||
this.deps.onCapabilities(this.capabilities, this.apiVersion ?? "?");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- vault event handlers --------------------------------------------
|
// ---- vault event handlers --------------------------------------------
|
||||||
|
|
@ -485,6 +484,9 @@ export class SyncEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
private isExcluded(path: string, settings: SyncEngineSettings): boolean {
|
private isExcluded(path: string, settings: SyncEngineSettings): boolean {
|
||||||
|
if (isFolderFiltered(path, settings.includeFolders, settings.excludeFolders)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return isExcluded(path, settings.excludePatterns);
|
return isExcluded(path, settings.excludePatterns);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ export interface SurfsensePluginSettings {
|
||||||
connectorId: number | null;
|
connectorId: number | null;
|
||||||
/** UUID for the vault — lives here so Obsidian Sync replicates it across devices. */
|
/** UUID for the vault — lives here so Obsidian Sync replicates it across devices. */
|
||||||
vaultId: string;
|
vaultId: string;
|
||||||
vaultName: string;
|
|
||||||
syncMode: "auto" | "manual";
|
syncMode: "auto" | "manual";
|
||||||
|
includeFolders: string[];
|
||||||
|
excludeFolders: string[];
|
||||||
excludePatterns: string[];
|
excludePatterns: string[];
|
||||||
includeAttachments: boolean;
|
includeAttachments: boolean;
|
||||||
lastSyncAt: number | null;
|
lastSyncAt: number | null;
|
||||||
|
|
@ -24,8 +25,9 @@ export const DEFAULT_SETTINGS: SurfsensePluginSettings = {
|
||||||
searchSpaceId: null,
|
searchSpaceId: null,
|
||||||
connectorId: null,
|
connectorId: null,
|
||||||
vaultId: "",
|
vaultId: "",
|
||||||
vaultName: "",
|
|
||||||
syncMode: "auto",
|
syncMode: "auto",
|
||||||
|
includeFolders: [],
|
||||||
|
excludeFolders: [],
|
||||||
excludePatterns: [".trash", "_attachments", "templates"],
|
excludePatterns: [".trash", "_attachments", "templates"],
|
||||||
includeAttachments: false,
|
includeAttachments: false,
|
||||||
lastSyncAt: null,
|
lastSyncAt: null,
|
||||||
|
|
@ -96,14 +98,12 @@ export interface ConnectResponse {
|
||||||
connector_id: number;
|
connector_id: number;
|
||||||
vault_id: string;
|
vault_id: string;
|
||||||
search_space_id: number;
|
search_space_id: number;
|
||||||
api_version: string;
|
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
server_time_utc: string;
|
server_time_utc: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthResponse {
|
export interface HealthResponse {
|
||||||
api_version: string;
|
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
server_time_utc: string;
|
server_time_utc: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
/*
|
/*
|
||||||
* SurfSense Obsidian plugin styles. Kept tiny on purpose — Obsidian
|
* SurfSense Obsidian plugin styles. Status-bar widget only — the settings
|
||||||
* theming should drive most of the look; we only add the bits we
|
* tab uses Obsidian's stock Setting rows, no custom CSS needed.
|
||||||
* cannot express via the standard PluginSettingTab/Setting components.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.surfsense-status {
|
.surfsense-status {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 0 6px;
|
}
|
||||||
cursor: default;
|
|
||||||
|
.surfsense-status--clickable {
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surfsense-status__icon {
|
.surfsense-status__icon {
|
||||||
|
|
@ -23,10 +22,6 @@
|
||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surfsense-status__text {
|
|
||||||
font-size: var(--font-ui-smaller);
|
|
||||||
}
|
|
||||||
|
|
||||||
.surfsense-status--ok .surfsense-status__icon {
|
.surfsense-status--ok .surfsense-status__icon {
|
||||||
color: var(--color-green);
|
color: var(--color-green);
|
||||||
}
|
}
|
||||||
|
|
@ -42,25 +37,3 @@
|
||||||
.surfsense-status--err .surfsense-status__icon {
|
.surfsense-status--err .surfsense-status__icon {
|
||||||
color: var(--color-red);
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue