278 files

This commit is contained in:
Home Assistant Version Control
2026-09-13 20:38:58 +00:00
parent d7a0a08372
commit 1b4f5f68c6
278 changed files with 36892 additions and 7150 deletions
@@ -16,7 +16,9 @@ from __future__ import annotations
import hashlib
import logging
import math
import os
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from uuid import uuid4
@@ -26,7 +28,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util
from ..const import DOMAIN, MAX_DOCS_PER_OBJECT, SIGNAL_DOCUMENTS_UPDATED
from ..const import (
CONF_PARTS,
CONF_TASKS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_DOCS_PER_OBJECT,
MAX_TEXT_LENGTH,
SIGNAL_DOCUMENTS_UPDATED,
)
if TYPE_CHECKING:
from .document_text import DocumentTextIndex
@@ -49,6 +59,132 @@ KIND_FILE = "file"
KIND_WEBLINK = "weblink"
def doc_wire_dict(doc: dict[str, Any], *, include_id: bool) -> dict[str, Any]:
"""A document's portable metadata — the record the JSON export AND the
documents ZIP archive write, and ``async_import_documents`` reads back.
One builder so the two cannot drift: the archive used to omit ``id``
(so a ZIP restore never filled the import's id map and completion
photos / part ``doc_id`` links were only re-pointed on a JSON restore)
and ``task_pages``. ``include_id`` is False only for callers that
deliberately anonymise (none today — the parameter documents the choice).
"""
out: dict[str, Any] = {}
if include_id:
out["id"] = doc.get("id")
if doc.get("kind") == KIND_WEBLINK:
out.update({"kind": KIND_WEBLINK, "url": doc.get("url")})
else:
out.update({"kind": KIND_FILE, "hash": doc.get("hash")})
out["title"] = doc.get("title")
if doc.get("kind") != KIND_WEBLINK:
out.update({"filename": doc.get("filename"), "mime": doc.get("mime"), "size": doc.get("size")})
out.update(
{
"tags": doc.get("tags") or [],
"description": doc.get("description") or "",
"task_ids": doc.get("task_ids") or [],
"part_ids": doc.get("part_ids") or [],
}
)
pages = doc.get("task_pages")
if isinstance(pages, dict) and pages:
out["task_pages"] = dict(pages)
return out
async def async_rewrite_doc_refs(hass: HomeAssistant, rewrite: Callable[[str], str | None]) -> int:
"""Apply ``rewrite`` to every document reference held OUTSIDE the
document store: completion photos on history entries (the per-object
Store) and the spare parts' ``doc_id`` (entry data). ``rewrite(old)``
returns the id to keep, or ``None`` to drop the reference. Returns the
number of references changed. The one walker behind
:func:`async_forget_doc_ids` (document delete) and the archive import's
re-pointing of restored documents.
"""
from .completion_photos import history_photo_ids
changed_total = 0
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
if store is not None:
store_changed = False
for task_id in list(entry.data.get(CONF_TASKS) or {}):
history = store.get_history(task_id)
new_history: list[dict[str, Any]] = []
task_changed = False
for hist_entry in history:
photos = history_photo_ids(hist_entry) if isinstance(hist_entry, dict) else []
if not photos:
new_history.append(hist_entry)
continue
kept: list[str] = []
entry_changed = False
for old in photos:
new = rewrite(old)
if new != old:
entry_changed = True
changed_total += 1
if new is not None and new not in kept:
kept.append(new)
if not entry_changed and "photo_doc_id" not in hist_entry:
new_history.append(hist_entry)
continue
patched = dict(hist_entry)
patched.pop("photo_doc_id", None)
if kept:
patched["photo_doc_ids"] = kept
else:
patched.pop("photo_doc_ids", None)
new_history.append(patched)
task_changed = task_changed or entry_changed
if task_changed:
store.set_history(task_id, new_history)
store_changed = True
if store_changed:
await store.async_save()
parts = entry.data.get(CONF_PARTS) or {}
new_parts: dict[str, Any] = {}
parts_changed = False
for pid, part in parts.items():
old_doc = part.get("doc_id") if isinstance(part, dict) else None
if isinstance(old_doc, str) and old_doc:
new_doc = rewrite(old_doc)
if new_doc != old_doc:
part = {**part, "doc_id": new_doc}
parts_changed = True
changed_total += 1
new_parts[pid] = part
if parts_changed:
hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_PARTS: new_parts})
return changed_total
async def async_forget_doc_ids(hass: HomeAssistant, doc_ids: set[str]) -> int:
"""Strip deleted document ids from every history entry's completion
photos and every part's ``doc_id`` — the reverse cascade a document
delete lacked (the ids dangled: a missing picture, a part link to
nothing). Returns the number of references dropped."""
if not doc_ids:
return 0
return await async_rewrite_doc_refs(hass, lambda old: None if old in doc_ids else old)
def _safe_size(raw: Any) -> int:
"""Byte size from an exported record — a non-numeric / negative / bool
value degrades to 0 instead of raising (an unchecked ``int(...)`` here
aborted the whole JSON import on one bad record; bug audit 2026-09-12).
"""
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
return 0
if isinstance(raw, float) and not math.isfinite(raw):
return 0
return max(0, int(raw))
class DocumentStore:
"""Global store for maintenance-object documents (metadata + blob registry).
@@ -153,6 +289,12 @@ class DocumentStore:
doc = self.documents.get(doc_id)
return {"id": doc_id, **doc} if doc is not None else None
@staticmethod
def clean_description(raw: Any) -> str:
"""A document's free-text description (#164): one string, trimmed,
capped like every other note; anything else is the empty string."""
return raw.strip()[:MAX_TEXT_LENGTH] if isinstance(raw, str) else ""
def for_object(self, object_id: str) -> list[dict[str, Any]]:
"""All documents attached to an object, newest first."""
docs = [{"id": did, **d} for did, d in self.documents.items() if d.get("object_id") == object_id]
@@ -172,6 +314,7 @@ class DocumentStore:
mime: str,
title: str | None = None,
tags: list[str] | None = None,
description: str | None = None,
) -> dict[str, Any]:
"""Store an uploaded file (content-addressed + deduped) for an object.
@@ -213,6 +356,7 @@ class DocumentStore:
"mime": mime,
"size": len(content),
"tags": list(tags or []),
"description": self.clean_description(description),
"task_ids": [],
"part_ids": [],
"added_at": dt_util.utcnow().isoformat(),
@@ -254,6 +398,7 @@ class DocumentStore:
url: str,
title: str | None = None,
tags: list[str] | None = None,
description: str | None = None,
) -> dict[str, Any]:
"""Attach an external web-link (0 storage, NOT in backups)."""
doc_id = uuid4().hex
@@ -263,6 +408,7 @@ class DocumentStore:
"url": url,
"title": title or url,
"tags": list(tags or []),
"description": self.clean_description(description),
"task_ids": [],
"part_ids": [],
"added_at": dt_util.utcnow().isoformat(),
@@ -308,63 +454,96 @@ class DocumentStore:
return []
return [part_id_map[p] for p in (meta.get("part_ids") or []) if p in part_id_map]
def _remap_pages(meta: dict[str, Any]) -> dict[str, int]:
"""``task_pages`` (page hints) follow the task-id remap; a page
for a task that did not survive the remap is dropped with it."""
pages = meta.get("task_pages")
if not task_id_map or not isinstance(pages, dict):
return {}
return {task_id_map[t]: int(p) for t, p in pages.items() if t in task_id_map and isinstance(p, int) and not isinstance(p, bool) and p >= 1}
created = 0
for meta in docs:
if not isinstance(meta, dict):
continue
tags = [x for x in (meta.get("tags") or []) if isinstance(x, str)]
title = meta.get("title")
if meta.get("kind") == KIND_WEBLINK:
url = meta.get("url")
# Only http(s) links — the add-link WS path enforces the same, so
# a crafted export can't smuggle a javascript:/data: URL that the
# frontend would later window.open (matches ws_documents_add_link).
if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
continue
new_id = uuid4().hex
_remember(meta, new_id)
self.documents[new_id] = {
"object_id": object_id,
"kind": KIND_WEBLINK,
"url": url,
"title": title or url,
"tags": tags,
"task_ids": _remap(meta),
"part_ids": _remap_parts(meta),
"added_at": dt_util.utcnow().isoformat(),
}
created += 1
elif meta.get("kind") == KIND_FILE:
digest = meta.get("hash")
if not isinstance(digest, str) or len(digest) != 64 or not all(c in "0123456789abcdef" for c in digest):
continue
size = int(meta.get("size") or 0)
mime = meta.get("mime") or "application/octet-stream"
blob = self.blobs.get(digest)
if blob is None:
blob = {"size": size, "mime": mime, "refcount": 0}
self.blobs[digest] = blob
blob["refcount"] += 1
new_id = uuid4().hex
_remember(meta, new_id)
self.documents[new_id] = {
"object_id": object_id,
"kind": KIND_FILE,
"hash": digest,
"title": title or meta.get("filename") or "document",
"filename": meta.get("filename") or "document",
"mime": mime,
"size": size,
"tags": tags,
"task_ids": _remap(meta),
"part_ids": _remap_parts(meta),
"added_at": dt_util.utcnow().isoformat(),
}
created += 1
# One malformed record (tags not a list, size a string, …) must
# skip THAT record, not abort the caller's whole import — the
# JSON importer calls this outside its per-object try
# (bug audit 2026-09-12).
try:
created += self._import_one_document(meta, object_id, _remember, _remap, _remap_parts, _remap_pages)
except (TypeError, ValueError, AttributeError):
_LOGGER.warning("Skipping malformed document record %r during import", meta.get("id"))
if created:
await self._async_save()
return created
def _import_one_document(
self,
meta: dict[str, Any],
object_id: str,
remember: Callable[[dict[str, Any], str], None],
remap: Callable[[dict[str, Any]], list[str]],
remap_parts: Callable[[dict[str, Any]], list[str]],
remap_pages: Callable[[dict[str, Any]], dict[str, int]],
) -> int:
"""Recreate ONE exported document record; returns 1 if created, else 0."""
tags = [x for x in (meta.get("tags") or []) if isinstance(x, str)]
title = meta.get("title")
pages = remap_pages(meta)
if meta.get("kind") == KIND_WEBLINK:
url = meta.get("url")
# Only http(s) links — the add-link WS path enforces the same, so
# a crafted export can't smuggle a javascript:/data: URL that the
# frontend would later window.open (matches ws_documents_add_link).
if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
return 0
new_id = uuid4().hex
remember(meta, new_id)
self.documents[new_id] = {
"object_id": object_id,
"kind": KIND_WEBLINK,
"url": url,
"title": title or url,
"tags": tags,
"description": self.clean_description(meta.get("description")),
"task_ids": remap(meta),
"part_ids": remap_parts(meta),
**({"task_pages": pages} if pages else {}),
"added_at": dt_util.utcnow().isoformat(),
}
return 1
if meta.get("kind") == KIND_FILE:
digest = meta.get("hash")
if not isinstance(digest, str) or len(digest) != 64 or not all(c in "0123456789abcdef" for c in digest):
return 0
size = _safe_size(meta.get("size"))
mime = meta.get("mime") or "application/octet-stream"
blob = self.blobs.get(digest)
if blob is None:
blob = {"size": size, "mime": mime, "refcount": 0}
self.blobs[digest] = blob
blob["refcount"] += 1
new_id = uuid4().hex
remember(meta, new_id)
self.documents[new_id] = {
"object_id": object_id,
"kind": KIND_FILE,
"hash": digest,
"title": title or meta.get("filename") or "document",
"filename": meta.get("filename") or "document",
"mime": mime,
"size": size,
"tags": tags,
"description": self.clean_description(meta.get("description")),
"task_ids": remap(meta),
"part_ids": remap_parts(meta),
**({"task_pages": pages} if pages else {}),
"added_at": dt_util.utcnow().isoformat(),
}
return 1
return 0
# ------------------------------------------------------------------
# Update
# ------------------------------------------------------------------
@@ -378,8 +557,9 @@ class DocumentStore:
task_ids: list[str] | None = None,
task_pages: dict[str, int] | None = None,
part_ids: list[str] | None = None,
description: str | None = None,
) -> bool:
"""Update editable metadata (title / tags / task+part links / per-task page).
"""Update editable metadata (title / tags / description / task+part links / per-task page).
``task_pages`` is a ``{task_id: page}`` map merged into the doc: a page
``>= 1`` sets the jump-to page for that task's link, ``0`` clears it. Page
@@ -394,6 +574,8 @@ class DocumentStore:
doc["title"] = title
if tags is not None:
doc["tags"] = list(tags)
if description is not None:
doc["description"] = self.clean_description(description)
if task_ids is not None:
doc["task_ids"] = list(task_ids)
if part_ids is not None:
@@ -464,6 +646,62 @@ class DocumentStore:
await self._async_save()
return touched
def task_links(self, task_id: str) -> dict[str, int | None]:
"""``{doc_id: page hint or None}`` for every document linked to a task.
The snapshot ``task/move`` takes BEFORE its delete leg runs
:meth:`async_unlink_task`, so the links can be restored afterwards.
"""
links: dict[str, int | None] = {}
for doc_id, doc in self.documents.items():
linked = doc.get("task_ids")
if isinstance(linked, list) and task_id in linked:
pages = doc.get("task_pages")
page = pages.get(task_id) if isinstance(pages, dict) else None
links[doc_id] = page if isinstance(page, int) and page >= 1 else None
return links
async def async_relink_task(
self,
task_id: str,
links: dict[str, int | None],
*,
rehome_doc_ids: set[str] | None = None,
object_id: str | None = None,
) -> int:
"""Restore task links dropped by :meth:`async_unlink_task`; optionally
re-home some of the docs to another object. Returns the docs touched.
``task/move`` (bug audit 2026-09-12): the moved task's completion
photos are documents owned by the SOURCE object — when that object is
later deleted, ``async_remove_object`` takes the photos with it and
the history entries' ``photo_doc_ids`` dangle. Documents carry only
an ``object_id``, so re-homing is a plain re-stamp; the caller decides
which docs move (photos linked to nothing but this task) and which
merely keep their link (a manual shared with the object's other
tasks). Unknown doc ids are skipped.
"""
touched = 0
rehome = rehome_doc_ids or set()
for doc_id, page in links.items():
doc = self.documents.get(doc_id)
if doc is None:
continue
linked = list(doc.get("task_ids") or [])
if task_id not in linked:
linked.append(task_id)
doc["task_ids"] = linked
if page is not None:
pages = dict(doc.get("task_pages") or {})
pages[task_id] = page
doc["task_pages"] = pages
if object_id and doc_id in rehome:
doc["object_id"] = object_id
touched += 1
if touched:
await self._async_save()
return touched
async def async_remove_object(self, object_id: str) -> int:
"""Remove every document of an object (on object delete). Bytes freed."""
doc_ids = [did for did, d in self.documents.items() if d.get("object_id") == object_id]