mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-24 21:38:09 +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 (``/api/v1/obsidian/*``).
|
||||||
Obsidian plugin ingestion routes.
|
|
||||||
|
|
||||||
This is the public surface that the SurfSense Obsidian plugin
|
Wire surface for the ``surfsense_obsidian/`` plugin. API stability is the
|
||||||
(``surfsense_obsidian/``) speaks to. It is a separate router from the
|
``/api/v1/`` prefix plus the additive ``capabilities`` array on /health;
|
||||||
legacy server-path Obsidian connector — the legacy code stays in place
|
no plugin-version gate.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -67,14 +44,10 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/obsidian", tags=["obsidian-plugin"])
|
router = APIRouter(prefix="/obsidian", tags=["obsidian-plugin"])
|
||||||
|
|
||||||
|
|
||||||
# Bumped manually whenever the wire contract gains a non-additive change.
|
# Bumped only on non-additive wire changes; additive ones ride extra='ignore'.
|
||||||
# Additive (extra='ignore'-safe) changes do NOT bump this.
|
|
||||||
OBSIDIAN_API_VERSION = "1"
|
OBSIDIAN_API_VERSION = "1"
|
||||||
|
|
||||||
# Capabilities advertised on /health and /connect. Plugins use this list
|
# Plugins feature-gate on these. Add entries, never rename or remove.
|
||||||
# for feature gating ("does this server understand attachments_v2?"). Add
|
|
||||||
# new strings, never rename/remove existing ones — older plugins ignore
|
|
||||||
# unknown entries safely.
|
|
||||||
OBSIDIAN_CAPABILITIES: list[str] = ["sync", "rename", "delete", "manifest"]
|
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(
|
async def _resolve_vault_connector(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
user: User,
|
user: User,
|
||||||
vault_id: str,
|
vault_id: str,
|
||||||
) -> SearchSourceConnector:
|
) -> SearchSourceConnector:
|
||||||
"""Find the OBSIDIAN_CONNECTOR row that owns ``vault_id`` for this user.
|
"""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).
|
|
||||||
"""
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(SearchSourceConnector).where(
|
select(SearchSourceConnector).where(
|
||||||
and_(
|
and_(
|
||||||
|
|
@ -136,12 +132,7 @@ async def _ensure_search_space_access(
|
||||||
user: User,
|
user: User,
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
) -> SearchSpace:
|
) -> SearchSpace:
|
||||||
"""Confirm the user owns the requested search space.
|
"""Owner-only access to the search space (shared spaces are a follow-up)."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(SearchSpace).where(
|
select(SearchSpace).where(
|
||||||
and_(SearchSpace.id == search_space_id, SearchSpace.user_id == user.id)
|
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(
|
async def obsidian_health(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
) -> HealthResponse:
|
) -> HealthResponse:
|
||||||
"""Return the API contract handshake.
|
"""Return the API contract handshake; plugin caches it per onload."""
|
||||||
|
|
||||||
The plugin calls this once per ``onload`` and caches the result for
|
|
||||||
capability-gating decisions.
|
|
||||||
"""
|
|
||||||
return HealthResponse(
|
return HealthResponse(
|
||||||
**_build_handshake(),
|
**_build_handshake(),
|
||||||
server_time_utc=datetime.now(UTC),
|
server_time_utc=datetime.now(UTC),
|
||||||
|
|
@ -187,9 +174,9 @@ async def obsidian_connect(
|
||||||
) -> ConnectResponse:
|
) -> ConnectResponse:
|
||||||
"""Register a vault, or return the existing connector row.
|
"""Register a vault, or return the existing connector row.
|
||||||
|
|
||||||
Idempotent on the (user_id, OBSIDIAN_CONNECTOR, vault_id) tuple so
|
Idempotent on (user_id, OBSIDIAN_CONNECTOR, vault_id). Called on every
|
||||||
re-installing the plugin or reconnecting from a new device picks up
|
plugin onload as a heartbeat — upserts ``device_id`` into
|
||||||
the same connector — and therefore the same documents.
|
``config['devices']`` so the web UI can show a "Devices: N" tile.
|
||||||
"""
|
"""
|
||||||
await _ensure_search_space_access(
|
await _ensure_search_space_access(
|
||||||
session, user=user, search_space_id=payload.search_space_id
|
session, user=user, search_space_id=payload.search_space_id
|
||||||
|
|
@ -215,27 +202,31 @@ async def obsidian_connect(
|
||||||
|
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
cfg = dict(existing.config or {})
|
cfg = dict(existing.config or {})
|
||||||
|
devices = _upsert_device(cfg.get("devices"), payload.device_id, now_iso)
|
||||||
cfg.update(
|
cfg.update(
|
||||||
{
|
{
|
||||||
"vault_id": payload.vault_id,
|
"vault_id": payload.vault_id,
|
||||||
"vault_name": payload.vault_name,
|
"vault_name": payload.vault_name,
|
||||||
"source": "plugin",
|
"source": "plugin",
|
||||||
"plugin_version": payload.plugin_version,
|
"plugin_version": payload.plugin_version,
|
||||||
"device_id": payload.device_id,
|
"devices": devices,
|
||||||
|
"device_count": len(devices),
|
||||||
"last_connect_at": now_iso,
|
"last_connect_at": now_iso,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if payload.device_label:
|
|
||||||
cfg["device_label"] = payload.device_label
|
|
||||||
cfg.pop("legacy", None)
|
cfg.pop("legacy", None)
|
||||||
cfg.pop("vault_path", None)
|
cfg.pop("vault_path", None)
|
||||||
existing.config = cfg
|
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.is_indexable = False
|
||||||
existing.search_space_id = payload.search_space_id
|
existing.search_space_id = payload.search_space_id
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(existing)
|
await session.refresh(existing)
|
||||||
connector = existing
|
connector = existing
|
||||||
else:
|
else:
|
||||||
|
devices = _upsert_device(None, payload.device_id, now_iso)
|
||||||
connector = SearchSourceConnector(
|
connector = SearchSourceConnector(
|
||||||
name=f"Obsidian — {payload.vault_name}",
|
name=f"Obsidian — {payload.vault_name}",
|
||||||
connector_type=SearchSourceConnectorType.OBSIDIAN_CONNECTOR,
|
connector_type=SearchSourceConnectorType.OBSIDIAN_CONNECTOR,
|
||||||
|
|
@ -245,8 +236,8 @@ async def obsidian_connect(
|
||||||
"vault_name": payload.vault_name,
|
"vault_name": payload.vault_name,
|
||||||
"source": "plugin",
|
"source": "plugin",
|
||||||
"plugin_version": payload.plugin_version,
|
"plugin_version": payload.plugin_version,
|
||||||
"device_id": payload.device_id,
|
"devices": devices,
|
||||||
"device_label": payload.device_label,
|
"device_count": len(devices),
|
||||||
"files_synced": 0,
|
"files_synced": 0,
|
||||||
"last_connect_at": now_iso,
|
"last_connect_at": now_iso,
|
||||||
},
|
},
|
||||||
|
|
@ -271,11 +262,7 @@ async def obsidian_sync(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Batch-upsert notes pushed by the plugin.
|
"""Batch-upsert notes; returns per-note ack so the plugin can dequeue/retry."""
|
||||||
|
|
||||||
Returns per-note ack so the plugin can dequeue successes and retry
|
|
||||||
failures.
|
|
||||||
"""
|
|
||||||
connector = await _resolve_vault_connector(
|
connector = await _resolve_vault_connector(
|
||||||
session, user=user, vault_id=payload.vault_id
|
session, user=user, vault_id=payload.vault_id
|
||||||
)
|
)
|
||||||
|
|
@ -439,11 +426,7 @@ async def obsidian_manifest(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> ManifestResponse:
|
) -> ManifestResponse:
|
||||||
"""Return the server-side ``{path: {hash, mtime}}`` manifest.
|
"""Return ``{path: {hash, mtime}}`` for the plugin's onload reconcile diff."""
|
||||||
|
|
||||||
Used by the plugin's ``onload`` reconcile to find files that were
|
|
||||||
edited or deleted while the plugin was offline.
|
|
||||||
"""
|
|
||||||
connector = await _resolve_vault_connector(
|
connector = await _resolve_vault_connector(
|
||||||
session, user=user, vault_id=vault_id
|
session, user=user, vault_id=vault_id
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,8 @@
|
||||||
"""
|
"""Wire schemas spoken between the SurfSense Obsidian plugin and the backend.
|
||||||
Obsidian Plugin connector schemas.
|
|
||||||
|
|
||||||
Wire format spoken between the SurfSense Obsidian plugin
|
All schemas inherit ``extra='ignore'`` from :class:`_PluginBase` so additive
|
||||||
(``surfsense_obsidian/``) and the FastAPI backend.
|
field changes never break either side; hard breaks live behind a new URL
|
||||||
|
prefix (``/api/v2/...``).
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -31,22 +16,13 @@ _PLUGIN_MODEL_CONFIG = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
class _PluginBase(BaseModel):
|
class _PluginBase(BaseModel):
|
||||||
"""Base class for all plugin payload schemas.
|
"""Base schema carrying the shared forward-compatibility config."""
|
||||||
|
|
||||||
Carries the forward-compatibility config so subclasses don't have to
|
|
||||||
repeat it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = _PLUGIN_MODEL_CONFIG
|
model_config = _PLUGIN_MODEL_CONFIG
|
||||||
|
|
||||||
|
|
||||||
class NotePayload(_PluginBase):
|
class NotePayload(_PluginBase):
|
||||||
"""One Obsidian note as pushed by the plugin.
|
"""One Obsidian note as pushed by the plugin (the source of truth)."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
vault_id: str = Field(..., description="Stable plugin-generated UUID for this vault")
|
vault_id: str = Field(..., description="Stable plugin-generated UUID for this vault")
|
||||||
path: str = Field(..., description="Vault-relative path, e.g. 'notes/foo.md'")
|
path: str = Field(..., description="Vault-relative path, e.g. 'notes/foo.md'")
|
||||||
|
|
@ -68,7 +44,7 @@ class NotePayload(_PluginBase):
|
||||||
|
|
||||||
|
|
||||||
class SyncBatchRequest(_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
|
vault_id: str
|
||||||
notes: list[NotePayload] = Field(default_factory=list, max_length=100)
|
notes: list[NotePayload] = Field(default_factory=list, max_length=100)
|
||||||
|
|
@ -90,8 +66,6 @@ class DeleteBatchRequest(_PluginBase):
|
||||||
|
|
||||||
|
|
||||||
class ManifestEntry(_PluginBase):
|
class ManifestEntry(_PluginBase):
|
||||||
"""One row of the server-side manifest used by the plugin to reconcile."""
|
|
||||||
|
|
||||||
hash: str
|
hash: str
|
||||||
mtime: datetime
|
mtime: datetime
|
||||||
|
|
||||||
|
|
@ -104,26 +78,18 @@ class ManifestResponse(_PluginBase):
|
||||||
|
|
||||||
|
|
||||||
class ConnectRequest(_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_id: str
|
||||||
vault_name: str
|
vault_name: str
|
||||||
search_space_id: int
|
search_space_id: int
|
||||||
plugin_version: str
|
plugin_version: str
|
||||||
device_id: 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):
|
class ConnectResponse(_PluginBase):
|
||||||
"""Returned from POST /connect.
|
"""Carries the same handshake fields as ``HealthResponse`` so the plugin
|
||||||
|
learns the contract without a separate ``GET /health`` round-trip."""
|
||||||
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``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
connector_id: int
|
connector_id: int
|
||||||
vault_id: str
|
vault_id: str
|
||||||
|
|
@ -133,14 +99,7 @@ class ConnectResponse(_PluginBase):
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(_PluginBase):
|
class HealthResponse(_PluginBase):
|
||||||
"""API contract handshake.
|
"""API contract handshake. ``capabilities`` is additive-only string list."""
|
||||||
|
|
||||||
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_version: str
|
api_version: str
|
||||||
capabilities: list[str]
|
capabilities: list[str]
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,6 @@ export class SurfSenseApiClient {
|
||||||
vaultId: string;
|
vaultId: string;
|
||||||
vaultName: string;
|
vaultName: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
deviceLabel: string;
|
|
||||||
}): Promise<ConnectResponse> {
|
}): Promise<ConnectResponse> {
|
||||||
return await this.request<ConnectResponse>(
|
return await this.request<ConnectResponse>(
|
||||||
"POST",
|
"POST",
|
||||||
|
|
@ -117,7 +116,6 @@ export class SurfSenseApiClient {
|
||||||
vault_name: input.vaultName,
|
vault_name: input.vaultName,
|
||||||
plugin_version: this.opts.pluginVersion,
|
plugin_version: this.opts.pluginVersion,
|
||||||
device_id: input.deviceId,
|
device_id: input.deviceId,
|
||||||
device_label: input.deviceLabel,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,28 +11,18 @@ import {
|
||||||
type SurfsensePluginSettings,
|
type SurfsensePluginSettings,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/** SurfSense plugin entry point. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export default class SurfSensePlugin extends Plugin {
|
export default class SurfSensePlugin extends Plugin {
|
||||||
settings!: SurfsensePluginSettings;
|
settings!: SurfsensePluginSettings;
|
||||||
api!: SurfSenseApiClient;
|
api!: SurfSenseApiClient;
|
||||||
queue!: PersistentQueue;
|
queue!: PersistentQueue;
|
||||||
engine!: SyncEngine;
|
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;
|
private statusBar: StatusBar | null = null;
|
||||||
lastStatus: StatusState = { kind: "idle", queueDepth: 0 };
|
lastStatus: StatusState = { kind: "idle", queueDepth: 0 };
|
||||||
serverCapabilities: string[] = [];
|
serverCapabilities: string[] = [];
|
||||||
|
|
@ -69,6 +59,7 @@ export default class SurfSensePlugin extends Plugin {
|
||||||
await this.saveSettings();
|
await this.saveSettings();
|
||||||
this.settingTab?.renderStatus();
|
this.settingTab?.renderStatus();
|
||||||
},
|
},
|
||||||
|
getDeviceId: () => this.deviceId,
|
||||||
setStatus: (s) => {
|
setStatus: (s) => {
|
||||||
this.lastStatus = s;
|
this.lastStatus = s;
|
||||||
this.statusBar?.update(s);
|
this.statusBar?.update(s);
|
||||||
|
|
@ -143,8 +134,7 @@ export default class SurfSensePlugin extends Plugin {
|
||||||
id: "open-settings",
|
id: "open-settings",
|
||||||
name: "Open settings",
|
name: "Open settings",
|
||||||
callback: () => {
|
callback: () => {
|
||||||
// Obsidian exposes this through the Setting host on the workspace;
|
// `app.setting` isn't in the d.ts; fall back silently if it moves.
|
||||||
// fall back silently if the API moves so we never throw.
|
|
||||||
type SettingHost = {
|
type SettingHost = {
|
||||||
open?: () => void;
|
open?: () => void;
|
||||||
openTabById?: (id: string) => 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
|
// Wait for layout so the metadataCache is warm before reconcile.
|
||||||
// startup work, so the metadataCache is warm before reconcile.
|
|
||||||
this.app.workspace.onLayoutReady(() => {
|
this.app.workspace.onLayoutReady(() => {
|
||||||
void this.engine.start();
|
void this.engine.start();
|
||||||
});
|
});
|
||||||
|
|
@ -188,13 +177,28 @@ export default class SurfSensePlugin extends Plugin {
|
||||||
await this.saveData(this.settings);
|
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 {
|
private seedIdentity(): void {
|
||||||
if (!this.settings.vaultId) {
|
if (!this.settings.vaultId) {
|
||||||
this.settings.vaultId = generateUuid();
|
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) {
|
if (!this.settings.vaultName) {
|
||||||
this.settings.vaultName = this.app.vault.getName();
|
this.settings.vaultName = this.app.vault.getName();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,7 @@ import { parseExcludePatterns } from "./excludes";
|
||||||
import type SurfSensePlugin from "./main";
|
import type SurfSensePlugin from "./main";
|
||||||
import type { SearchSpace } from "./types";
|
import type { SearchSpace } from "./types";
|
||||||
|
|
||||||
/**
|
/** Plugin settings tab. */
|
||||||
* 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).
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class SurfSenseSettingTab extends PluginSettingTab {
|
export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
private readonly plugin: SurfSensePlugin;
|
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)
|
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.")
|
||||||
|
|
@ -214,19 +184,16 @@ export class SurfSenseSettingTab extends PluginSettingTab {
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Vault ID")
|
.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) => {
|
.addText((text) => {
|
||||||
text.inputEl.disabled = true;
|
text.inputEl.disabled = true;
|
||||||
text.setValue(settings.vaultId);
|
text.setValue(settings.vaultId);
|
||||||
});
|
});
|
||||||
|
|
||||||
new Setting(containerEl)
|
// Device ID is deliberately not exposed: it's an opaque per-install UUID
|
||||||
.setName("Device ID")
|
// (see seedIdentity in main.ts) and the web UI only shows a device count.
|
||||||
.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);
|
|
||||||
});
|
|
||||||
|
|
||||||
new Setting(containerEl).setName("Status").setHeading();
|
new Setting(containerEl).setName("Status").setHeading();
|
||||||
this.statusEl = containerEl.createDiv({ cls: "surfsense-settings__status" });
|
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.
|
* Owner of "what does the vault look like vs the server" reasoning.
|
||||||
*
|
*
|
||||||
* Onload sequence (per plan §p4_plugin_sync_engine, in this exact order):
|
* Start order: connect (or fall back to /health) → drain queue → reconcile →
|
||||||
* 1. apiClient.health() — proves connectivity and pulls the capabilities
|
* subscribe events. Reconcile no-ops if last run was < RECONCILE_MIN_INTERVAL_MS ago.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface SyncEngineDeps {
|
export interface SyncEngineDeps {
|
||||||
|
|
@ -41,6 +29,8 @@ export interface SyncEngineDeps {
|
||||||
queue: PersistentQueue;
|
queue: PersistentQueue;
|
||||||
getSettings: () => SyncEngineSettings;
|
getSettings: () => SyncEngineSettings;
|
||||||
saveSettings: (mut: (s: SyncEngineSettings) => void) => Promise<void>;
|
saveSettings: (mut: (s: SyncEngineSettings) => void) => Promise<void>;
|
||||||
|
/** Per-install id sourced from app.saveLocalStorage (not synced data.json). */
|
||||||
|
getDeviceId: () => string;
|
||||||
setStatus: (s: StatusState) => void;
|
setStatus: (s: StatusState) => void;
|
||||||
onCapabilities: (caps: string[], apiVersion: string) => void;
|
onCapabilities: (caps: string[], apiVersion: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -50,8 +40,6 @@ export interface SyncEngineSettings {
|
||||||
vaultName: string;
|
vaultName: string;
|
||||||
connectorId: number | null;
|
connectorId: number | null;
|
||||||
searchSpaceId: number | null;
|
searchSpaceId: number | null;
|
||||||
deviceId: string;
|
|
||||||
deviceLabel: string;
|
|
||||||
excludePatterns: string[];
|
excludePatterns: string[];
|
||||||
includeAttachments: boolean;
|
includeAttachments: boolean;
|
||||||
syncMode: "auto" | "manual";
|
syncMode: "auto" | "manual";
|
||||||
|
|
@ -86,22 +74,27 @@ export class SyncEngine {
|
||||||
/** Run the onload sequence described in this file's docstring. */
|
/** Run the onload sequence described in this file's docstring. */
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
this.setStatus("syncing", "Connecting to SurfSense…");
|
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();
|
const settings = this.deps.getSettings();
|
||||||
if (!settings.connectorId || !settings.searchSpaceId) {
|
if (!settings.searchSpaceId) {
|
||||||
// No connector yet — settings tab will trigger ensureConnect once
|
// No target yet — bare /health probe still surfaces auth/network errors.
|
||||||
// the user picks a search space, then re-call start().
|
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.");
|
this.setStatus("idle", "Pick a search space in settings to start syncing.");
|
||||||
return;
|
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.flushQueue();
|
||||||
await this.maybeReconcile();
|
await this.maybeReconcile();
|
||||||
this.setStatus(this.queueStatusKind(), undefined);
|
this.setStatus(this.queueStatusKind(), undefined);
|
||||||
|
|
@ -119,8 +112,7 @@ export class SyncEngine {
|
||||||
searchSpaceId: settings.searchSpaceId,
|
searchSpaceId: settings.searchSpaceId,
|
||||||
vaultId: settings.vaultId,
|
vaultId: settings.vaultId,
|
||||||
vaultName: settings.vaultName,
|
vaultName: settings.vaultName,
|
||||||
deviceId: settings.deviceId,
|
deviceId: this.deps.getDeviceId(),
|
||||||
deviceLabel: settings.deviceLabel,
|
|
||||||
});
|
});
|
||||||
this.applyHealth(resp);
|
this.applyHealth(resp);
|
||||||
await this.deps.saveSettings((s) => {
|
await this.deps.saveSettings((s) => {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,15 @@
|
||||||
/**
|
/** Shared types for the SurfSense Obsidian plugin. Leaf module — no src/ imports. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface SurfsensePluginSettings {
|
export interface SurfsensePluginSettings {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
searchSpaceId: number | null;
|
searchSpaceId: number | null;
|
||||||
connectorId: number | null;
|
connectorId: number | null;
|
||||||
|
/** UUID for the vault — lives here so Obsidian Sync replicates it across devices. */
|
||||||
vaultId: string;
|
vaultId: string;
|
||||||
vaultName: string;
|
vaultName: string;
|
||||||
deviceId: string;
|
// Per-install deviceId is NOT in this interface on purpose: it lives in
|
||||||
deviceLabel: string;
|
// app.saveLocalStorage so it stays distinct on each device. See seedIdentity().
|
||||||
syncMode: "auto" | "manual";
|
syncMode: "auto" | "manual";
|
||||||
excludePatterns: string[];
|
excludePatterns: string[];
|
||||||
includeAttachments: boolean;
|
includeAttachments: boolean;
|
||||||
|
|
@ -32,8 +27,6 @@ export const DEFAULT_SETTINGS: SurfsensePluginSettings = {
|
||||||
connectorId: null,
|
connectorId: null,
|
||||||
vaultId: "",
|
vaultId: "",
|
||||||
vaultName: "",
|
vaultName: "",
|
||||||
deviceId: "",
|
|
||||||
deviceLabel: "",
|
|
||||||
syncMode: "auto",
|
syncMode: "auto",
|
||||||
excludePatterns: [".trash", "_attachments", "templates"],
|
excludePatterns: [".trash", "_attachments", "templates"],
|
||||||
includeAttachments: false,
|
includeAttachments: false,
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, Check, Copy, Download, Info } from "lucide-react";
|
import { AlertTriangle, Download, Info } from "lucide-react";
|
||||||
import { type FC, useCallback, useMemo, useRef, useState } from "react";
|
import { type FC, useMemo } from "react";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
import type { ConnectorConfigProps } from "../index";
|
||||||
|
|
||||||
export interface ObsidianConfigProps extends ConnectorConfigProps {
|
|
||||||
onNameChange?: (name: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLUGIN_RELEASES_URL =
|
const PLUGIN_RELEASES_URL =
|
||||||
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
|
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
|
||||||
|
|
||||||
|
|
@ -27,55 +19,32 @@ function formatTimestamp(value: unknown): string {
|
||||||
/**
|
/**
|
||||||
* Obsidian connector config view.
|
* 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
|
* 1. **Plugin connector** (`config.source === "plugin"`) — read-only stats
|
||||||
* panel showing what the plugin most recently reported.
|
* panel showing what the plugin most recently reported.
|
||||||
* 2. **Legacy server-path connector** (`config.legacy === true`, set by the
|
* 2. **Legacy server-path connector** (`config.legacy === true`, set by the
|
||||||
* Phase 3 alembic) — migration banner plus an "Install Plugin" CTA.
|
* Phase 3 alembic) — migration banner, an "Install Plugin" CTA, and a
|
||||||
* The user's existing notes stay searchable; only background sync stops.
|
* 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> = ({
|
export const ObsidianConfig: FC<ConnectorConfigProps> = ({ connector }) => {
|
||||||
connector,
|
|
||||||
onNameChange,
|
|
||||||
}) => {
|
|
||||||
const [name, setName] = useState<string>(connector.name || "");
|
|
||||||
const config = (connector.config ?? {}) as Record<string, unknown>;
|
const config = (connector.config ?? {}) as Record<string, unknown>;
|
||||||
const isLegacy = config.legacy === true;
|
const isLegacy = config.legacy === true;
|
||||||
const isPlugin = config.source === "plugin";
|
const isPlugin = config.source === "plugin";
|
||||||
|
|
||||||
const handleNameChange = (value: string) => {
|
if (isLegacy) return <LegacyBanner />;
|
||||||
setName(value);
|
if (isPlugin) return <PluginStats config={config} />;
|
||||||
onNameChange?.(value);
|
return <UnknownConnectorState />;
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const LegacyBanner: FC = () => {
|
const LegacyBanner: FC = () => {
|
||||||
|
|
@ -84,14 +53,12 @@ const LegacyBanner: FC = () => {
|
||||||
<Alert className="border-amber-500/40 bg-amber-500/10">
|
<Alert className="border-amber-500/40 bg-amber-500/10">
|
||||||
<AlertTriangle className="size-4 shrink-0 text-amber-500" />
|
<AlertTriangle className="size-4 shrink-0 text-amber-500" />
|
||||||
<AlertTitle className="text-xs sm:text-sm">
|
<AlertTitle className="text-xs sm:text-sm">
|
||||||
This connector has been migrated
|
Sync stopped — install the plugin to migrate
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription className="text-[11px] sm:text-xs leading-relaxed">
|
<AlertDescription className="text-[11px] sm:text-xs leading-relaxed">
|
||||||
This Obsidian connector used the legacy server-path method, which has
|
This Obsidian connector used the legacy server-path scanner, which has been removed. The
|
||||||
been removed. To resume syncing, install the SurfSense Obsidian
|
notes already indexed remain searchable, but they no longer reflect changes made in your
|
||||||
plugin and connect with this account. Your existing notes remain
|
vault.
|
||||||
searchable. After the plugin re-indexes your vault, you can delete
|
|
||||||
this connector to remove older copies.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
|
@ -107,7 +74,25 @@ const LegacyBanner: FC = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -115,6 +100,14 @@ const LegacyBanner: FC = () => {
|
||||||
const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
|
const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
|
||||||
const stats: { label: string; value: string }[] = useMemo(() => {
|
const stats: { label: string; value: string }[] = useMemo(() => {
|
||||||
const filesSynced = config.files_synced;
|
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 [
|
return [
|
||||||
{ label: "Vault", value: (config.vault_name as string) || "—" },
|
{ 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) || "—",
|
value: (config.plugin_version as string) || "—",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Device",
|
label: "Devices",
|
||||||
value:
|
value: deviceCount !== null ? deviceCount.toLocaleString() : "—",
|
||||||
(config.device_label as string) ||
|
|
||||||
(config.device_id as string) ||
|
|
||||||
"—",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Last sync",
|
label: "Last sync",
|
||||||
|
|
@ -134,8 +124,7 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Files synced",
|
label: "Files synced",
|
||||||
value:
|
value: typeof filesSynced === "number" ? filesSynced.toLocaleString() : "—",
|
||||||
typeof filesSynced === "number" ? filesSynced.toLocaleString() : "—",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
@ -146,8 +135,8 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
|
||||||
<Info className="size-4 shrink-0 text-emerald-500" />
|
<Info className="size-4 shrink-0 text-emerald-500" />
|
||||||
<AlertTitle className="text-xs sm:text-sm">Plugin connected</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">Plugin connected</AlertTitle>
|
||||||
<AlertDescription className="text-[11px] sm:text-xs">
|
<AlertDescription className="text-[11px] sm:text-xs">
|
||||||
Edits in Obsidian sync over HTTPS. To stop syncing, disable or
|
Edits in Obsidian sync over HTTPS. To stop syncing, disable or uninstall the plugin in
|
||||||
uninstall the plugin in Obsidian, or delete this connector.
|
Obsidian, or delete this connector.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</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">
|
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||||
{stat.label}
|
{stat.label}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 truncate text-xs font-medium sm:text-sm">
|
<dd className="mt-1 truncate text-xs font-medium sm:text-sm">{stat.value}</dd>
|
||||||
{stat.value}
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
@ -178,98 +165,8 @@ const UnknownConnectorState: FC = () => (
|
||||||
<Info className="size-4 shrink-0" />
|
<Info className="size-4 shrink-0" />
|
||||||
<AlertTitle className="text-xs sm:text-sm">Unrecognized config</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">Unrecognized config</AlertTitle>
|
||||||
<AlertDescription className="text-[11px] sm:text-xs">
|
<AlertDescription className="text-[11px] sm:text-xs">
|
||||||
This connector has neither plugin metadata nor a legacy marker. It may
|
This connector has neither plugin metadata nor a legacy marker. It may predate the migration —
|
||||||
predate the migration — you can safely delete it and re-install the
|
you can safely delete it and re-install the SurfSense Obsidian plugin to resume syncing.
|
||||||
SurfSense Obsidian plugin to resume syncing.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</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 isAuthExpired = connector.config?.auth_expired === true;
|
||||||
const reauthEndpoint = REAUTH_ENDPOINTS[connector.connector_type];
|
const reauthEndpoint = REAUTH_ENDPOINTS[connector.connector_type];
|
||||||
const [reauthing, setReauthing] = useState(false);
|
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 handleReauth = useCallback(async () => {
|
||||||
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
|
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
|
||||||
|
|
@ -412,7 +416,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
Disconnect
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isAuthExpired && reauthEndpoint ? (
|
{isPluginManagedReadOnly ? null : isAuthExpired && reauthEndpoint ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleReauth}
|
onClick={handleReauth}
|
||||||
disabled={reauthing || isDisconnecting}
|
disabled={reauthing || isDisconnecting}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue