"""WebSocket handlers for document metadata. Covers everything that is pure JSON: listing an object's documents, attaching a web-link, editing metadata (title / tags / task links), deleting, and the global storage summary. Binary **file** upload + download go through the authenticated HTTP views in ``views.py`` (multipart / streamed body) — websocket frames are a poor fit for large binaries. Authz mirrors object/task CRUD: reads (``list`` / ``storage``) are open to any panel user; mutations require write permission (admin or delegated operator) via ``@require_write``. Documents are object content, the same tier as objects and tasks — never the global-config tier. """ from __future__ import annotations from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant from ..const import ( CONF_OBJECT, DOMAIN, MAX_NAME_LENGTH, MAX_TEXT_LENGTH, MAX_URL_LENGTH, ) from ..helpers.aggregate import object_name from ..helpers.permissions import require_write from ..helpers.search_match import query_tokens, score_fields, snippet from . import ID_FIELD, _get_object_entries, _load_object_entry, object_id_for_entry from .tasks import _is_safe_url if TYPE_CHECKING: from ..helpers.documents import DocumentStore _MAX_TAGS = 20 _MAX_TAG_LEN = 64 _MAX_TASK_IDS = 100 # A list of non-empty, length-capped tag strings, itself length-capped. _TAGS_SCHEMA = vol.All( [vol.All(str, vol.Length(min=1, max=_MAX_TAG_LEN))], vol.Length(max=_MAX_TAGS), ) _TASK_IDS_SCHEMA = vol.All( [ID_FIELD], vol.Length(max=_MAX_TASK_IDS), ) # {task_id: page} jump-to-page hints; page 0 clears, >=1 sets (PDFs, #page=N). # A plain ``str`` type-key matches any task id — a ``vol.All(str, ...)`` key is # treated as an unknown key by voluptuous and rejected as "extra keys". _TASK_PAGES_SCHEMA = vol.Schema({str: vol.All(int, vol.Range(min=0, max=99999))}) def _get_store(hass: HomeAssistant) -> DocumentStore: """Return the loaded document store (an async_setup invariant).""" from .. import DOCUMENT_STORE_KEY store: DocumentStore = hass.data[DOMAIN][DOCUMENT_STORE_KEY] return store @websocket_api.websocket_command( { vol.Required("type"): "maintenance_supporter/documents/list", vol.Required("entry_id"): ID_FIELD, } ) @websocket_api.async_response async def ws_documents_list( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """List all documents attached to an object (newest first).""" entry = _load_object_entry(hass, connection, msg) if entry is None: return store = _get_store(hass) documents = store.for_object(object_id_for_entry(entry)) connection.send_result(msg["id"], {"documents": documents}) @websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/documents/storage"}) @websocket_api.async_response async def ws_documents_storage( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return the global storage summary (physical vs logical, per object/category). ``search_index`` (#171) says how much of the library is full-text searchable: ``indexed`` blobs carry a text layer, ``no_text`` are scans / image-only PDFs, ``unsupported`` are photos and other binaries, ``pending`` still wait for the backfill. """ store = _get_store(hass) summary = store.storage_summary() if store.text_index is not None: summary["search_index"] = store.text_index.summary() connection.send_result(msg["id"], summary) @websocket_api.websocket_command( { vol.Required("type"): "maintenance_supporter/documents/add_link", vol.Required("entry_id"): ID_FIELD, vol.Required("url"): vol.All(str, vol.Length(min=1, max=MAX_URL_LENGTH)), vol.Optional("title"): vol.Any(vol.All(str, vol.Length(max=MAX_NAME_LENGTH)), None), vol.Optional("tags"): _TAGS_SCHEMA, vol.Optional("description"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None), } ) @require_write @websocket_api.async_response async def ws_documents_add_link( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Attach an external web-link to an object (0 storage, not in backups).""" entry = _load_object_entry(hass, connection, msg) if entry is None: return url = msg["url"].strip() # Web-links must be absolute http/https — a relative or scheme-less URL is # meaningless as a stored external reference (and _is_safe_url treats those # as "safe", so the explicit prefix check is required here). if not url.lower().startswith(("http://", "https://")) or not _is_safe_url(url): connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed") return title = msg.get("title") doc = await _get_store(hass).async_add_weblink( object_id_for_entry(entry), url=url, title=title.strip() if isinstance(title, str) and title.strip() else None, tags=msg.get("tags"), description=msg.get("description"), ) connection.send_result(msg["id"], doc) @websocket_api.websocket_command( { vol.Required("type"): "maintenance_supporter/documents/update", vol.Required("doc_id"): ID_FIELD, vol.Optional("title"): vol.All(str, vol.Length(max=MAX_NAME_LENGTH)), vol.Optional("tags"): _TAGS_SCHEMA, vol.Optional("task_ids"): _TASK_IDS_SCHEMA, vol.Optional("task_pages"): _TASK_PAGES_SCHEMA, # Spare-part links (v2.26) — same shape/cap as task links. vol.Optional("part_ids"): _TASK_IDS_SCHEMA, # #164: a free-text description ('' clears). vol.Optional("description"): vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), } ) @require_write @websocket_api.async_response async def ws_documents_update( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Update editable document metadata (title / tags / task links / per-task page).""" store = _get_store(hass) title = msg.get("title") # A present title (even empty) sets/clears it; absent or null → no change. # Don't collapse "" to None here — that would make an intentional clear read # as "leave unchanged" and the old title would survive (L5). ok = await store.async_update( msg["doc_id"], title=title.strip() if isinstance(title, str) else None, tags=msg.get("tags"), task_ids=msg.get("task_ids"), task_pages=msg.get("task_pages"), part_ids=msg.get("part_ids"), description=msg.get("description"), ) if not ok: connection.send_error(msg["id"], "not_found", "Document not found") return connection.send_result(msg["id"], store.get(msg["doc_id"])) @websocket_api.websocket_command( { vol.Required("type"): "maintenance_supporter/documents/delete", vol.Required("doc_id"): ID_FIELD, } ) @require_write @websocket_api.async_response async def ws_documents_delete( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Remove a document. Frees blob bytes only when the last reference goes. History entries' completion photos and parts' ``doc_id`` that pointed at it are cleared too (they used to dangle).""" from ..helpers.documents import async_forget_doc_ids store = _get_store(hass) if store.get(msg["doc_id"]) is None: connection.send_error(msg["id"], "not_found", "Document not found") return freed = await store.async_remove(msg["doc_id"]) await async_forget_doc_ids(hass, {msg["doc_id"]}) connection.send_result(msg["id"], {"success": True, "bytes_freed": freed}) @websocket_api.websocket_command( { vol.Required("type"): "maintenance_supporter/documents/discard_upload", vol.Required("entry_id"): ID_FIELD, vol.Required("doc_id"): ID_FIELD, } ) @websocket_api.async_response async def ws_documents_discard_upload( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Delete a completion photo that never became part of a record (read tier). Every signed-in user may upload a completion photo, but only writers may delete documents — so a photo a household member removed from the dialog again, or whose completion was cancelled, stayed behind as an orphan counting against the object's document cap (bug audit 2026-09-27, R SEC-3). This deletes a document only when it belongs to ``entry_id``'s object, is tagged exactly ``photo`` and nothing points at it (no history entry of any task, no part, no task link) — anything else answers ``not_found``, whether it exists or not, so the command reveals nothing and can remove nothing that is part of the record. """ from ..helpers.completion_requirements import is_unattached_photo, photo_references entry = _load_object_entry(hass, connection, msg) if entry is None: return store = _get_store(hass) doc = store.documents.get(msg["doc_id"]) references = photo_references(hass, strict=False) if ( doc is None or references is None or doc.get("object_id") != object_id_for_entry(entry) or not is_unattached_photo(doc, msg["doc_id"], references) ): connection.send_error(msg["id"], "not_found", "Document not found") return await store.async_remove(msg["doc_id"]) connection.send_result(msg["id"], {"success": True}) _SEARCH_MAX_RESULTS = 50 #: Field weights for the tolerant matcher — the title is what people remember, #: the file name and tags come next, a URL or MIME rarely. _DOC_FIELD_WEIGHTS = (("title", 3), ("filename", 2), ("description", 2), ("url", 1), ("mime", 1)) def _object_map(hass: HomeAssistant) -> dict[str, tuple[str, str]]: """object id -> (entry_id, name), so hits carry a human-readable location.""" obj_map: dict[str, tuple[str, str]] = {} for entry in _get_object_entries(hass): obj = entry.data.get(CONF_OBJECT, {}) oid = obj.get("id") if isinstance(oid, str) and oid: obj_map[oid] = (entry.entry_id, object_name(entry)) return obj_map def _doc_hit(did: str, doc: dict[str, Any], obj_map: dict[str, tuple[str, str]]) -> dict[str, Any]: entry_id, name = obj_map.get(doc.get("object_id", ""), ("", "")) return { "id": did, "entry_id": entry_id, "object_name": name, "kind": doc.get("kind"), "title": doc.get("title"), "filename": doc.get("filename"), "url": doc.get("url"), "size": doc.get("size"), "tags": doc.get("tags") or [], "description": doc.get("description") or "", } def _doc_meta_score(tokens: list[tuple[str, ...]], doc: dict[str, Any]) -> int: fields: list[tuple[str, int]] = [(str(doc.get(f) or ""), w) for f, w in _DOC_FIELD_WEIGHTS] fields.extend((str(tag), 2) for tag in (doc.get("tags") or [])) return score_fields(tokens, fields) @websocket_api.websocket_command( { vol.Required("type"): "maintenance_supporter/documents/search", vol.Required("query"): vol.All(str, vol.Length(max=MAX_NAME_LENGTH)), } ) @websocket_api.async_response async def ws_documents_search( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Find documents across all objects by title / filename / tag (read, open). Tolerant matching (#171): every word of the query must match somewhere — as a prefix, a substring or with one typo, diacritics folded — and hits come back best first. """ tokens = query_tokens(msg["query"]) if not tokens: connection.send_result(msg["id"], {"results": []}) return obj_map = _object_map(hass) store = _get_store(hass) scored: list[tuple[int, str, dict[str, Any]]] = [] for did, doc in store.documents.items(): score = _doc_meta_score(tokens, doc) if score > 0: scored.append((score, did, doc)) scored.sort(key=lambda item: -item[0]) results = [_doc_hit(did, doc, obj_map) for _, did, doc in scored[:_SEARCH_MAX_RESULTS]] connection.send_result(msg["id"], {"results": results}) _GLOBAL_SEARCH_MAX = 20 _HISTORY_NOTE_MAX = 4000 def _history_hit(entry_id: str, task_id: str, td: dict[str, Any], object_name: str, h: dict[str, Any], *, score: int) -> dict[str, Any]: """One history hit of the global search; ``ref`` is the completion's reference ("8.3-2", #170) when the object, task and entry all carry one.""" from ..helpers.reference_numbers import format_entry_ref obj_ref = td.get("_object_ref") return { "entry_id": entry_id, "task_id": task_id, "task_name": str(td.get("name") or ""), "object_name": object_name, "timestamp": h.get("timestamp"), "type": h.get("type"), "ref": format_entry_ref(obj_ref, td.get("ref_no"), h.get("ref_no")), "snippet": "", "score": score, } @websocket_api.websocket_command( { vol.Required("type"): "maintenance_supporter/search", vol.Required("query"): vol.All(str, vol.Length(max=MAX_NAME_LENGTH)), vol.Optional("limit", default=8): vol.All(int, vol.Range(min=1, max=_GLOBAL_SEARCH_MAX)), } ) @websocket_api.async_response async def ws_search( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """The server side of the panel's global search (#171, read, open). Objects, tasks and parts are matched in the panel from data it already holds; this command covers what is NOT in the browser: documents (by title / file name / tags AND by their extracted text — page + snippet) and the notes written into task histories. Both lists come back best first, ``limit`` each. """ from ..helpers.aggregate import merged_tasks, object_name from ..helpers.reference_numbers import parse_ref query = msg["query"] limit = max(1, min(int(msg.get("limit", 8)), _GLOBAL_SEARCH_MAX)) # A typed reference ("8.3-2", #170) names ONE completion — answer with # exactly that entry; objects and tasks by reference the panel resolves # from its own data. parsed = parse_ref(query) if parsed is not None and parsed[2] is not None: obj_ref, task_ref, entry_ref = parsed for entry in _get_object_entries(hass): if (entry.data.get(CONF_OBJECT) or {}).get("ref_no") != obj_ref: continue for tid, td in merged_tasks(entry).items(): if td.get("ref_no") != task_ref: continue for h in td.get("history") or []: if isinstance(h, dict) and h.get("ref_no") == entry_ref: hit = _history_hit(entry.entry_id, tid, {**td, "_object_ref": obj_ref}, object_name(entry), h, score=1000) hit["snippet"] = " ".join(str(h.get("notes") or "").split())[:160] connection.send_result(msg["id"], {"documents": [], "history": [hit]}) return connection.send_result(msg["id"], {"documents": [], "history": []}) return tokens = query_tokens(query) if not tokens: connection.send_result(msg["id"], {"documents": [], "history": []}) return obj_map = _object_map(hass) store = _get_store(hass) # Documents: metadata hits first, then content hits from the text index # (a document that matches both keeps the higher score and gains the page). doc_hits: dict[str, dict[str, Any]] = {} for did, doc in store.documents.items(): score = _doc_meta_score(tokens, doc) if score > 0: doc_hits[did] = {**_doc_hit(did, doc, obj_map), "score": score * 10, "match": "meta", "page": None, "snippet": ""} if store.text_index is not None: by_digest: dict[str, list[str]] = {} for did, doc in store.documents.items(): digest = doc.get("hash") if isinstance(digest, str) and digest: by_digest.setdefault(digest, []).append(did) for hit in await store.text_index.async_search(msg["query"], limit=limit * 2): for did in by_digest.get(hit["digest"], []): # The search awaits: a document deleted meanwhile raised a # KeyError here and the whole search failed (bug audit # 2026-09-26) — it simply is no hit any more. live = store.documents.get(did) if live is None: doc_hits.pop(did, None) continue cur = doc_hits.get(did) if cur is None: doc_hits[did] = { **_doc_hit(did, live, obj_map), "score": hit["score"], "match": "content", "page": hit["page"], "snippet": hit["snippet"], } else: cur["page"] = hit["page"] cur["snippet"] = hit["snippet"] cur["score"] = max(cur["score"], hit["score"]) + min(cur["score"], hit["score"]) // 4 documents = sorted((hit for did, hit in doc_hits.items() if did in store.documents), key=lambda d: -d["score"])[:limit] # History notes across every task of every object. history: list[dict[str, Any]] = [] for entry in _get_object_entries(hass): oname = object_name(entry) # Not ``obj_ref``: that name is the parsed int of the reference branch above. object_ref = (entry.data.get(CONF_OBJECT) or {}).get("ref_no") for tid, td in merged_tasks(entry).items(): td = {**td, "_object_ref": object_ref} for h in td.get("history") or []: notes = h.get("notes") if isinstance(h, dict) else None if not isinstance(notes, str) or not notes.strip(): continue score = score_fields(tokens, [(notes[:_HISTORY_NOTE_MAX], 1)]) if score <= 0: continue piece, _at = snippet(notes, tokens[0][0], radius=60) hit = _history_hit(entry.entry_id, tid, td, oname, h, score=score) hit["snippet"] = piece or " ".join(notes.split())[:160] history.append(hit) # Best first; equal scores newest first (two stable sorts). history.sort(key=lambda h: str(h.get("timestamp") or ""), reverse=True) history.sort(key=lambda h: -h["score"]) connection.send_result(msg["id"], {"documents": documents, "history": history[:limit]})