393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant
from ..const import (
CONF_OBJECT,
DOMAIN,
MAX_ID_LENGTH,
MAX_NAME_LENGTH,
MAX_TEXT_LENGTH,
MAX_URL_LENGTH,
@@ -31,7 +30,7 @@ from ..const import (
from ..helpers.aggregate import object_name
from ..helpers.permissions import require_write
from ..helpers.search_match import query_tokens, score_fields, snippet
from . import _get_object_entries, _load_object_entry, object_id_for_entry
from . import ID_FIELD, _get_object_entries, _load_object_entry, object_id_for_entry
from .tasks import _is_safe_url
if TYPE_CHECKING:
@@ -47,7 +46,7 @@ _TAGS_SCHEMA = vol.All(
vol.Length(max=_MAX_TAGS),
)
_TASK_IDS_SCHEMA = vol.All(
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
[ID_FIELD],
vol.Length(max=_MAX_TASK_IDS),
)
# {task_id: page} jump-to-page hints; page 0 clears, >=1 sets (PDFs, #page=N).
@@ -67,7 +66,7 @@ def _get_store(hass: HomeAssistant) -> DocumentStore:
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/documents/list",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@websocket_api.async_response
@@ -109,7 +108,7 @@ async def ws_documents_storage(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/documents/add_link",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
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,
@@ -150,7 +149,7 @@ async def ws_documents_add_link(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/documents/update",
vol.Required("doc_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
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,
@@ -192,7 +191,7 @@ async def ws_documents_update(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/documents/delete",
vol.Required("doc_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("doc_id"): ID_FIELD,
}
)
@require_write
@@ -216,6 +215,51 @@ async def ws_documents_delete(
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.
@@ -382,10 +426,17 @@ async def ws_search(
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, store.documents[did], obj_map),
**_doc_hit(did, live, obj_map),
"score": hit["score"],
"match": "content",
"page": hit["page"],
@@ -395,15 +446,16 @@ async def ws_search(
cur["page"] = hit["page"]
cur["snippet"] = hit["snippet"]
cur["score"] = max(cur["score"], hit["score"]) + min(cur["score"], hit["score"]) // 4
documents = sorted(doc_hits.values(), key=lambda d: -d["score"])[:limit]
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)
obj_ref = (entry.data.get(CONF_OBJECT) or {}).get("ref_no")
# 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": obj_ref}
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():