tiptap-python-utils 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,15 +22,7 @@ from .model import (
22
22
  )
23
23
  from .contract.policy import content_id, is_parseable, node_id, tiptap_id
24
24
  from .select import Selection
25
- from .shared import (
26
- fingerprint_shared,
27
- has_shared,
28
- new_shared_id,
29
- shared_id,
30
- shared_families,
31
- stamp_shared,
32
- sync_shared,
33
- )
25
+ from .shared import SharedFamilies, fingerprint, new_shared_id
34
26
  from .tasks import has_open_tasks, open_tasks, syncable_tasks
35
27
  from .text import NodeText, text_slices, visible_text, word_count
36
28
  from .walk import Ref, Walker
@@ -52,6 +44,7 @@ __all__ = [
52
44
  "Paragraph",
53
45
  "Ref",
54
46
  "Selection",
47
+ "SharedFamilies",
55
48
  "TableCell",
56
49
  "TaskItem",
57
50
  "TaskList",
@@ -60,9 +53,8 @@ __all__ = [
60
53
  "Unknown",
61
54
  "Walker",
62
55
  "content_id",
63
- "fingerprint_shared",
56
+ "fingerprint",
64
57
  "has_open_tasks",
65
- "has_shared",
66
58
  "is_parseable",
67
59
  "key",
68
60
  "kind",
@@ -70,10 +62,6 @@ __all__ = [
70
62
  "node_id",
71
63
  "open_tasks",
72
64
  "registry",
73
- "shared_families",
74
- "shared_id",
75
- "stamp_shared",
76
- "sync_shared",
77
65
  "syncable_tasks",
78
66
  "text_slices",
79
67
  "tiptap_id",
@@ -28,6 +28,7 @@ from .model import (
28
28
  Text,
29
29
  )
30
30
  from .select import Selection
31
+ from .shared import SharedFamilies
31
32
  from .walk import Ref, Walker
32
33
 
33
34
 
@@ -117,6 +118,36 @@ class Content:
117
118
  tuple(ref for ref in self.refs() if ref.node.kind == node_kind),
118
119
  )
119
120
 
121
+ def where_shared_id(self, shared_id: str) -> Selection:
122
+ return Selection(
123
+ self,
124
+ tuple(
125
+ ref
126
+ for ref in self.refs(parseable=True)
127
+ if ref.node.shared_id == shared_id
128
+ ),
129
+ )
130
+
131
+ def has_shared(self, shared_id: str) -> bool:
132
+ return any(
133
+ ref.node.shared_id == shared_id for ref in self.refs(parseable=True)
134
+ )
135
+
136
+ def shared_families(self) -> SharedFamilies:
137
+ return SharedFamilies.from_content(self)
138
+
139
+ def sync_shared(self, families: SharedFamilies) -> "Content":
140
+ if self._root is None:
141
+ return self
142
+ refs = tuple(
143
+ ref
144
+ for ref in self.refs(parseable=True)
145
+ if ref.node.shared_id and ref.node.shared_id in families
146
+ )
147
+ if not refs:
148
+ return self
149
+ return Selection(self, refs).transform(families.merge)
150
+
120
151
  def append_root(self, node_or_raw: Any) -> "Content":
121
152
  return self.of(kind.DOC).append(node_or_raw)
122
153
 
@@ -79,6 +79,9 @@ class Node:
79
79
  attrs[name] = deepcopy(value)
80
80
  return replace(self, attrs=attrs, present=self.present | {key.ATTRS})
81
81
 
82
+ def with_shared_id(self, value: str) -> "Node":
83
+ return self.with_attr(key.SHARED_ID, value)
84
+
82
85
  def with_content(self, content: ContentTuple) -> "Node":
83
86
  return replace(self, content=content, present=self.present | {key.CONTENT})
84
87
 
@@ -66,10 +66,6 @@ class TaskItem(Node):
66
66
  def is_completed(self) -> bool:
67
67
  return bool(task_completion(self.attrs, self.extra))
68
68
 
69
- @property
70
- def shared_id(self) -> str | None:
71
- return policy.shared_id(self.attrs)
72
-
73
69
  def raw_attrs(self) -> Dict[str, Any]:
74
70
  attrs = super().raw_attrs()
75
71
  if key.CHECKED in attrs or (
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from copy import deepcopy
6
6
  from dataclasses import replace
7
- from typing import TYPE_CHECKING, Any, Iterator, Tuple
7
+ from typing import TYPE_CHECKING, Any, Callable, Iterator, Tuple
8
8
 
9
9
  from .. import codec
10
10
  from ..contract import key
@@ -69,6 +69,9 @@ class Selection:
69
69
  raise TiptapValidationError("Child node content must not be a document root")
70
70
  return self._apply(lambda node: node.append(child))
71
71
 
72
+ def transform(self, fn: Callable[[Node], Node]) -> "Content":
73
+ return self._apply(fn)
74
+
72
75
  def dump(self) -> str:
73
76
  return self._content.dump()
74
77
 
@@ -1,17 +1,11 @@
1
1
  """Shared-node service exports."""
2
2
 
3
- from .families import has_shared, shared_families
4
- from .fingerprint import fingerprint_shared
5
- from .identity import new_shared_id, normalize_shared_id, shared_id, stamp_shared
6
- from .sync import sync_shared
3
+ from .families import SharedFamilies
4
+ from .fingerprint import fingerprint
5
+ from .identity import new_shared_id
7
6
 
8
7
  __all__ = [
9
- "fingerprint_shared",
10
- "has_shared",
8
+ "SharedFamilies",
9
+ "fingerprint",
11
10
  "new_shared_id",
12
- "normalize_shared_id",
13
- "shared_families",
14
- "shared_id",
15
- "stamp_shared",
16
- "sync_shared",
17
11
  ]
@@ -1,47 +1,64 @@
1
- """Group canonical bodies by sharedId and answer presence queries."""
1
+ """SharedFamilies: canonical bodies grouped by sharedId."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from copy import deepcopy
6
- from typing import Any, Dict
5
+ from dataclasses import dataclass, field
6
+ from types import MappingProxyType
7
+ from typing import TYPE_CHECKING, Iterator, Mapping
7
8
 
8
- from ..content import Content
9
+ from .. import codec
9
10
  from ..contract import key
10
11
  from ..exceptions import TiptapValidationError
11
- from .fingerprint import fingerprint_shared
12
- from .identity import normalize_shared_id
13
-
14
-
15
- def shared_families(content: str | Dict[str, Any]) -> dict[str, dict[str, Any]]:
16
- """Return canonical node bodies grouped by sharedId."""
17
- tiptap = Content.require(content)
18
- families: dict[str, dict[str, Any]] = {}
19
- fingerprints: dict[str, str] = {}
20
-
21
- for ref in tiptap.refs(parseable=True):
22
- node = ref.node.raw()
23
- node_shared_id = normalize_shared_id(node.get(key.ATTRS, {}).get(key.SHARED_ID))
24
- if not node_shared_id:
25
- continue
26
-
27
- fingerprint = fingerprint_shared(node)
28
- if (
29
- node_shared_id in fingerprints
30
- and fingerprints[node_shared_id] != fingerprint
31
- ):
32
- raise TiptapValidationError(
33
- f"Conflicting node bodies detected for sharedId '{node_shared_id}'"
34
- )
35
- if node_shared_id not in families:
36
- families[node_shared_id] = deepcopy(node)
37
- fingerprints[node_shared_id] = fingerprint
38
-
39
- return families
40
-
41
-
42
- def has_shared(content: str | Dict[str, Any], shared_id: str) -> bool:
43
- tiptap = Content.require(content)
44
- for ref in tiptap.refs(parseable=True):
45
- if normalize_shared_id(ref.node.attrs.get(key.SHARED_ID)) == shared_id:
46
- return True
47
- return False
12
+ from ..model import Node
13
+ from .fingerprint import fingerprint
14
+
15
+ if TYPE_CHECKING:
16
+ from ..content import Content
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SharedFamilies:
21
+ """Canonical node bodies indexed by sharedId."""
22
+
23
+ _bodies: Mapping[str, Node] = field(default_factory=lambda: MappingProxyType({}))
24
+
25
+ @classmethod
26
+ def from_content(cls, content: "Content") -> "SharedFamilies":
27
+ bodies: dict[str, Node] = {}
28
+ prints: dict[str, str] = {}
29
+ for ref in content.refs(parseable=True):
30
+ sid = ref.node.shared_id
31
+ if not sid:
32
+ continue
33
+ fp = fingerprint(ref.node)
34
+ if sid in prints and prints[sid] != fp:
35
+ raise TiptapValidationError(
36
+ f"Conflicting node bodies detected for sharedId '{sid}'"
37
+ )
38
+ bodies.setdefault(sid, ref.node)
39
+ prints.setdefault(sid, fp)
40
+ return cls(MappingProxyType(bodies))
41
+
42
+ def __contains__(self, shared_id: str) -> bool:
43
+ return shared_id in self._bodies
44
+
45
+ def __getitem__(self, shared_id: str) -> Node:
46
+ return self._bodies[shared_id]
47
+
48
+ def __iter__(self) -> Iterator[str]:
49
+ return iter(self._bodies)
50
+
51
+ def __len__(self) -> int:
52
+ return len(self._bodies)
53
+
54
+ def merge(self, target: Node) -> Node:
55
+ """Return target rewritten from the canonical body, preserving local id and sharedId."""
56
+ canonical = self[target.shared_id]
57
+ raw = canonical.raw()
58
+ attrs = dict(raw.get(key.ATTRS, {}))
59
+ if target.id:
60
+ attrs[key.ID] = target.id
61
+ if target.shared_id:
62
+ attrs[key.SHARED_ID] = target.shared_id
63
+ raw[key.ATTRS] = attrs
64
+ return codec.read_node(raw)
@@ -1,21 +1,20 @@
1
- """Identity-stripped fingerprint of a raw node, used to detect divergent bodies."""
1
+ """Identity-stripped fingerprint of a node, used to detect divergent bodies."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from copy import deepcopy
7
- from typing import Any
8
6
 
9
7
  from ..contract import key
8
+ from ..model import Node
10
9
 
11
10
 
12
- def fingerprint_shared(node: dict[str, Any]) -> str:
13
- normalized = deepcopy(node)
14
- attrs = dict(normalized.get(key.ATTRS, {}))
11
+ def fingerprint(node: Node) -> str:
12
+ raw = node.raw()
13
+ attrs = dict(raw.get(key.ATTRS, {}))
15
14
  attrs.pop(key.ID, None)
16
15
  attrs.pop(key.SHARED_ID, None)
17
16
  if attrs:
18
- normalized[key.ATTRS] = attrs
17
+ raw[key.ATTRS] = attrs
19
18
  else:
20
- normalized.pop(key.ATTRS, None)
21
- return json.dumps(normalized, sort_keys=True)
19
+ raw.pop(key.ATTRS, None)
20
+ return json.dumps(raw, sort_keys=True)
@@ -1,48 +1,9 @@
1
- """Shared-node identity primitives.
2
-
3
- Pure value-level helpers: normalize a sharedId, mint a new one, read a node's
4
- sharedId, and stamp identity onto a raw node payload. No document traversal,
5
- no families, no sync — those live in their sibling modules.
6
- """
1
+ """Shared-node identity primitives."""
7
2
 
8
3
  from __future__ import annotations
9
4
 
10
- from typing import Any, Dict, Optional
11
5
  from uuid import uuid4
12
6
 
13
- from .. import codec
14
- from ..contract import key
15
-
16
-
17
- def normalize_shared_id(value: Any) -> Optional[str]:
18
- if value is None:
19
- return None
20
- if isinstance(value, str):
21
- stripped = value.strip()
22
- return stripped or None
23
- return str(value)
24
-
25
7
 
26
8
  def new_shared_id() -> str:
27
9
  return f"shared-{uuid4().hex}"
28
-
29
-
30
- def shared_id(node: str | Dict[str, Any]) -> Optional[str]:
31
- parsed = codec.read_node_input(node, label="Node content")
32
- return normalize_shared_id(parsed.attrs.get(key.SHARED_ID))
33
-
34
-
35
- def stamp_shared(
36
- node: str | Dict[str, Any],
37
- shared_id: str,
38
- local_id: Optional[str] = None,
39
- ) -> dict[str, Any]:
40
- """Return a deep-copied node with sharedId and optional local id stamped."""
41
- parsed = codec.read_node_input(node, label="Node content").raw()
42
- attrs = dict(parsed.get(key.ATTRS, {}))
43
-
44
- if local_id is not None:
45
- attrs[key.ID] = local_id
46
- attrs[key.SHARED_ID] = shared_id
47
- parsed[key.ATTRS] = attrs
48
- return parsed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tiptap_python_utils
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python utilities for parsing, traversing, editing, and serializing TipTap JSON content.
5
5
  Author: tiptap_python_utils contributors
6
6
  License: MIT License
@@ -219,36 +219,44 @@ task.shared_id # sharedId attr, if any
219
219
 
220
220
  ## Shared-Node Synchronization
221
221
 
222
- `shared_families` collects canonical bodies grouped by `sharedId`;
223
- `sync_shared` rewrites every matching node in a document using those canonical
224
- bodies while preserving per-instance identity (`id`, `sharedId`).
222
+ `Content.shared_families()` collects canonical bodies grouped by `sharedId` into
223
+ a `SharedFamilies` value object. `Content.sync_shared(families)` rewrites every
224
+ matching node in the document from those canonical bodies, preserving
225
+ per-instance identity (`id`, `sharedId`). Both return immutable values — the
226
+ original `Content` is never mutated.
225
227
 
226
228
  ```python
227
- from tiptap_python_utils import shared_families, sync_shared
229
+ from tiptap_python_utils import Content
228
230
 
229
231
  # Canonical doc: the source of truth for every shared body.
230
- canonical = {"type": "doc", "content": [
232
+ canonical = Content.require({"type": "doc", "content": [
231
233
  {
232
234
  "type": "paragraph",
233
235
  "attrs": {"id": "p1", "sharedId": "intro"},
234
236
  "content": [{"type": "text", "text": "Authoritative intro"}],
235
237
  }
236
- ]}
238
+ ]})
237
239
 
238
240
  # Doc that mirrors the same sharedId but with a stale body.
239
- target = {"type": "doc", "content": [
241
+ target = Content.require({"type": "doc", "content": [
240
242
  {
241
243
  "type": "paragraph",
242
244
  "attrs": {"id": "p1-copy", "sharedId": "intro"},
243
245
  "content": [{"type": "text", "text": "Stale copy"}],
244
246
  }
245
- ]}
247
+ ]})
246
248
 
247
- families = shared_families(canonical)
248
- synced_json, changed = sync_shared(target, families)
249
- assert changed is True
249
+ synced = target.sync_shared(canonical.shared_families())
250
+ assert synced.has_shared("intro")
250
251
  ```
251
252
 
253
+ Related helpers on `Content`:
254
+
255
+ - `content.where_shared_id(sid)` — `Selection` over every node with that sharedId.
256
+ - `content.has_shared(sid)` — quick presence check.
257
+ - `node.with_shared_id(sid)` — stamp a sharedId onto a node (returns a new node).
258
+ - `new_shared_id()` — mint a fresh `shared-…` identifier.
259
+
252
260
  ## Architecture (one paragraph)
253
261
 
254
262
  The package is layered: `contract` (key/kind/policy primitives) → `model`
@@ -268,13 +276,13 @@ Common imports are available from the package root:
268
276
  from tiptap_python_utils import (
269
277
  Content,
270
278
  Paragraph,
279
+ SharedFamilies,
271
280
  TaskItem,
272
281
  Text,
273
282
  has_open_tasks,
274
283
  kind,
284
+ new_shared_id,
275
285
  open_tasks,
276
- shared_families,
277
- sync_shared,
278
286
  text_slices,
279
287
  visible_text,
280
288
  word_count,
@@ -1,5 +1,5 @@
1
- tiptap_python_utils/__init__.py,sha256=okNcgTgISvshG4f7LCfGv4Q1GN2thdlPlIg7SAHWFTw,1590
2
- tiptap_python_utils/content.py,sha256=_-iwFt-4nDH4xxWkEFrzRVMPlufq43tRaKYJxNBBY1I,5557
1
+ tiptap_python_utils/__init__.py,sha256=J78QM5SdfIB3qEeZ0f0ejFJgE1lS8o6VJdCxD5_yCkc,1417
2
+ tiptap_python_utils/content.py,sha256=NfTow1rPtTKcBkYTyPA50y0mjmlq57uXnXwFeKgKH_0,6522
3
3
  tiptap_python_utils/exceptions.py,sha256=GIP91_6gaz4Vp-uMbmZBFFpnHHvToKYWyYf78Apt0ns,150
4
4
  tiptap_python_utils/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
5
5
  tiptap_python_utils/types.py,sha256=f6ocw9oOTyj_khush3bP5cHS2YpybKS0jQgjzSNVx_k,152
@@ -12,17 +12,16 @@ tiptap_python_utils/contract/key.py,sha256=6T4Y-26hESduXmLaNOOpbZ-zTYkHgABvDCHjL
12
12
  tiptap_python_utils/contract/kind.py,sha256=I-vkJ9mskw07qjbIR5uC1s3z0AvtwA2y3qovkzTsY2Q,297
13
13
  tiptap_python_utils/contract/policy.py,sha256=AeJfLv07JWqhuJMakUgz1Y-bMpac28h426bj17hKvwk,1193
14
14
  tiptap_python_utils/model/__init__.py,sha256=Xv2GP0fCM72H2_Jxo5vvJfStA6ztDavzjYcaV0U0p9g,1049
15
- tiptap_python_utils/model/base.py,sha256=Lq5mFvzQRV_bi368szFGjee8mM_AJNIjtHe9sECUr4M,4558
16
- tiptap_python_utils/model/nodes.py,sha256=FKUgRAZWINNhUgiImkhkY5NcB7EzuRdb6Da40JUNI68,2989
15
+ tiptap_python_utils/model/base.py,sha256=STQczhXwe49x48TrCXkIzU4F4vcz1FS65ipXS663Az4,4663
16
+ tiptap_python_utils/model/nodes.py,sha256=obi8prcwJeJ3MBGZNCdbiGBwiPNJhiKb08b3oW5tTm4,2891
17
17
  tiptap_python_utils/model/payload.py,sha256=UAWAaXgh50tglwFGBjkWj5ngMHQE35XQR1oHNysHdTw,2417
18
18
  tiptap_python_utils/model/registry.py,sha256=2TbiEvUkVh85W0I0YKjpeVivcvKFCvcO1GtYEzMNu6Q,1168
19
19
  tiptap_python_utils/select/__init__.py,sha256=lFavos0E3w2D_keUPDa5xkWhIjkQiee7yNK7X-G-U6o,88
20
- tiptap_python_utils/select/selection.py,sha256=NAcnYfB-HGu4SlAlWPwyRpRkgRzIBjYJR4CCtSRAzSU,4891
21
- tiptap_python_utils/shared/__init__.py,sha256=IC6p4jp-f8Fe1QVQC2UvqnmBnyo2AlaAEDghqREyKZg,428
22
- tiptap_python_utils/shared/families.py,sha256=v8XUDKighu7q1EXGTDiEZMFGVs9qGFe57usiuq1LHbc,1596
23
- tiptap_python_utils/shared/fingerprint.py,sha256=_5l2ZKpUuZAcIjkoodKqoBHgKAMjL_wFKyqprNsRV_M,560
24
- tiptap_python_utils/shared/identity.py,sha256=IqNjSfgDtFS3GR0DP_nExECXV0WgNC2NDBs3Nc4KJPA,1347
25
- tiptap_python_utils/shared/sync.py,sha256=k2523VJJsiIRNrvJyt9W11Z7JIoniKSxTnrwtv43O1A,2127
20
+ tiptap_python_utils/select/selection.py,sha256=oTJ5l4o4vr0yKMXcIfhZInzXFqD8X6T9ZhE2WSxpVEY,4999
21
+ tiptap_python_utils/shared/__init__.py,sha256=XoRaVFjOlCWbnTeTFKw5DdoLqGA6sGdRr0eGpSkFQS8,223
22
+ tiptap_python_utils/shared/families.py,sha256=7B39bXpEePfearOl2tzLDuJelo1yG67-anzVlmnidVs,2069
23
+ tiptap_python_utils/shared/fingerprint.py,sha256=YUKhUgPXIEFQ_ujUxnh1jPfxT1eilrFP_-hmqXmlkCM,476
24
+ tiptap_python_utils/shared/identity.py,sha256=cwUy6CMvlGV15Z8FyY3idDl2tPs9eiIWbwJRsNQgwY0,164
26
25
  tiptap_python_utils/tasks/__init__.py,sha256=EXNu9YfJHBjyUZGf5HJIvaW7Ub_aN6fw_CZBNjBW0Og,154
27
26
  tiptap_python_utils/tasks/query.py,sha256=59hb0fubBHlfOQekv_KY3DbTEBPFdYh_1Tc9LuFsL9w,464
28
27
  tiptap_python_utils/text/__init__.py,sha256=F8PVtbGQlshGAtNLGNhGt8eyOwyE3Z5GC2lZLovxWg4,170
@@ -31,8 +30,8 @@ tiptap_python_utils/tree/__init__.py,sha256=YzlXRkQrlRGYmkWbpZ8mutZOmK4BzmiZaBqU
31
30
  tiptap_python_utils/tree/path.py,sha256=bHmBFIZ3BV4FVlB79HhSgjeP6qHdNXClB9DFovSe8fs,1067
32
31
  tiptap_python_utils/walk/__init__.py,sha256=hKU7DhRY6HVlxCxyWmnZtoobWNx5MCIT0OoErGe4Bp4,120
33
32
  tiptap_python_utils/walk/traversal.py,sha256=6GDFmPAuo_t4FeOeV6hqrqvwcTZ8G93ep21-hrFt7S0,2320
34
- tiptap_python_utils-0.2.0.dist-info/licenses/LICENSE,sha256=pIGeAFdiaTJBpJilmEbR8YQ5agrt7DqbGlTTrzqO6Qo,1089
35
- tiptap_python_utils-0.2.0.dist-info/METADATA,sha256=CXKgXRn7hKpmQre_D9YMroy27b-k83RgFoGyLweOwpU,10411
36
- tiptap_python_utils-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
37
- tiptap_python_utils-0.2.0.dist-info/top_level.txt,sha256=t7g66MmK6WqTixs5_ZnXe4j-RvH9thuDRKvxk_8F0AQ,20
38
- tiptap_python_utils-0.2.0.dist-info/RECORD,,
33
+ tiptap_python_utils-0.3.0.dist-info/licenses/LICENSE,sha256=pIGeAFdiaTJBpJilmEbR8YQ5agrt7DqbGlTTrzqO6Qo,1089
34
+ tiptap_python_utils-0.3.0.dist-info/METADATA,sha256=wuFsehiOaIjsPpYh9FegYN6OeXddbnlnDbIufDlAyPA,10854
35
+ tiptap_python_utils-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
36
+ tiptap_python_utils-0.3.0.dist-info/top_level.txt,sha256=t7g66MmK6WqTixs5_ZnXe4j-RvH9thuDRKvxk_8F0AQ,20
37
+ tiptap_python_utils-0.3.0.dist-info/RECORD,,
@@ -1,63 +0,0 @@
1
- """Rewrite shared nodes from canonical bodies while preserving local identity."""
2
-
3
- from __future__ import annotations
4
-
5
- from copy import deepcopy
6
- from typing import Any, Dict
7
-
8
- from .. import codec
9
- from ..content import Content
10
- from ..contract import key
11
- from ..tree import node_at_path, replace_at_path
12
- from .identity import normalize_shared_id
13
-
14
-
15
- def sync_shared(
16
- content: str | Dict[str, Any],
17
- families: dict[str, dict[str, Any]],
18
- ) -> tuple[str, bool]:
19
- """Rewrite matching shared nodes using canonical bodies."""
20
- tiptap = Content.require(content)
21
- updated_root = tiptap._require_root()
22
- changed = False
23
-
24
- refs = tuple(tiptap.refs(parseable=True))
25
- for ref in sorted(refs, key=lambda item: len(item.path), reverse=True):
26
- current = node_at_path(updated_root, ref.path)
27
- current_raw = current.raw()
28
- current_shared_id = normalize_shared_id(
29
- current_raw.get(key.ATTRS, {}).get(key.SHARED_ID)
30
- )
31
- if not current_shared_id or current_shared_id not in families:
32
- continue
33
-
34
- replacement_raw = _merge_preserving_identity(
35
- current_raw,
36
- families[current_shared_id],
37
- )
38
- if replacement_raw == current_raw:
39
- continue
40
-
41
- replacement = codec.read_node_input(replacement_raw, label="Node content")
42
- updated_root = replace_at_path(updated_root, ref.path, replacement)
43
- changed = True
44
-
45
- return tiptap._with_root(updated_root).dump(), changed
46
-
47
-
48
- def _merge_preserving_identity(
49
- target_node: dict[str, Any],
50
- canonical_node: dict[str, Any],
51
- ) -> dict[str, Any]:
52
- replacement = deepcopy(canonical_node)
53
- target_attrs = target_node.get(key.ATTRS, {})
54
- replacement_attrs = dict(replacement.get(key.ATTRS, {}))
55
-
56
- if isinstance(target_attrs, dict) and target_attrs.get(key.ID):
57
- replacement_attrs[key.ID] = target_attrs[key.ID]
58
- target_shared_id = normalize_shared_id(target_attrs.get(key.SHARED_ID))
59
- if target_shared_id:
60
- replacement_attrs[key.SHARED_ID] = target_shared_id
61
-
62
- replacement[key.ATTRS] = replacement_attrs
63
- return replacement