mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
feat: refine Obsidian plugin routes and schemas for improved device management and API stability
This commit is contained in:
parent
60d9e7ed8c
commit
b5c9388c8a
9 changed files with 182 additions and 385 deletions
|
|
@ -1,31 +1,8 @@
|
|||
"""
|
||||
Obsidian plugin ingestion routes.
|
||||
"""Obsidian plugin ingestion routes (``/api/v1/obsidian/*``).
|
||||
|
||||
This is the public surface that the SurfSense Obsidian plugin
|
||||
(``surfsense_obsidian/``) speaks to. It is a separate router from the
|
||||
legacy server-path Obsidian connector — the legacy code stays in place
|
||||
until the ``obsidian-legacy-cleanup`` plan ships.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
- ``GET /api/v1/obsidian/health`` — version handshake
|
||||
- ``POST /api/v1/obsidian/connect`` — register or get a vault row
|
||||
- ``POST /api/v1/obsidian/sync`` — batch upsert
|
||||
- ``POST /api/v1/obsidian/rename`` — batch rename
|
||||
- ``DELETE /api/v1/obsidian/notes`` — batch soft-delete
|
||||
- ``GET /api/v1/obsidian/manifest`` — reconcile manifest
|
||||
|
||||
Auth contract
|
||||
-------------
|
||||
|
||||
Every endpoint requires ``Depends(current_active_user)`` — the same JWT
|
||||
bearer the rest of the API uses; future PAT migration is transparent.
|
||||
|
||||
API stability is provided by the ``/api/v1/...`` URL prefix and the
|
||||
``capabilities`` array advertised on ``/health`` (additive only). There
|
||||
is no plugin-version gate; "your plugin is out of date" notices are
|
||||
delegated to Obsidian's built-in community-store updater.
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -67,14 +44,10 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter(prefix="/obsidian", tags=["obsidian-plugin"])
|
||||
|
||||
|
||||
# Bumped manually whenever the wire contract gains a non-additive change.
|
||||
# Additive (extra='ignore'-safe) changes do NOT bump this.
|
||||
# Bumped only on non-additive wire changes; additive ones ride extra='ignore'.
|
||||
OBSIDIAN_API_VERSION = "1"
|
||||
|
||||
# Capabilities advertised on /health and /connect. Plugins use this list
|
||||
# for feature gating ("does this server understand attachments_v2?"). Add
|
||||
# new strings, never rename/remove existing ones — older plugins ignore
|
||||
# unknown entries safely.
|
||||
# Plugins feature-gate on these. Add entries, never rename or remove.
|
||||
OBSIDIAN_CAPABILITIES: list[str] = ["sync", "rename", "delete", "manifest"]
|
||||
|
||||
|
||||
|
|
@ -90,18 +63,41 @@ def _build_handshake() -> dict[str, object]:
|
|||
}
|
||||
|
||||
|
||||
def _upsert_device(
|
||||
existing_devices: object,
|
||||
device_id: str,
|
||||
now_iso: str,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""Upsert ``device_id`` into ``{device_id: {first_seen_at, last_seen_at}}``.
|
||||
|
||||
Keyed by device_id for O(1) dedup; ``len(devices)`` is the count.
|
||||
Timestamps are kept for a future stale-device pruner.
|
||||
"""
|
||||
devices: dict[str, dict[str, str]] = {}
|
||||
if isinstance(existing_devices, dict):
|
||||
for key, val in existing_devices.items():
|
||||
if not isinstance(key, str) or not key or not isinstance(val, dict):
|
||||
continue
|
||||
devices[key] = {
|
||||
"first_seen_at": str(val.get("first_seen_at") or now_iso),
|
||||
"last_seen_at": str(val.get("last_seen_at") or now_iso),
|
||||
}
|
||||
|
||||
prev = devices.get(device_id)
|
||||
devices[device_id] = {
|
||||
"first_seen_at": prev["first_seen_at"] if prev else now_iso,
|
||||
"last_seen_at": now_iso,
|
||||
}
|
||||
return devices
|
||||
|
||||
|
||||
async def _resolve_vault_connector(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user: User,
|
||||
vault_id: str,
|
||||
) -> SearchSourceConnector:
|
||||
"""Find the OBSIDIAN_CONNECTOR row that owns ``vault_id`` for this user.
|
||||
|
||||
Looked up by the (user_id, connector_type, config['vault_id']) tuple
|
||||
so users can have multiple vaults each backed by its own connector
|
||||
row (one per search space).
|
||||
"""
|
||||
"""Find the OBSIDIAN_CONNECTOR row that owns ``vault_id`` for this user."""
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).where(
|
||||
and_(
|
||||
|
|
@ -136,12 +132,7 @@ async def _ensure_search_space_access(
|
|||
user: User,
|
||||
search_space_id: int,
|
||||
) -> SearchSpace:
|
||||
"""Confirm the user owns the requested search space.
|
||||
|
||||
Plugin currently does not support shared search spaces (RBAC roles)
|
||||
— that's a follow-up. Restricting to owner-only here keeps the
|
||||
surface narrow and avoids leaking other members' connectors.
|
||||
"""
|
||||
"""Owner-only access to the search space (shared spaces are a follow-up)."""
|
||||
result = await session.execute(
|
||||
select(SearchSpace).where(
|
||||
and_(SearchSpace.id == search_space_id, SearchSpace.user_id == user.id)
|
||||
|
|
@ -168,11 +159,7 @@ async def _ensure_search_space_access(
|
|||
async def obsidian_health(
|
||||
user: User = Depends(current_active_user),
|
||||
) -> HealthResponse:
|
||||
"""Return the API contract handshake.
|
||||
|
||||
The plugin calls this once per ``onload`` and caches the result for
|
||||
capability-gating decisions.
|
||||
"""
|
||||
"""Return the API contract handshake; plugin caches it per onload."""
|
||||
return HealthResponse(
|
||||
**_build_handshake(),
|
||||
server_time_utc=datetime.now(UTC),
|
||||
|
|
@ -187,9 +174,9 @@ async def obsidian_connect(
|
|||
) -> ConnectResponse:
|
||||
"""Register a vault, or return the existing connector row.
|
||||
|
||||
Idempotent on the (user_id, OBSIDIAN_CONNECTOR, vault_id) tuple so
|
||||
re-installing the plugin or reconnecting from a new device picks up
|
||||
the same connector — and therefore the same documents.
|
||||
Idempotent on (user_id, OBSIDIAN_CONNECTOR, vault_id). Called on every
|
||||
plugin onload as a heartbeat — upserts ``device_id`` into
|
||||
``config['devices']`` so the web UI can show a "Devices: N" tile.
|
||||
"""
|
||||
await _ensure_search_space_access(
|
||||
session, user=user, search_space_id=payload.search_space_id
|
||||
|
|
@ -215,27 +202,31 @@ async def obsidian_connect(
|
|||
|
||||
if existing is not None:
|
||||
cfg = dict(existing.config or {})
|
||||
devices = _upsert_device(cfg.get("devices"), payload.device_id, now_iso)
|
||||
cfg.update(
|
||||
{
|
||||
"vault_id": payload.vault_id,
|
||||
"vault_name": payload.vault_name,
|
||||
"source": "plugin",
|
||||
"plugin_version": payload.plugin_version,
|
||||
"device_id": payload.device_id,
|
||||
"devices": devices,
|
||||
"device_count": len(devices),
|
||||
"last_connect_at": now_iso,
|
||||
}
|
||||
)
|
||||
if payload.device_label:
|
||||
cfg["device_label"] = payload.device_label
|
||||
cfg.pop("legacy", None)
|
||||
cfg.pop("vault_path", None)
|
||||
existing.config = cfg
|
||||
# Re-stamp on every connect so vault renames in Obsidian propagate;
|
||||
# the web UI hides the Name input for Obsidian connectors.
|
||||
existing.name = f"Obsidian — {payload.vault_name}"
|
||||
existing.is_indexable = False
|
||||
existing.search_space_id = payload.search_space_id
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
connector = existing
|
||||
else:
|
||||
devices = _upsert_device(None, payload.device_id, now_iso)
|
||||
connector = SearchSourceConnector(
|
||||
name=f"Obsidian — {payload.vault_name}",
|
||||
connector_type=SearchSourceConnectorType.OBSIDIAN_CONNECTOR,
|
||||
|
|
@ -245,8 +236,8 @@ async def obsidian_connect(
|
|||
"vault_name": payload.vault_name,
|
||||
"source": "plugin",
|
||||
"plugin_version": payload.plugin_version,
|
||||
"device_id": payload.device_id,
|
||||
"device_label": payload.device_label,
|
||||
"devices": devices,
|
||||
"device_count": len(devices),
|
||||
"files_synced": 0,
|
||||
"last_connect_at": now_iso,
|
||||
},
|
||||
|
|
@ -271,11 +262,7 @@ async def obsidian_sync(
|
|||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> dict[str, object]:
|
||||
"""Batch-upsert notes pushed by the plugin.
|
||||
|
||||
Returns per-note ack so the plugin can dequeue successes and retry
|
||||
failures.
|
||||
"""
|
||||
"""Batch-upsert notes; returns per-note ack so the plugin can dequeue/retry."""
|
||||
connector = await _resolve_vault_connector(
|
||||
session, user=user, vault_id=payload.vault_id
|
||||
)
|
||||
|
|
@ -439,11 +426,7 @@ async def obsidian_manifest(
|
|||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> ManifestResponse:
|
||||
"""Return the server-side ``{path: {hash, mtime}}`` manifest.
|
||||
|
||||
Used by the plugin's ``onload`` reconcile to find files that were
|
||||
edited or deleted while the plugin was offline.
|
||||
"""
|
||||
"""Return ``{path: {hash, mtime}}`` for the plugin's onload reconcile diff."""
|
||||
connector = await _resolve_vault_connector(
|
||||
session, user=user, vault_id=vault_id
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,8 @@
|
|||
"""
|
||||
Obsidian Plugin connector schemas.
|
||||
"""Wire schemas spoken between the SurfSense Obsidian plugin and the backend.
|
||||
|
||||
Wire format spoken between the SurfSense Obsidian plugin
|
||||
(``surfsense_obsidian/``) and the FastAPI backend.
|
||||
|
||||
Stability contract
|
||||
------------------
|
||||
Every request and response schema sets ``model_config = ConfigDict(extra='ignore')``.
|
||||
This is the API stability contract — not just hygiene:
|
||||
|
||||
- Old plugins talking to a newer backend silently drop any new response fields
|
||||
they don't understand instead of failing validation.
|
||||
- New plugins talking to an older backend can include forward-looking request
|
||||
fields (e.g. attachments metadata) without the older backend rejecting them.
|
||||
|
||||
Hard breaking changes are reserved for the URL prefix (``/api/v2/...``).
|
||||
Additive evolution is signaled via the ``capabilities`` array on
|
||||
``HealthResponse`` / ``ConnectResponse`` — older plugins ignore unknown
|
||||
capability strings safely.
|
||||
All schemas inherit ``extra='ignore'`` from :class:`_PluginBase` so additive
|
||||
field changes never break either side; hard breaks live behind a new URL
|
||||
prefix (``/api/v2/...``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -31,22 +16,13 @@ _PLUGIN_MODEL_CONFIG = ConfigDict(extra="ignore")
|
|||
|
||||
|
||||
class _PluginBase(BaseModel):
|
||||
"""Base class for all plugin payload schemas.
|
||||
|
||||
Carries the forward-compatibility config so subclasses don't have to
|
||||
repeat it.
|
||||
"""
|
||||
"""Base schema carrying the shared forward-compatibility config."""
|
||||
|
||||
model_config = _PLUGIN_MODEL_CONFIG
|
||||
|
||||
|
||||
class NotePayload(_PluginBase):
|
||||
"""One Obsidian note as pushed by the plugin.
|
||||
|
||||
The plugin is the source of truth: ``content`` is the post-frontmatter
|
||||
body, ``frontmatter``/``tags``/``headings``/etc. are precomputed by the
|
||||
plugin via ``app.metadataCache`` so the backend doesn't have to re-parse.
|
||||
"""
|
||||
"""One Obsidian note as pushed by the plugin (the source of truth)."""
|
||||
|
||||
vault_id: str = Field(..., description="Stable plugin-generated UUID for this vault")
|
||||
path: str = Field(..., description="Vault-relative path, e.g. 'notes/foo.md'")
|
||||
|
|
@ -68,7 +44,7 @@ class NotePayload(_PluginBase):
|
|||
|
||||
|
||||
class SyncBatchRequest(_PluginBase):
|
||||
"""Batch upsert. Plugin sends 10-20 notes per request to amortize HTTP overhead."""
|
||||
"""Batch upsert; plugin sends 10-20 notes per request."""
|
||||
|
||||
vault_id: str
|
||||
notes: list[NotePayload] = Field(default_factory=list, max_length=100)
|
||||
|
|
@ -90,8 +66,6 @@ class DeleteBatchRequest(_PluginBase):
|
|||
|
||||
|
||||
class ManifestEntry(_PluginBase):
|
||||
"""One row of the server-side manifest used by the plugin to reconcile."""
|
||||
|
||||
hash: str
|
||||
mtime: datetime
|
||||
|
||||
|
|
@ -104,26 +78,18 @@ class ManifestResponse(_PluginBase):
|
|||
|
||||
|
||||
class ConnectRequest(_PluginBase):
|
||||
"""First-call handshake to register or look up a vault connector row."""
|
||||
"""Vault registration / heartbeat. Replayed on every plugin onload."""
|
||||
|
||||
vault_id: str
|
||||
vault_name: str
|
||||
search_space_id: int
|
||||
plugin_version: str
|
||||
device_id: str
|
||||
device_label: str | None = Field(
|
||||
default=None,
|
||||
description="User-friendly device name shown in the web UI (e.g. 'iPad Pro').",
|
||||
)
|
||||
|
||||
|
||||
class ConnectResponse(_PluginBase):
|
||||
"""Returned from POST /connect.
|
||||
|
||||
Carries the same handshake fields as ``HealthResponse`` so the plugin
|
||||
learns the contract on its very first call without an extra round-trip
|
||||
to ``GET /health``.
|
||||
"""
|
||||
"""Carries the same handshake fields as ``HealthResponse`` so the plugin
|
||||
learns the contract without a separate ``GET /health`` round-trip."""
|
||||
|
||||
connector_id: int
|
||||
vault_id: str
|
||||
|
|
@ -133,14 +99,7 @@ class ConnectResponse(_PluginBase):
|
|||
|
||||
|
||||
class HealthResponse(_PluginBase):
|
||||
"""API contract handshake.
|
||||
|
||||
The plugin calls ``GET /health`` once per ``onload`` and caches the
|
||||
result. ``capabilities`` is a forward-extensible string list: future
|
||||
additions (``'pat_auth'``, ``'scoped_pat'``, ``'attachments_v2'``,
|
||||
``'shared_search_spaces'``...) ship without breaking older plugins
|
||||
because they only enable extra behavior, never gate existing endpoints.
|
||||
"""
|
||||
"""API contract handshake. ``capabilities`` is additive-only string list."""
|
||||
|
||||
api_version: str
|
||||
capabilities: list[str]
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ export class SurfSenseApiClient {
|
|||
vaultId: string;
|
||||
vaultName: string;
|
||||
deviceId: string;
|
||||
deviceLabel: string;
|
||||
}): Promise<ConnectResponse> {
|
||||
return await this.request<ConnectResponse>(
|
||||
"POST",
|
||||
|
|
@ -117,7 +116,6 @@ export class SurfSenseApiClient {
|
|||
vault_name: input.vaultName,
|
||||
plugin_version: this.opts.pluginVersion,
|
||||
device_id: input.deviceId,
|
||||
device_label: input.deviceLabel,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,28 +11,18 @@ import {
|
|||
type SurfsensePluginSettings,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* SurfSense plugin entry point.
|
||||
*
|
||||
* Replaces the obsidian-sample-plugin SampleModal/ribbon stub. Lifecycle:
|
||||
*
|
||||
* onload():
|
||||
* load settings → seed identity (vault_id, device_id) →
|
||||
* wire api client + queue + sync engine + status bar →
|
||||
* register settings tab → register vault + metadataCache events →
|
||||
* register commands (resync, sync current note, open settings) →
|
||||
* register status bar item →
|
||||
* kick off engine.start() (health → drain → reconcile).
|
||||
*
|
||||
* onunload():
|
||||
* stop the queue's debounce timer; unregistered events and DOM
|
||||
* handles auto-clean via the Plugin base class.
|
||||
*/
|
||||
/** SurfSense plugin entry point. */
|
||||
export default class SurfSensePlugin extends Plugin {
|
||||
settings!: SurfsensePluginSettings;
|
||||
api!: SurfSenseApiClient;
|
||||
queue!: PersistentQueue;
|
||||
engine!: SyncEngine;
|
||||
/**
|
||||
* Per-install identifier kept in `app.saveLocalStorage` rather than
|
||||
* `data.json`, so it does NOT travel through Obsidian Sync — each
|
||||
* machine on a synced vault stays distinguishable.
|
||||
*/
|
||||
deviceId = "";
|
||||
private statusBar: StatusBar | null = null;
|
||||
lastStatus: StatusState = { kind: "idle", queueDepth: 0 };
|
||||
serverCapabilities: string[] = [];
|
||||
|
|
@ -69,6 +59,7 @@ export default class SurfSensePlugin extends Plugin {
|
|||
await this.saveSettings();
|
||||
this.settingTab?.renderStatus();
|
||||
},
|
||||
getDeviceId: () => this.deviceId,
|
||||
setStatus: (s) => {
|
||||
this.lastStatus = s;
|
||||
this.statusBar?.update(s);
|
||||
|
|
@ -143,8 +134,7 @@ export default class SurfSensePlugin extends Plugin {
|
|||
id: "open-settings",
|
||||
name: "Open settings",
|
||||
callback: () => {
|
||||
// Obsidian exposes this through the Setting host on the workspace;
|
||||
// fall back silently if the API moves so we never throw.
|
||||
// `app.setting` isn't in the d.ts; fall back silently if it moves.
|
||||
type SettingHost = {
|
||||
open?: () => void;
|
||||
openTabById?: (id: string) => void;
|
||||
|
|
@ -155,8 +145,7 @@ export default class SurfSensePlugin extends Plugin {
|
|||
},
|
||||
});
|
||||
|
||||
// Kick off the start sequence after Obsidian finishes its own
|
||||
// startup work, so the metadataCache is warm before reconcile.
|
||||
// Wait for layout so the metadataCache is warm before reconcile.
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
void this.engine.start();
|
||||
});
|
||||
|
|
@ -188,13 +177,28 @@ export default class SurfSensePlugin extends Plugin {
|
|||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint vault_id (in data.json, travels with the vault) and device_id
|
||||
* (in `app.saveLocalStorage`, stays per-install) on first run.
|
||||
*/
|
||||
private seedIdentity(): void {
|
||||
if (!this.settings.vaultId) {
|
||||
this.settings.vaultId = generateUuid();
|
||||
}
|
||||
if (!this.settings.deviceId) {
|
||||
this.settings.deviceId = generateUuid();
|
||||
|
||||
// loadLocalStorage / saveLocalStorage aren't in the d.ts; cast at the boundary.
|
||||
const localStore = this.app as unknown as {
|
||||
loadLocalStorage: (key: string) => string | null;
|
||||
saveLocalStorage: (key: string, value: string | null) => void;
|
||||
};
|
||||
const storageKey = "surfsense:deviceId";
|
||||
let deviceId = localStore.loadLocalStorage(storageKey);
|
||||
if (!deviceId) {
|
||||
deviceId = generateUuid();
|
||||
localStore.saveLocalStorage(storageKey, deviceId);
|
||||
}
|
||||
this.deviceId = deviceId;
|
||||
|
||||
if (!this.settings.vaultName) {
|
||||
this.settings.vaultName = this.app.vault.getName();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,22 +9,7 @@ import { parseExcludePatterns } from "./excludes";
|
|||
import type SurfSensePlugin from "./main";
|
||||
import type { SearchSpace } from "./types";
|
||||
|
||||
/**
|
||||
* Plugin settings tab.
|
||||
*
|
||||
* Replaces the obsidian-sample-plugin SampleSettingTab stub. Same module
|
||||
* path so existing imports from main.ts keep resolving.
|
||||
*
|
||||
* Surface mirrors the per-plan list:
|
||||
* server URL · api token · search space · vault name · sync mode ·
|
||||
* exclude patterns · include attachments · status panel.
|
||||
*
|
||||
* Vault id, device id, and device label are auto-generated UUIDs the
|
||||
* first time settings load — they're displayed (read-only) so users can
|
||||
* audit them, but never editable. Vault id is decoupled from the OS
|
||||
* folder name so renaming the vault doesn't invalidate the connector
|
||||
* (edge case #5 from the plan).
|
||||
*/
|
||||
/** Plugin settings tab. */
|
||||
|
||||
export class SurfSenseSettingTab extends PluginSettingTab {
|
||||
private readonly plugin: SurfSensePlugin;
|
||||
|
|
@ -151,21 +136,6 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
|||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Device label")
|
||||
.setDesc(
|
||||
"Optional human-readable label shown next to the device ID in the Surfsense web app.",
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("My laptop")
|
||||
.setValue(settings.deviceLabel)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.deviceLabel = value.trim();
|
||||
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.")
|
||||
|
|
@ -214,19 +184,16 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
|||
|
||||
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.")
|
||||
.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);
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Device ID")
|
||||
.setDesc("Stable identifier for this install. Used by the backend so you can revoke a single device without disconnecting the others.")
|
||||
.addText((text) => {
|
||||
text.inputEl.disabled = true;
|
||||
text.setValue(settings.deviceId);
|
||||
});
|
||||
// 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" });
|
||||
|
|
|
|||
|
|
@ -19,20 +19,8 @@ import type {
|
|||
/**
|
||||
* Owner of "what does the vault look like vs the server" reasoning.
|
||||
*
|
||||
* Onload sequence (per plan §p4_plugin_sync_engine, in this exact order):
|
||||
* 1. apiClient.health() — proves connectivity and pulls the capabilities
|
||||
* handshake before we issue any sync traffic.
|
||||
* 2. Cache health.capabilities + api_version on the plugin instance
|
||||
* so feature gating (e.g. "attachments_v2" before syncing binaries)
|
||||
* reads from local state instead of round-tripping.
|
||||
* 3. Drain queue — items persisted from the previous session land first.
|
||||
* 4. Reconcile — GET /manifest, diff against vault, queue uploads/deletes.
|
||||
* 5. Subscribe events — only after the above so the user's first edit
|
||||
* after launching Obsidian doesn't race with the manifest diff.
|
||||
*
|
||||
* Reconcile skips itself if last successful reconcile is < RECONCILE_MIN_INTERVAL_MS
|
||||
* ago. ConnectResponse already carries handshake fields so first connect
|
||||
* does not need a separate /health round-trip.
|
||||
* Start order: connect (or fall back to /health) → drain queue → reconcile →
|
||||
* subscribe events. Reconcile no-ops if last run was < RECONCILE_MIN_INTERVAL_MS ago.
|
||||
*/
|
||||
|
||||
export interface SyncEngineDeps {
|
||||
|
|
@ -41,6 +29,8 @@ export interface SyncEngineDeps {
|
|||
queue: PersistentQueue;
|
||||
getSettings: () => SyncEngineSettings;
|
||||
saveSettings: (mut: (s: SyncEngineSettings) => void) => Promise<void>;
|
||||
/** Per-install id sourced from app.saveLocalStorage (not synced data.json). */
|
||||
getDeviceId: () => string;
|
||||
setStatus: (s: StatusState) => void;
|
||||
onCapabilities: (caps: string[], apiVersion: string) => void;
|
||||
}
|
||||
|
|
@ -50,8 +40,6 @@ export interface SyncEngineSettings {
|
|||
vaultName: string;
|
||||
connectorId: number | null;
|
||||
searchSpaceId: number | null;
|
||||
deviceId: string;
|
||||
deviceLabel: string;
|
||||
excludePatterns: string[];
|
||||
includeAttachments: boolean;
|
||||
syncMode: "auto" | "manual";
|
||||
|
|
@ -86,22 +74,27 @@ export class SyncEngine {
|
|||
/** Run the onload sequence described in this file's docstring. */
|
||||
async start(): Promise<void> {
|
||||
this.setStatus("syncing", "Connecting to SurfSense…");
|
||||
try {
|
||||
const health = await this.deps.apiClient.health();
|
||||
this.applyHealth(health);
|
||||
} catch (err) {
|
||||
this.handleStartupError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = this.deps.getSettings();
|
||||
if (!settings.connectorId || !settings.searchSpaceId) {
|
||||
// No connector yet — settings tab will trigger ensureConnect once
|
||||
// the user picks a search space, then re-call start().
|
||||
if (!settings.searchSpaceId) {
|
||||
// No target yet — bare /health probe still surfaces auth/network errors.
|
||||
try {
|
||||
const health = await this.deps.apiClient.health();
|
||||
this.applyHealth(health);
|
||||
} catch (err) {
|
||||
this.handleStartupError(err);
|
||||
return;
|
||||
}
|
||||
this.setStatus("idle", "Pick a search space in settings to start syncing.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-announce on every load: /connect doubles as the device heartbeat
|
||||
// that bumps last_seen_at and powers the "Devices: N" tile in the web UI.
|
||||
await this.ensureConnected();
|
||||
|
||||
if (!this.deps.getSettings().connectorId) return;
|
||||
|
||||
await this.flushQueue();
|
||||
await this.maybeReconcile();
|
||||
this.setStatus(this.queueStatusKind(), undefined);
|
||||
|
|
@ -119,8 +112,7 @@ export class SyncEngine {
|
|||
searchSpaceId: settings.searchSpaceId,
|
||||
vaultId: settings.vaultId,
|
||||
vaultName: settings.vaultName,
|
||||
deviceId: settings.deviceId,
|
||||
deviceLabel: settings.deviceLabel,
|
||||
deviceId: this.deps.getDeviceId(),
|
||||
});
|
||||
this.applyHealth(resp);
|
||||
await this.deps.saveSettings((s) => {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,15 @@
|
|||
/**
|
||||
* Shared types for the SurfSense Obsidian plugin.
|
||||
*
|
||||
* Kept in a leaf module with no other src/ imports so it can be imported
|
||||
* from anywhere (settings, api-client, sync-engine, status-bar, main)
|
||||
* without creating cycles.
|
||||
*/
|
||||
/** Shared types for the SurfSense Obsidian plugin. Leaf module — no src/ imports. */
|
||||
|
||||
export interface SurfsensePluginSettings {
|
||||
serverUrl: string;
|
||||
apiToken: string;
|
||||
searchSpaceId: number | null;
|
||||
connectorId: number | null;
|
||||
/** UUID for the vault — lives here so Obsidian Sync replicates it across devices. */
|
||||
vaultId: string;
|
||||
vaultName: string;
|
||||
deviceId: string;
|
||||
deviceLabel: string;
|
||||
// Per-install deviceId is NOT in this interface on purpose: it lives in
|
||||
// app.saveLocalStorage so it stays distinct on each device. See seedIdentity().
|
||||
syncMode: "auto" | "manual";
|
||||
excludePatterns: string[];
|
||||
includeAttachments: boolean;
|
||||
|
|
@ -32,8 +27,6 @@ export const DEFAULT_SETTINGS: SurfsensePluginSettings = {
|
|||
connectorId: null,
|
||||
vaultId: "",
|
||||
vaultName: "",
|
||||
deviceId: "",
|
||||
deviceLabel: "",
|
||||
syncMode: "auto",
|
||||
excludePatterns: [".trash", "_attachments", "templates"],
|
||||
includeAttachments: false,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, Check, Copy, Download, Info } from "lucide-react";
|
||||
import { type FC, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, Download, Info } from "lucide-react";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useApiKey } from "@/hooks/use-api-key";
|
||||
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface ObsidianConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
const PLUGIN_RELEASES_URL =
|
||||
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
|
||||
|
||||
|
|
@ -27,55 +19,32 @@ function formatTimestamp(value: unknown): string {
|
|||
/**
|
||||
* Obsidian connector config view.
|
||||
*
|
||||
* Renders one of two modes depending on the connector's `config`:
|
||||
* Read-only on purpose: the plugin owns vault identity, so the connector's
|
||||
* display name is auto-derived from `payload.vault_name` server-side on
|
||||
* every `/connect` (see `obsidian_plugin_routes.obsidian_connect`). The
|
||||
* web UI doesn't expose a Name input or a Save button for Obsidian (the
|
||||
* latter is suppressed in `connector-edit-view.tsx`).
|
||||
*
|
||||
* Renders one of three modes depending on the connector's `config`:
|
||||
*
|
||||
* 1. **Plugin connector** (`config.source === "plugin"`) — read-only stats
|
||||
* panel showing what the plugin most recently reported.
|
||||
* 2. **Legacy server-path connector** (`config.legacy === true`, set by the
|
||||
* Phase 3 alembic) — migration banner plus an "Install Plugin" CTA.
|
||||
* The user's existing notes stay searchable; only background sync stops.
|
||||
* Phase 3 alembic) — migration banner, an "Install Plugin" CTA, and a
|
||||
* short "how to migrate" checklist that ends with the user pressing the
|
||||
* standard Disconnect button (which deletes this connector along with
|
||||
* every document it previously indexed).
|
||||
* 3. **Unknown** — fallback for rows that escaped the alembic; suggests a
|
||||
* clean re-install.
|
||||
*/
|
||||
export const ObsidianConfig: FC<ObsidianConfigProps> = ({
|
||||
connector,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
export const ObsidianConfig: FC<ConnectorConfigProps> = ({ connector }) => {
|
||||
const config = (connector.config ?? {}) as Record<string, unknown>;
|
||||
const isLegacy = config.legacy === true;
|
||||
const isPlugin = config.source === "plugin";
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
onNameChange?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector name (always editable) */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 space-y-3 sm:space-y-4 dark:bg-white/5">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Obsidian Vault"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLegacy ? (
|
||||
<LegacyBanner />
|
||||
) : isPlugin ? (
|
||||
<PluginStats config={config} />
|
||||
) : (
|
||||
<UnknownConnectorState />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (isLegacy) return <LegacyBanner />;
|
||||
if (isPlugin) return <PluginStats config={config} />;
|
||||
return <UnknownConnectorState />;
|
||||
};
|
||||
|
||||
const LegacyBanner: FC = () => {
|
||||
|
|
@ -84,14 +53,12 @@ const LegacyBanner: FC = () => {
|
|||
<Alert className="border-amber-500/40 bg-amber-500/10">
|
||||
<AlertTriangle className="size-4 shrink-0 text-amber-500" />
|
||||
<AlertTitle className="text-xs sm:text-sm">
|
||||
This connector has been migrated
|
||||
Sync stopped — install the plugin to migrate
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-[11px] sm:text-xs leading-relaxed">
|
||||
This Obsidian connector used the legacy server-path method, which has
|
||||
been removed. To resume syncing, install the SurfSense Obsidian
|
||||
plugin and connect with this account. Your existing notes remain
|
||||
searchable. After the plugin re-indexes your vault, you can delete
|
||||
this connector to remove older copies.
|
||||
This Obsidian connector used the legacy server-path scanner, which has been removed. The
|
||||
notes already indexed remain searchable, but they no longer reflect changes made in your
|
||||
vault.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
|
@ -107,7 +74,25 @@ const LegacyBanner: FC = () => {
|
|||
</Button>
|
||||
</a>
|
||||
|
||||
<ApiKeyReminder />
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
|
||||
<h3 className="mb-3 text-sm font-medium sm:text-base">How to migrate</h3>
|
||||
<ol className="list-decimal space-y-2 pl-5 text-[11px] leading-relaxed text-muted-foreground sm:text-xs">
|
||||
<li>Install the SurfSense Obsidian plugin using the button above.</li>
|
||||
<li>
|
||||
In Obsidian, open Settings → SurfSense, sign in, pick a search space, and wait for the
|
||||
first sync to finish.
|
||||
</li>
|
||||
<li>
|
||||
Confirm the new "Obsidian — <vault>" connector shows your notes, then return here
|
||||
and use the Disconnect button below to remove this legacy connector.
|
||||
</li>
|
||||
</ol>
|
||||
<p className="mt-3 text-[11px] leading-relaxed text-amber-600 dark:text-amber-400 sm:text-xs">
|
||||
Heads up: Disconnect also deletes every document this connector previously indexed. Make
|
||||
sure the plugin has finished its first sync before you disconnect, otherwise your Obsidian
|
||||
notes will disappear from search until the plugin re-indexes them.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -115,6 +100,14 @@ const LegacyBanner: FC = () => {
|
|||
const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
|
||||
const stats: { label: string; value: string }[] = useMemo(() => {
|
||||
const filesSynced = config.files_synced;
|
||||
// Prefer the stamped count; fall back to len(devices) for rows the
|
||||
// backend hasn't re-stamped yet.
|
||||
const deviceCount =
|
||||
typeof config.device_count === "number"
|
||||
? config.device_count
|
||||
: config.devices && typeof config.devices === "object"
|
||||
? Object.keys(config.devices as Record<string, unknown>).length
|
||||
: null;
|
||||
return [
|
||||
{ label: "Vault", value: (config.vault_name as string) || "—" },
|
||||
{
|
||||
|
|
@ -122,11 +115,8 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
|
|||
value: (config.plugin_version as string) || "—",
|
||||
},
|
||||
{
|
||||
label: "Device",
|
||||
value:
|
||||
(config.device_label as string) ||
|
||||
(config.device_id as string) ||
|
||||
"—",
|
||||
label: "Devices",
|
||||
value: deviceCount !== null ? deviceCount.toLocaleString() : "—",
|
||||
},
|
||||
{
|
||||
label: "Last sync",
|
||||
|
|
@ -134,8 +124,7 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
|
|||
},
|
||||
{
|
||||
label: "Files synced",
|
||||
value:
|
||||
typeof filesSynced === "number" ? filesSynced.toLocaleString() : "—",
|
||||
value: typeof filesSynced === "number" ? filesSynced.toLocaleString() : "—",
|
||||
},
|
||||
];
|
||||
}, [config]);
|
||||
|
|
@ -146,8 +135,8 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
|
|||
<Info className="size-4 shrink-0 text-emerald-500" />
|
||||
<AlertTitle className="text-xs sm:text-sm">Plugin connected</AlertTitle>
|
||||
<AlertDescription className="text-[11px] sm:text-xs">
|
||||
Edits in Obsidian sync over HTTPS. To stop syncing, disable or
|
||||
uninstall the plugin in Obsidian, or delete this connector.
|
||||
Edits in Obsidian sync over HTTPS. To stop syncing, disable or uninstall the plugin in
|
||||
Obsidian, or delete this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
|
@ -162,9 +151,7 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
|
|||
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||
{stat.label}
|
||||
</dt>
|
||||
<dd className="mt-1 truncate text-xs font-medium sm:text-sm">
|
||||
{stat.value}
|
||||
</dd>
|
||||
<dd className="mt-1 truncate text-xs font-medium sm:text-sm">{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
|
@ -178,98 +165,8 @@ const UnknownConnectorState: FC = () => (
|
|||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">Unrecognized config</AlertTitle>
|
||||
<AlertDescription className="text-[11px] sm:text-xs">
|
||||
This connector has neither plugin metadata nor a legacy marker. It may
|
||||
predate the migration — you can safely delete it and re-install the
|
||||
SurfSense Obsidian plugin to resume syncing.
|
||||
This connector has neither plugin metadata nor a legacy marker. It may predate the migration —
|
||||
you can safely delete it and re-install the SurfSense Obsidian plugin to resume syncing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
const ApiKeyReminder: FC = () => {
|
||||
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
|
||||
const [copiedUrl, setCopiedUrl] = useState(false);
|
||||
const urlCopyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const backendUrl =
|
||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "https://api.surfsense.com";
|
||||
|
||||
const copyServerUrl = useCallback(async () => {
|
||||
const ok = await copyToClipboardUtil(backendUrl);
|
||||
if (!ok) return;
|
||||
setCopiedUrl(true);
|
||||
if (urlCopyTimerRef.current) clearTimeout(urlCopyTimerRef.current);
|
||||
urlCopyTimerRef.current = setTimeout(() => setCopiedUrl(false), 2000);
|
||||
}, [backendUrl]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 space-y-3 dark:bg-white/5">
|
||||
<h3 className="text-sm font-medium sm:text-base">
|
||||
Plugin connection details
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground sm:text-xs">
|
||||
Paste these into the plugin's settings inside Obsidian.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">API token</Label>
|
||||
{isLoading ? (
|
||||
<div className="h-9 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
|
||||
<p className="cursor-text select-all whitespace-nowrap font-mono text-[10px] text-muted-foreground">
|
||||
{apiKey || "No API key available"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
disabled={!apiKey}
|
||||
className="size-7 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={copied ? "Copied" : "Copy API key"}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
Token expires after 24 hours; long-lived tokens are coming in a
|
||||
future release.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Server URL</Label>
|
||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
|
||||
<p className="cursor-text select-all whitespace-nowrap font-mono text-[10px] text-muted-foreground">
|
||||
{backendUrl}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={copyServerUrl}
|
||||
className="size-7 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={copiedUrl ? "Copied" : "Copy server URL"}
|
||||
>
|
||||
{copiedUrl ? (
|
||||
<Check className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -87,6 +87,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
const isAuthExpired = connector.config?.auth_expired === true;
|
||||
const reauthEndpoint = REAUTH_ENDPOINTS[connector.connector_type];
|
||||
const [reauthing, setReauthing] = useState(false);
|
||||
// Obsidian is plugin-driven: name + config are owned by the plugin, so
|
||||
// the web edit view has nothing the user can persist back. Hide Save
|
||||
// (and re-auth, which Obsidian never uses) entirely for that type.
|
||||
const isPluginManagedReadOnly = connector.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR;
|
||||
|
||||
const handleReauth = useCallback(async () => {
|
||||
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
|
||||
|
|
@ -412,7 +416,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
{isAuthExpired && reauthEndpoint ? (
|
||||
{isPluginManagedReadOnly ? null : isAuthExpired && reauthEndpoint ? (
|
||||
<Button
|
||||
onClick={handleReauth}
|
||||
disabled={reauthing || isDisconnecting}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue