From 6c2a905afea57f7023e50693bcb0cdb8e3dc444d Mon Sep 17 00:00:00 2001 From: domverse Date: Mon, 30 Mar 2026 08:43:42 +0200 Subject: [PATCH] feat: sync saved views and metadata entities to replicas - Sync Tags, Correspondents, Document Types, Custom Fields via _ensure_schema_parity (already existed) - Add saved views sync: create/update on replica with filter rule ID translation - _FILTER_RULE_ENTITY_MAP translates entity-referencing rule types (correspondents, document types, tags, custom fields) to replica IDs - Saved views sync is best-effort (non-fatal on failure) Co-Authored-By: Claude Sonnet 4.6 --- app/sync/engine.py | 84 +++++++++++++++++++++++++++++++++++++++++++ app/sync/paperless.py | 11 ++++++ 2 files changed, 95 insertions(+) diff --git a/app/sync/engine.py b/app/sync/engine.py index e80289c..2beec9a 100644 --- a/app/sync/engine.py +++ b/app/sync/engine.py @@ -134,6 +134,77 @@ def _translate_metadata(meta: dict, maps: dict) -> dict: return result +# Maps paperless-ngx FilterRuleType integers that reference entity IDs. +# Based on paperless-ngx FilterRuleType enum (stable since v1.14). +_FILTER_RULE_ENTITY_MAP: dict[int, str] = { + 3: "correspondents", # CORRESPONDENT + 4: "document_types", # DOCUMENT_TYPE + 6: "tags", # HAS_TAG + 17: "tags", # DOES_NOT_HAVE_TAG + 21: "correspondents", # DOES_NOT_HAVE_CORRESPONDENT + 22: "document_types", # DOES_NOT_HAVE_DOCUMENT_TYPE + 25: "custom_fields", # HAS_CUSTOM_FIELD + 26: "custom_fields", # DOES_NOT_HAVE_CUSTOM_FIELD +} + + +async def _sync_saved_views( + master: PaperlessClient, + replica: PaperlessClient, + maps: dict, + replica_obj: Replica, + run_id: int, + session: Session, +) -> None: + """Sync saved views from master to replica, translating entity IDs in filter rules.""" + master_views = await master.get_saved_views() + replica_views = {v["name"]: v for v in await replica.get_saved_views()} + + created = updated = 0 + for view in master_views: + translated_rules = [] + for rule in view.get("filter_rules", []): + rule_type = rule.get("rule_type") + value = rule.get("value") + entity_key = _FILTER_RULE_ENTITY_MAP.get(rule_type) + if entity_key and value is not None: + try: + replica_id = maps[entity_key].get(int(value)) + if replica_id is None: + continue # entity not synced yet — skip rule + value = str(replica_id) + except (ValueError, TypeError): + pass # non-integer value, pass through unchanged + translated_rules.append({"rule_type": rule_type, "value": value}) + + view_data = { + "name": view["name"], + "show_on_dashboard": view.get("show_on_dashboard", False), + "show_in_sidebar": view.get("show_in_sidebar", False), + "sort_field": view.get("sort_field", ""), + "sort_reverse": view.get("sort_reverse", False), + "filter_rules": translated_rules, + } + + existing = replica_views.get(view["name"]) + if existing: + await replica.update_saved_view(existing["id"], view_data) + updated += 1 + else: + await replica.create_saved_view(view_data) + created += 1 + + if created or updated: + emit_log( + "info", + f"Saved views synced: {created} created, {updated} updated", + replica=replica_obj.name, + replica_id=replica_obj.id, + run_id=run_id, + session=session, + ) + + def _sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() @@ -262,6 +333,19 @@ async def _sync_replica( ) raise + # Step 5a2: sync saved views (best-effort, non-fatal) + try: + await _sync_saved_views(master, replica, maps, replica_obj, run_id, session) + except Exception as e: + emit_log( + "warning", + f"Saved views sync failed (non-fatal): {e}", + replica=replica_obj.name, + replica_id=replica_obj.id, + run_id=run_id, + session=session, + ) + # Step 5b: resolve pending tasks _progress.phase = f"resolving tasks — {replica_obj.name}" await _resolve_pending_tasks( diff --git a/app/sync/paperless.py b/app/sync/paperless.py index 42d4d0a..8f4b333 100644 --- a/app/sync/paperless.py +++ b/app/sync/paperless.py @@ -195,6 +195,17 @@ class PaperlessClient: ) return r.json() + async def get_saved_views(self) -> list[dict]: + return await self._get_all("/api/saved_views/") + + async def create_saved_view(self, view_data: dict) -> dict: + r = await self._request("POST", "/api/saved_views/", json=view_data) + return r.json() + + async def update_saved_view(self, view_id: int, view_data: dict) -> dict: + r = await self._request("PUT", f"/api/saved_views/{view_id}/", json=view_data) + return r.json() + async def test_connection(self) -> dict: """Returns {ok, error, latency_ms, doc_count}.""" t0 = time.monotonic()