Skip to content

Commit 6e12d82

Browse files
Merge pull request #7 from AnishSarkar22/feat/obsidian-plugin
Feat/obsidian plugin
2 parents 00fbaac + 5047527 commit 6e12d82

23 files changed

Lines changed: 666 additions & 214 deletions

File tree

surfsense_backend/alembic/versions/129_obsidian_plugin_vault_identity.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,7 @@ def downgrade() -> None:
9191
)
9292
conn.execute(
9393
sa.text(
94-
"DROP INDEX IF EXISTS "
95-
"search_source_connectors_obsidian_plugin_vault_uniq"
94+
"DROP INDEX IF EXISTS search_source_connectors_obsidian_plugin_vault_uniq"
9695
)
9796
)
9897
conn.execute(

surfsense_backend/app/routes/obsidian_plugin_routes.py

Lines changed: 134 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
SyncAckItem,
4242
SyncBatchRequest,
4343
)
44+
from app.services.notification_service import NotificationService
4445
from app.services.obsidian_plugin_indexer import (
4546
delete_note,
4647
get_manifest,
@@ -68,6 +69,105 @@ def _build_handshake() -> dict[str, object]:
6869
return {"capabilities": list(OBSIDIAN_CAPABILITIES)}
6970

7071

72+
def _connector_type_value(connector: SearchSourceConnector) -> str:
73+
connector_type = connector.connector_type
74+
if hasattr(connector_type, "value"):
75+
return str(connector_type.value)
76+
return str(connector_type)
77+
78+
79+
async def _start_obsidian_sync_notification(
80+
session: AsyncSession,
81+
*,
82+
user: User,
83+
connector: SearchSourceConnector,
84+
total_count: int,
85+
):
86+
"""Create/update the rolling inbox item for Obsidian plugin sync.
87+
88+
Obsidian sync is continuous and batched, so we keep one stable
89+
operation_id per connector instead of creating a new notification per batch.
90+
"""
91+
handler = NotificationService.connector_indexing
92+
operation_id = f"obsidian_sync_connector_{connector.id}"
93+
connector_name = connector.name or "Obsidian"
94+
notification = await handler.find_or_create_notification(
95+
session=session,
96+
user_id=user.id,
97+
operation_id=operation_id,
98+
title=f"Syncing: {connector_name}",
99+
message="Syncing from Obsidian plugin",
100+
search_space_id=connector.search_space_id,
101+
initial_metadata={
102+
"connector_id": connector.id,
103+
"connector_name": connector_name,
104+
"connector_type": _connector_type_value(connector),
105+
"sync_stage": "processing",
106+
"indexed_count": 0,
107+
"failed_count": 0,
108+
"total_count": total_count,
109+
"source": "obsidian_plugin",
110+
},
111+
)
112+
return await handler.update_notification(
113+
session=session,
114+
notification=notification,
115+
status="in_progress",
116+
metadata_updates={
117+
"sync_stage": "processing",
118+
"total_count": total_count,
119+
},
120+
)
121+
122+
123+
async def _finish_obsidian_sync_notification(
124+
session: AsyncSession,
125+
*,
126+
notification,
127+
indexed: int,
128+
failed: int,
129+
):
130+
"""Mark the rolling Obsidian sync inbox item complete or failed."""
131+
handler = NotificationService.connector_indexing
132+
connector_name = notification.notification_metadata.get(
133+
"connector_name", "Obsidian"
134+
)
135+
if failed > 0 and indexed == 0:
136+
title = f"Failed: {connector_name}"
137+
message = (
138+
f"Sync failed: {failed} file(s) failed"
139+
if failed > 1
140+
else "Sync failed: 1 file failed"
141+
)
142+
status_value = "failed"
143+
stage = "failed"
144+
else:
145+
title = f"Ready: {connector_name}"
146+
if failed > 0:
147+
message = f"Partially synced: {indexed} file(s) synced, {failed} failed."
148+
elif indexed == 0:
149+
message = "Already up to date!"
150+
elif indexed == 1:
151+
message = "Now searchable! 1 file synced."
152+
else:
153+
message = f"Now searchable! {indexed} files synced."
154+
status_value = "completed"
155+
stage = "completed"
156+
157+
await handler.update_notification(
158+
session=session,
159+
notification=notification,
160+
title=title,
161+
message=message,
162+
status=status_value,
163+
metadata_updates={
164+
"indexed_count": indexed,
165+
"failed_count": failed,
166+
"sync_stage": stage,
167+
},
168+
)
169+
170+
71171
async def _resolve_vault_connector(
72172
session: AsyncSession,
73173
*,
@@ -175,9 +275,7 @@ async def _find_by_fingerprint(
175275
return (await session.execute(stmt)).scalars().first()
176276

177277

178-
def _build_config(
179-
payload: ConnectRequest, *, now_iso: str
180-
) -> dict[str, object]:
278+
def _build_config(payload: ConnectRequest, *, now_iso: str) -> dict[str, object]:
181279
return {
182280
"vault_id": payload.vault_id,
183281
"vault_name": payload.vault_name,
@@ -188,7 +286,7 @@ def _build_config(
188286

189287

190288
def _display_name(vault_name: str) -> str:
191-
return f"Obsidian \u2014 {vault_name}"
289+
return f"Obsidian - {vault_name}"
192290

193291

194292
@router.post("/connect", response_model=ConnectResponse)
@@ -335,6 +433,18 @@ async def obsidian_sync(
335433
connector = await _resolve_vault_connector(
336434
session, user=user, vault_id=payload.vault_id
337435
)
436+
notification = None
437+
try:
438+
notification = await _start_obsidian_sync_notification(
439+
session, user=user, connector=connector, total_count=len(payload.notes)
440+
)
441+
except Exception:
442+
logger.warning(
443+
"obsidian sync notification start failed connector=%s user=%s",
444+
connector.id,
445+
user.id,
446+
exc_info=True,
447+
)
338448

339449
items: list[SyncAckItem] = []
340450
indexed = 0
@@ -346,9 +456,7 @@ async def obsidian_sync(
346456
session, connector=connector, payload=note, user_id=str(user.id)
347457
)
348458
indexed += 1
349-
items.append(
350-
SyncAckItem(path=note.path, status="ok", document_id=doc.id)
351-
)
459+
items.append(SyncAckItem(path=note.path, status="ok", document_id=doc.id))
352460
except HTTPException:
353461
raise
354462
except Exception as exc:
@@ -362,6 +470,22 @@ async def obsidian_sync(
362470
SyncAckItem(path=note.path, status="error", error=str(exc)[:300])
363471
)
364472

473+
if notification is not None:
474+
try:
475+
await _finish_obsidian_sync_notification(
476+
session,
477+
notification=notification,
478+
indexed=indexed,
479+
failed=failed,
480+
)
481+
except Exception:
482+
logger.warning(
483+
"obsidian sync notification finish failed connector=%s user=%s",
484+
connector.id,
485+
user.id,
486+
exc_info=True,
487+
)
488+
365489
return SyncAck(
366490
vault_id=payload.vault_id,
367491
indexed=indexed,
@@ -471,9 +595,7 @@ async def obsidian_delete_notes(
471595
path,
472596
payload.vault_id,
473597
)
474-
items.append(
475-
DeleteAckItem(path=path, status="error", error=str(exc)[:300])
476-
)
598+
items.append(DeleteAckItem(path=path, status="error", error=str(exc)[:300]))
477599

478600
return DeleteAck(
479601
vault_id=payload.vault_id,
@@ -490,9 +612,7 @@ async def obsidian_manifest(
490612
session: AsyncSession = Depends(get_async_session),
491613
) -> ManifestResponse:
492614
"""Return ``{path: {hash, mtime}}`` for the plugin's onload reconcile diff."""
493-
connector = await _resolve_vault_connector(
494-
session, user=user, vault_id=vault_id
495-
)
615+
connector = await _resolve_vault_connector(session, user=user, vault_id=vault_id)
496616
return await get_manifest(session, connector=connector, vault_id=vault_id)
497617

498618

@@ -507,9 +627,7 @@ async def obsidian_stats(
507627
``files_synced`` excludes tombstones so it matches ``/manifest``;
508628
``last_sync_at`` includes them so deletes advance the freshness signal.
509629
"""
510-
connector = await _resolve_vault_connector(
511-
session, user=user, vault_id=vault_id
512-
)
630+
connector = await _resolve_vault_connector(session, user=user, vault_id=vault_id)
513631

514632
is_active = Document.document_metadata["deleted_at"].as_string().is_(None)
515633

surfsense_backend/app/schemas/obsidian_plugin.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,37 @@ class _PluginBase(BaseModel):
2121
model_config = _PLUGIN_MODEL_CONFIG
2222

2323

24+
class HeadingRef(_PluginBase):
25+
"""One markdown heading extracted from Obsidian metadata cache."""
26+
27+
heading: str
28+
level: int = Field(ge=1, le=6)
29+
30+
2431
class NotePayload(_PluginBase):
2532
"""One Obsidian note as pushed by the plugin (the source of truth)."""
2633

27-
vault_id: str = Field(..., description="Stable plugin-generated UUID for this vault")
34+
vault_id: str = Field(
35+
..., description="Stable plugin-generated UUID for this vault"
36+
)
2837
path: str = Field(..., description="Vault-relative path, e.g. 'notes/foo.md'")
2938
name: str = Field(..., description="File stem (no extension)")
30-
extension: str = Field(default="md", description="File extension without leading dot")
39+
extension: str = Field(
40+
default="md", description="File extension without leading dot"
41+
)
3142
content: str = Field(default="", description="Raw markdown body (post-frontmatter)")
3243

3344
frontmatter: dict[str, Any] = Field(default_factory=dict)
3445
tags: list[str] = Field(default_factory=list)
35-
headings: list[str] = Field(default_factory=list)
46+
headings: list[HeadingRef] = Field(default_factory=list)
3647
resolved_links: list[str] = Field(default_factory=list)
3748
unresolved_links: list[str] = Field(default_factory=list)
3849
embeds: list[str] = Field(default_factory=list)
3950
aliases: list[str] = Field(default_factory=list)
4051

41-
content_hash: str = Field(..., description="Plugin-computed SHA-256 of the raw content")
52+
content_hash: str = Field(
53+
..., description="Plugin-computed SHA-256 of the raw content"
54+
)
4255
size: int | None = Field(
4356
default=None,
4457
ge=0,

surfsense_backend/app/services/obsidian_plugin_indexer.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@
5353
ManifestResponse,
5454
NotePayload,
5555
)
56-
from app.services.llm_service import get_user_long_context_llm
5756
from app.utils.document_converters import generate_unique_identifier_hash
5857
from app.utils.document_versioning import create_version_snapshot
5958

@@ -102,7 +101,7 @@ def _build_metadata(
102101
"extension": payload.extension,
103102
"frontmatter": payload.frontmatter,
104103
"tags": payload.tags,
105-
"headings": payload.headings,
104+
"headings": [h.model_dump() for h in payload.headings],
106105
"outgoing_links": payload.resolved_links,
107106
"unresolved_links": payload.unresolved_links,
108107
"embeds": payload.embeds,
@@ -126,9 +125,7 @@ def _build_document_string(payload: NotePayload, vault_name: str) -> str:
126125
existing search relevance heuristics keep working unchanged.
127126
"""
128127
tags_line = ", ".join(payload.tags) if payload.tags else "None"
129-
links_line = (
130-
", ".join(payload.resolved_links) if payload.resolved_links else "None"
131-
)
128+
links_line = ", ".join(payload.resolved_links) if payload.resolved_links else "None"
132129
return (
133130
"<METADATA>\n"
134131
f"Title: {payload.name}\n"
@@ -235,12 +232,12 @@ async def upsert_note(
235232
if not prepared:
236233
if existing is not None:
237234
return existing
238-
raise RuntimeError(
239-
f"Indexing pipeline rejected obsidian note {payload.path}"
240-
)
235+
raise RuntimeError(f"Indexing pipeline rejected obsidian note {payload.path}")
241236

242237
document = prepared[0]
243238

239+
from app.services.llm_service import get_user_long_context_llm
240+
244241
llm = await get_user_long_context_llm(session, str(user_id), search_space_id)
245242
return await pipeline.index(document, connector_doc, llm)
246243

0 commit comments

Comments
 (0)