189 files

This commit is contained in:
Home Assistant Version Control
2026-09-08 18:05:32 +00:00
parent 79fefc0a7c
commit 303886f212
189 changed files with 21402 additions and 3514 deletions
@@ -18,7 +18,7 @@ import hashlib
import logging
import os
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from homeassistant.core import HomeAssistant
@@ -28,6 +28,9 @@ from homeassistant.util import dt as dt_util
from ..const import DOMAIN, MAX_DOCS_PER_OBJECT, SIGNAL_DOCUMENTS_UPDATED
if TYPE_CHECKING:
from .document_text import DocumentTextIndex
_LOGGER = logging.getLogger(__name__)
DOC_STORE_VERSION = 1
@@ -60,6 +63,10 @@ class DocumentStore:
self.hass = hass
self._store: Store[dict[str, Any]] = Store(hass, DOC_STORE_VERSION, DOC_STORE_KEY)
self._data: dict[str, Any] = {"documents": {}, "blobs": {}}
# The full-text index (#171) follows the blob lifecycle: told about
# every new blob, told when the last reference goes. Optional so the
# store stays usable on its own (tests, tooling).
self.text_index: DocumentTextIndex | None = None
# ------------------------------------------------------------------
# Paths
@@ -214,6 +221,7 @@ class DocumentStore:
await self._async_save()
if not wrote_new and not deduped:
_LOGGER.debug("Adopted pre-existing blob %s into registry", digest[:12])
self.notify_blob_added(digest)
return {
"id": doc_id,
"deduped": deduped,
@@ -221,6 +229,12 @@ class DocumentStore:
**doc,
}
def notify_blob_added(self, digest: str) -> None:
"""A blob is (now) referenced — let the text index extract it in the
background. Idempotent: an already-extracted blob is a no-op."""
if self.text_index is not None:
self.text_index.schedule(digest)
def _store_blob_sync(self, content: bytes) -> tuple[str, bool]:
"""Hash content and write the blob if absent. Returns (digest, wrote_new)."""
digest = hashlib.sha256(content).hexdigest()
@@ -263,6 +277,7 @@ class DocumentStore:
docs: list[dict[str, Any]],
task_id_map: dict[str, str] | None = None,
part_id_map: dict[str, str] | None = None,
id_map: dict[str, str] | None = None,
) -> int:
"""Recreate document metadata for an imported object (P6).
@@ -272,9 +287,17 @@ class DocumentStore:
absent and the hygiene scan flags the doc as dangling. ``task_ids`` /
``part_ids`` are remapped through their old→new id maps so a doc's
task and spare-part links survive the import; ids with no mapping are
dropped. Returns the number created.
dropped. ``id_map`` (if given) is filled with old→new document ids
for every doc whose export carried an ``id`` — the importer uses it
to re-point completion photos and part ``doc_id`` links. Returns the
number created.
"""
def _remember(meta: dict[str, Any], new_id: str) -> None:
old_id = meta.get("id")
if id_map is not None and isinstance(old_id, str) and old_id:
id_map[old_id] = new_id
def _remap(meta: dict[str, Any]) -> list[str]:
if not task_id_map:
return []
@@ -298,7 +321,9 @@ class DocumentStore:
# frontend would later window.open (matches ws_documents_add_link).
if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
continue
self.documents[uuid4().hex] = {
new_id = uuid4().hex
_remember(meta, new_id)
self.documents[new_id] = {
"object_id": object_id,
"kind": KIND_WEBLINK,
"url": url,
@@ -320,7 +345,9 @@ class DocumentStore:
blob = {"size": size, "mime": mime, "refcount": 0}
self.blobs[digest] = blob
blob["refcount"] += 1
self.documents[uuid4().hex] = {
new_id = uuid4().hex
_remember(meta, new_id)
self.documents[new_id] = {
"object_id": object_id,
"kind": KIND_FILE,
"hash": digest,
@@ -464,15 +491,24 @@ class DocumentStore:
if blob["refcount"] <= 0:
size = int(blob.get("size", 0))
self.blobs.pop(digest, None)
if self.text_index is not None:
await self.text_index.async_forget(digest)
await self.hass.async_add_executor_job(self._delete_blob_sync, digest)
return size
return 0
def _delete_blob_sync(self, digest: str) -> None:
"""Delete a blob file and its extracted-text sidecar (executor)."""
from .document_text import text_sidecar_path
try:
self.blob_path(digest).unlink(missing_ok=True)
except OSError:
_LOGGER.warning("Could not delete blob %s", digest[:12])
try:
text_sidecar_path(self.hass, digest).unlink(missing_ok=True)
except (OSError, ValueError):
_LOGGER.debug("Could not delete text sidecar %s", digest[:12])
# ------------------------------------------------------------------
# Storage summary (backs the sensor + the panel overview)