278 files
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user