33from __future__ import annotations
44
55import logging
6+ import re
67import uuid
78from dataclasses import dataclass , field
89from datetime import date , datetime , timezone
@@ -101,6 +102,22 @@ def format_datetime(dt: datetime | None) -> str | None:
101102 return utc_dt .isoformat ().replace ("+00:00" , "Z" )
102103
103104
105+ TEMPLATE_ID_MAX_LENGTH = 80
106+ _TEMPLATE_ID_RE = re .compile (r"[a-z0-9_.]+" )
107+
108+
109+ def clean_template_id (raw : Any ) -> str :
110+ """A library template id (``make_bed``, ``pack.pet_care.2``) or "".
111+
112+ Anything else — a restored backup's junk, a too-long string — reads as
113+ "", which only means the library won't recognise the item as added.
114+ """
115+ value = str (raw or "" ).strip ()
116+ if len (value ) > TEMPLATE_ID_MAX_LENGTH or not _TEMPLATE_ID_RE .fullmatch (value ):
117+ return ""
118+ return value
119+
120+
104121def normalize_tag_ids (raw : Any ) -> list [str ]:
105122 """Clean a chore's linked HA tag ids (#923).
106123
@@ -491,6 +508,10 @@ class Chore:
491508 # one completion (0 = no cap).
492509 quantity_unit : str = ""
493510 quantity_max : int = 0
511+ # The library entry this chore was added from (a catalogue id such as
512+ # ``make_bed``, or ``pack.<pack>.<n>``), so the 📚 library can show it as
513+ # already added. "" for chores made by hand.
514+ template_id : str = ""
494515 id : str = field (default_factory = generate_id )
495516
496517 @classmethod
@@ -576,6 +597,7 @@ def from_dict(cls, data: dict[str, Any]) -> Chore:
576597 reference_image_url = str (data .get ("reference_image_url" , "" ) or "" ),
577598 quantity_unit = str (data .get ("quantity_unit" , "" ) or "" ).strip ()[:QUANTITY_UNIT_MAX_LENGTH ],
578599 quantity_max = _clamp_int (data .get ("quantity_max" ), 0 , MAX_QUANTITY ),
600+ template_id = clean_template_id (data .get ("template_id" )),
579601 id = data .get ("id" ) or generate_id (),
580602 )
581603
@@ -648,6 +670,7 @@ def to_dict(self) -> dict[str, Any]:
648670 "reference_image_url" : self .reference_image_url ,
649671 "quantity_unit" : self .quantity_unit ,
650672 "quantity_max" : self .quantity_max ,
673+ "template_id" : self .template_id ,
651674 "id" : self .id ,
652675 }
653676
@@ -741,6 +764,10 @@ class Reward:
741764 # Streak freeze (#925): approving a claim hands the child one streak-freeze
742765 # token instead of anything physical. Always a fixed cost like any reward.
743766 streak_freeze : bool = False
767+ # The library entry this reward was added from (a catalogue id such as
768+ # ``sleepover``); "" for rewards made by hand. Only used to show "already
769+ # added".
770+ template_id : str = ""
744771 id : str = field (default_factory = generate_id )
745772
746773 def __post_init__ (self ) -> None :
@@ -783,6 +810,7 @@ def from_dict(cls, data: dict[str, Any]) -> Reward:
783810 available_from = str (data .get ("available_from" , "" ) or "" ),
784811 available_until = str (data .get ("available_until" , "" ) or "" ),
785812 streak_freeze = bool (data .get ("streak_freeze" , False )),
813+ template_id = clean_template_id (data .get ("template_id" )),
786814 id = data .get ("id" ) or generate_id (),
787815 )
788816
@@ -809,6 +837,7 @@ def to_dict(self) -> dict[str, Any]:
809837 "available_from" : self .available_from ,
810838 "available_until" : self .available_until ,
811839 "streak_freeze" : self .streak_freeze ,
840+ "template_id" : self .template_id ,
812841 "id" : self .id ,
813842 }
814843
0 commit comments