tiptap-python-utils 0.1.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.
- tiptap_python_utils/__init__.py +85 -0
- tiptap_python_utils/codec/__init__.py +29 -0
- tiptap_python_utils/codec/json.py +116 -0
- tiptap_python_utils/content.py +174 -0
- tiptap_python_utils/contract/__init__.py +5 -0
- tiptap_python_utils/contract/key.py +14 -0
- tiptap_python_utils/contract/kind.py +14 -0
- tiptap_python_utils/contract/policy.py +52 -0
- tiptap_python_utils/edit/__init__.py +12 -0
- tiptap_python_utils/edit/commands.py +163 -0
- tiptap_python_utils/exceptions.py +5 -0
- tiptap_python_utils/model/__init__.py +345 -0
- tiptap_python_utils/py.typed +1 -0
- tiptap_python_utils/select/__init__.py +5 -0
- tiptap_python_utils/select/selection.py +72 -0
- tiptap_python_utils/shared/__init__.py +23 -0
- tiptap_python_utils/shared/service.py +147 -0
- tiptap_python_utils/tasks/__init__.py +5 -0
- tiptap_python_utils/tasks/query.py +18 -0
- tiptap_python_utils/text/__init__.py +5 -0
- tiptap_python_utils/text/extract.py +113 -0
- tiptap_python_utils/tree/__init__.py +5 -0
- tiptap_python_utils/tree/path.py +36 -0
- tiptap_python_utils/types.py +7 -0
- tiptap_python_utils/walk/__init__.py +5 -0
- tiptap_python_utils/walk/traversal.py +88 -0
- tiptap_python_utils-0.1.0.dist-info/METADATA +176 -0
- tiptap_python_utils-0.1.0.dist-info/RECORD +31 -0
- tiptap_python_utils-0.1.0.dist-info/WHEEL +5 -0
- tiptap_python_utils-0.1.0.dist-info/licenses/LICENSE +21 -0
- tiptap_python_utils-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Typed TipTap AST model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from typing import Any, ClassVar, Dict, Iterator, Mapping, Tuple, TypeVar
|
|
8
|
+
|
|
9
|
+
from ..contract import key, kind, policy
|
|
10
|
+
|
|
11
|
+
ContentTuple = Tuple["Node", ...]
|
|
12
|
+
MarksTuple = Tuple[Any, ...]
|
|
13
|
+
NodeT = TypeVar("NodeT", bound="Node")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Node:
|
|
18
|
+
"""Base typed TipTap node."""
|
|
19
|
+
|
|
20
|
+
id: str = ""
|
|
21
|
+
content: ContentTuple = field(default_factory=tuple)
|
|
22
|
+
attrs: Dict[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
23
|
+
extra: Dict[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
24
|
+
present: frozenset[str] = field(default_factory=frozenset, compare=False, repr=False)
|
|
25
|
+
|
|
26
|
+
kind: ClassVar[str]
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if not self.id:
|
|
30
|
+
object.__setattr__(self, "id", policy.content_id(self.attrs))
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def read(cls: type[NodeT], raw: Mapping[str, Any], children: ContentTuple) -> NodeT:
|
|
34
|
+
return cls(**_payload(raw, children))
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def shared_id(self) -> str | None:
|
|
38
|
+
return policy.shared_id(self.attrs)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def text(self) -> str:
|
|
42
|
+
return " ".join(part for part in self.iter_text() if part)
|
|
43
|
+
|
|
44
|
+
def iter_text(self) -> Iterator[str]:
|
|
45
|
+
for child in self.content:
|
|
46
|
+
yield from child.iter_text()
|
|
47
|
+
|
|
48
|
+
def raw(self) -> Dict[str, Any]:
|
|
49
|
+
raw: Dict[str, Any] = {key.TYPE: self.kind}
|
|
50
|
+
raw.update(deepcopy(self.extra))
|
|
51
|
+
|
|
52
|
+
attrs = self.raw_attrs()
|
|
53
|
+
if attrs or key.ATTRS in self.present:
|
|
54
|
+
raw[key.ATTRS] = attrs
|
|
55
|
+
|
|
56
|
+
if self.content or key.CONTENT in self.present:
|
|
57
|
+
raw[key.CONTENT] = [child.raw() for child in self.content]
|
|
58
|
+
return raw
|
|
59
|
+
|
|
60
|
+
def raw_attrs(self) -> Dict[str, Any]:
|
|
61
|
+
attrs = deepcopy(self.attrs)
|
|
62
|
+
if self.id and not _has_any_identity(attrs):
|
|
63
|
+
attrs[key.ID] = self.id
|
|
64
|
+
return attrs
|
|
65
|
+
|
|
66
|
+
def with_text(self, value: str) -> "Node":
|
|
67
|
+
if not self.content:
|
|
68
|
+
return self
|
|
69
|
+
return replace(self, content=(Text(value=value),))
|
|
70
|
+
|
|
71
|
+
def with_attr(self, name: str, value: Any) -> "Node":
|
|
72
|
+
attrs = deepcopy(self.attrs)
|
|
73
|
+
attrs[name] = deepcopy(value)
|
|
74
|
+
return replace(self, attrs=attrs, present=self.present | {key.ATTRS})
|
|
75
|
+
|
|
76
|
+
def append(self, child: "Node") -> "Node":
|
|
77
|
+
return replace(self, content=self.content + (child,), present=self.present | {key.CONTENT})
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Text(Node):
|
|
82
|
+
value: str = ""
|
|
83
|
+
marks: MarksTuple = ()
|
|
84
|
+
|
|
85
|
+
kind: ClassVar[str] = kind.TEXT
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def read(cls, raw: Mapping[str, Any], children: ContentTuple) -> "Text":
|
|
89
|
+
value = raw.get(key.TEXT, "")
|
|
90
|
+
marks = raw.get(key.MARKS, [])
|
|
91
|
+
return cls(
|
|
92
|
+
value=value if isinstance(value, str) else str(value),
|
|
93
|
+
marks=tuple(deepcopy(marks)) if isinstance(marks, list) else (),
|
|
94
|
+
**_payload(raw, children, extra_keys={key.TEXT, key.MARKS}),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def text(self) -> str:
|
|
99
|
+
return self.value.strip()
|
|
100
|
+
|
|
101
|
+
def iter_text(self) -> Iterator[str]:
|
|
102
|
+
if self.text:
|
|
103
|
+
yield self.text
|
|
104
|
+
|
|
105
|
+
def raw(self) -> Dict[str, Any]:
|
|
106
|
+
raw = super().raw()
|
|
107
|
+
if self.value or key.TEXT in self.present:
|
|
108
|
+
raw[key.TEXT] = self.value
|
|
109
|
+
if self.marks or key.MARKS in self.present:
|
|
110
|
+
raw[key.MARKS] = [deepcopy(mark) for mark in self.marks]
|
|
111
|
+
return raw
|
|
112
|
+
|
|
113
|
+
def with_text(self, value: str) -> "Text":
|
|
114
|
+
return replace(self, value=value, present=self.present | {key.TEXT})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class Doc(Node):
|
|
119
|
+
kind: ClassVar[str] = kind.DOC
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class Paragraph(Node):
|
|
124
|
+
kind: ClassVar[str] = kind.PARAGRAPH
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class Heading(Node):
|
|
129
|
+
level: int = 1
|
|
130
|
+
|
|
131
|
+
kind: ClassVar[str] = kind.HEADING
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def read(cls, raw: Mapping[str, Any], children: ContentTuple) -> "Heading":
|
|
135
|
+
return cls(level=_heading_level(raw), **_payload(raw, children))
|
|
136
|
+
|
|
137
|
+
def raw_attrs(self) -> Dict[str, Any]:
|
|
138
|
+
attrs = super().raw_attrs()
|
|
139
|
+
if key.LEVEL in attrs or key.ATTRS not in self.present:
|
|
140
|
+
attrs.setdefault(key.LEVEL, self.level)
|
|
141
|
+
return attrs
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class TaskItem(Node):
|
|
146
|
+
task_item_id: str = ""
|
|
147
|
+
is_completed: bool = False
|
|
148
|
+
local_task_item_id: str = ""
|
|
149
|
+
canonical_task_item_id: str = ""
|
|
150
|
+
is_linked_copy: bool = False
|
|
151
|
+
|
|
152
|
+
kind: ClassVar[str] = kind.TASK_ITEM
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def read(cls, raw: Mapping[str, Any], children: ContentTuple) -> "TaskItem":
|
|
156
|
+
completed = _task_completion(raw)
|
|
157
|
+
local_id = policy.content_id(raw.get(key.ATTRS, {}))
|
|
158
|
+
canonical_id = _task_canonical_id(raw, local_id)
|
|
159
|
+
return cls(
|
|
160
|
+
task_item_id=canonical_id,
|
|
161
|
+
is_completed=bool(completed),
|
|
162
|
+
local_task_item_id=local_id,
|
|
163
|
+
canonical_task_item_id=canonical_id,
|
|
164
|
+
is_linked_copy=bool(local_id and canonical_id and local_id != canonical_id),
|
|
165
|
+
**_payload(raw, children),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def __post_init__(self) -> None:
|
|
169
|
+
super().__post_init__()
|
|
170
|
+
if not self.local_task_item_id and self.task_item_id:
|
|
171
|
+
object.__setattr__(self, "local_task_item_id", self.task_item_id)
|
|
172
|
+
if not self.canonical_task_item_id and self.task_item_id:
|
|
173
|
+
object.__setattr__(self, "canonical_task_item_id", self.task_item_id)
|
|
174
|
+
if (
|
|
175
|
+
not self.is_linked_copy
|
|
176
|
+
and self.local_task_item_id
|
|
177
|
+
and self.canonical_task_item_id
|
|
178
|
+
and self.local_task_item_id != self.canonical_task_item_id
|
|
179
|
+
):
|
|
180
|
+
object.__setattr__(self, "is_linked_copy", True)
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def shared_id(self) -> str | None:
|
|
184
|
+
return policy.shared_id(self.attrs)
|
|
185
|
+
|
|
186
|
+
def raw_attrs(self) -> Dict[str, Any]:
|
|
187
|
+
attrs = super().raw_attrs()
|
|
188
|
+
if self.local_task_item_id and not _has_any_identity(attrs):
|
|
189
|
+
attrs[key.ID] = self.local_task_item_id
|
|
190
|
+
if self.canonical_task_item_id and (
|
|
191
|
+
key.TASK_CANONICAL_ID in attrs
|
|
192
|
+
or self.canonical_task_item_id != self.local_task_item_id
|
|
193
|
+
):
|
|
194
|
+
attrs.setdefault(key.TASK_CANONICAL_ID, self.canonical_task_item_id)
|
|
195
|
+
if key.CHECKED in attrs or (
|
|
196
|
+
key.ATTRS not in self.present and key.STATUS not in self.extra
|
|
197
|
+
):
|
|
198
|
+
attrs.setdefault(key.CHECKED, self.is_completed)
|
|
199
|
+
return attrs
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class ListItem(Node):
|
|
204
|
+
kind: ClassVar[str] = kind.LIST_ITEM
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class CodeBlock(Node):
|
|
209
|
+
kind: ClassVar[str] = kind.CODE_BLOCK
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True)
|
|
213
|
+
class TaskList(Node):
|
|
214
|
+
kind: ClassVar[str] = kind.TASK_LIST
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class BulletList(Node):
|
|
219
|
+
kind: ClassVar[str] = kind.BULLET_LIST
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True)
|
|
223
|
+
class OrderedList(Node):
|
|
224
|
+
kind: ClassVar[str] = kind.ORDERED_LIST
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(frozen=True)
|
|
228
|
+
class Blockquote(Node):
|
|
229
|
+
kind: ClassVar[str] = kind.BLOCKQUOTE
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True)
|
|
233
|
+
class TableCell(Node):
|
|
234
|
+
kind: ClassVar[str] = kind.TABLE_CELL
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass(frozen=True)
|
|
238
|
+
class Unknown(Node):
|
|
239
|
+
raw_kind: str = ""
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
def read(cls, raw: Mapping[str, Any], children: ContentTuple) -> "Unknown":
|
|
243
|
+
return cls(raw_kind=str(raw.get(key.TYPE, "")), **_payload(raw, children))
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def kind(self) -> str:
|
|
247
|
+
return self.raw_kind
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class Registry:
|
|
251
|
+
"""Map raw TipTap kinds to typed node classes."""
|
|
252
|
+
|
|
253
|
+
def __init__(self) -> None:
|
|
254
|
+
self._classes: dict[str, type[Node]] = {}
|
|
255
|
+
|
|
256
|
+
def register(self, node_class: type[Node]) -> type[Node]:
|
|
257
|
+
self._classes[node_class.kind] = node_class
|
|
258
|
+
return node_class
|
|
259
|
+
|
|
260
|
+
def read(self, raw: Mapping[str, Any], children: ContentTuple) -> Node:
|
|
261
|
+
node_kind = str(raw.get(key.TYPE, ""))
|
|
262
|
+
node_class = self._classes.get(node_kind, Unknown)
|
|
263
|
+
return node_class.read(raw, children)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
registry = Registry()
|
|
267
|
+
for _node_class in (
|
|
268
|
+
Doc,
|
|
269
|
+
Text,
|
|
270
|
+
Paragraph,
|
|
271
|
+
Heading,
|
|
272
|
+
TaskItem,
|
|
273
|
+
ListItem,
|
|
274
|
+
CodeBlock,
|
|
275
|
+
TaskList,
|
|
276
|
+
BulletList,
|
|
277
|
+
OrderedList,
|
|
278
|
+
Blockquote,
|
|
279
|
+
TableCell,
|
|
280
|
+
):
|
|
281
|
+
registry.register(_node_class)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _payload(
|
|
285
|
+
raw: Mapping[str, Any],
|
|
286
|
+
children: ContentTuple,
|
|
287
|
+
*,
|
|
288
|
+
extra_keys: set[str] | None = None,
|
|
289
|
+
) -> dict[str, Any]:
|
|
290
|
+
present = frozenset(str(name) for name in raw.keys())
|
|
291
|
+
known = {key.TYPE, key.ATTRS, key.CONTENT} | (extra_keys or set())
|
|
292
|
+
attrs = raw.get(key.ATTRS, {})
|
|
293
|
+
return {
|
|
294
|
+
"id": policy.content_id(attrs),
|
|
295
|
+
"content": children,
|
|
296
|
+
"attrs": deepcopy(attrs) if isinstance(attrs, dict) else {},
|
|
297
|
+
"extra": {name: deepcopy(value) for name, value in raw.items() if name not in known},
|
|
298
|
+
"present": present,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _heading_level(raw: Mapping[str, Any]) -> int:
|
|
303
|
+
attrs = raw.get(key.ATTRS, {})
|
|
304
|
+
level = attrs.get(key.LEVEL, 1) if isinstance(attrs, dict) else 1
|
|
305
|
+
return level if isinstance(level, int) and 1 <= level <= 6 else 1
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _task_canonical_id(raw: Mapping[str, Any], fallback: str) -> str:
|
|
309
|
+
attrs = raw.get(key.ATTRS, {})
|
|
310
|
+
if not isinstance(attrs, dict):
|
|
311
|
+
return fallback
|
|
312
|
+
|
|
313
|
+
canonical_id = attrs.get(key.TASK_CANONICAL_ID)
|
|
314
|
+
if isinstance(canonical_id, str):
|
|
315
|
+
stripped = canonical_id.strip()
|
|
316
|
+
if stripped:
|
|
317
|
+
return stripped
|
|
318
|
+
if canonical_id is not None:
|
|
319
|
+
return str(canonical_id)
|
|
320
|
+
return fallback
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _task_completion(raw: Mapping[str, Any]) -> bool | None:
|
|
324
|
+
status = raw.get(key.STATUS)
|
|
325
|
+
if isinstance(status, str) and status.lower() in {"done", "pending"}:
|
|
326
|
+
return status.lower() == "done"
|
|
327
|
+
|
|
328
|
+
attrs = raw.get(key.ATTRS, {})
|
|
329
|
+
if not isinstance(attrs, dict):
|
|
330
|
+
attrs = {}
|
|
331
|
+
|
|
332
|
+
status = attrs.get(key.STATUS)
|
|
333
|
+
if isinstance(status, str) and status.lower() in {"done", "pending"}:
|
|
334
|
+
return status.lower() == "done"
|
|
335
|
+
|
|
336
|
+
checked = attrs.get(key.CHECKED)
|
|
337
|
+
if isinstance(checked, bool):
|
|
338
|
+
return checked
|
|
339
|
+
if isinstance(checked, str) and checked.strip().lower() in {"true", "false"}:
|
|
340
|
+
return checked.strip().lower() == "true"
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _has_any_identity(attrs: Dict[str, Any]) -> bool:
|
|
345
|
+
return key.ID in attrs or key.TIPTAP_ID in attrs
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Fluent TipTap selection API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Iterator, Tuple
|
|
6
|
+
|
|
7
|
+
from ..exceptions import TiptapValidationError
|
|
8
|
+
|
|
9
|
+
from .. import codec
|
|
10
|
+
from ..edit import append_child, set_attr, set_key, set_text
|
|
11
|
+
from ..model import Doc, Node
|
|
12
|
+
from ..tree import node_at_path, replace_at_path
|
|
13
|
+
from ..walk import Ref
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ..content import Content
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Selection:
|
|
20
|
+
"""A selected set of TipTap nodes that can be edited immutably."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, content: "Content", refs: Tuple[Ref, ...]) -> None:
|
|
23
|
+
self._content = content
|
|
24
|
+
self._refs = refs
|
|
25
|
+
|
|
26
|
+
def __iter__(self) -> Iterator[Ref]:
|
|
27
|
+
return iter(self._refs)
|
|
28
|
+
|
|
29
|
+
def __len__(self) -> int:
|
|
30
|
+
return len(self._refs)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def refs(self) -> Tuple[Ref, ...]:
|
|
34
|
+
return self._refs
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def nodes(self) -> Tuple[Node, ...]:
|
|
38
|
+
return tuple(ref.node for ref in self._refs)
|
|
39
|
+
|
|
40
|
+
def text(self, value: str) -> "Content":
|
|
41
|
+
return self._apply(lambda node: set_text(node, value))
|
|
42
|
+
|
|
43
|
+
def set(self, name: str, value: Any) -> "Content":
|
|
44
|
+
return self._apply(lambda node: set_key(node, name, value))
|
|
45
|
+
|
|
46
|
+
def attr(self, name: str, value: Any) -> "Content":
|
|
47
|
+
return self._apply(lambda node: set_attr(node, name, value))
|
|
48
|
+
|
|
49
|
+
def replace(self, node_or_raw: Any) -> "Content":
|
|
50
|
+
replacement = codec.read_node_input(node_or_raw, label="Node content")
|
|
51
|
+
return self._apply(lambda _node: replacement)
|
|
52
|
+
|
|
53
|
+
def append(self, node_or_raw: Any) -> "Content":
|
|
54
|
+
child = codec.read_node_input(node_or_raw, label="Node content")
|
|
55
|
+
if isinstance(child, Doc):
|
|
56
|
+
raise TiptapValidationError("Child node content must not be a document root")
|
|
57
|
+
return self._apply(lambda node: append_child(node, child))
|
|
58
|
+
|
|
59
|
+
def dump(self) -> str:
|
|
60
|
+
return self._content.dump()
|
|
61
|
+
|
|
62
|
+
def _apply(self, transform: Any) -> "Content":
|
|
63
|
+
root = self._content._require_root()
|
|
64
|
+
updated: Node = root
|
|
65
|
+
|
|
66
|
+
for ref in sorted(self._refs, key=lambda item: len(item.path), reverse=True):
|
|
67
|
+
current = node_at_path(updated, ref.path)
|
|
68
|
+
updated = replace_at_path(updated, ref.path, transform(current))
|
|
69
|
+
|
|
70
|
+
if not isinstance(updated, Doc):
|
|
71
|
+
raise TiptapValidationError("Document root must remain a TipTap doc")
|
|
72
|
+
return self._content._with_root(updated)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared-node service exports."""
|
|
2
|
+
|
|
3
|
+
from .service import (
|
|
4
|
+
fingerprint_shared,
|
|
5
|
+
has_shared,
|
|
6
|
+
new_shared_id,
|
|
7
|
+
normalize_shared_id,
|
|
8
|
+
shared_families,
|
|
9
|
+
shared_id,
|
|
10
|
+
stamp_shared,
|
|
11
|
+
sync_shared,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"fingerprint_shared",
|
|
16
|
+
"has_shared",
|
|
17
|
+
"new_shared_id",
|
|
18
|
+
"normalize_shared_id",
|
|
19
|
+
"shared_families",
|
|
20
|
+
"shared_id",
|
|
21
|
+
"stamp_shared",
|
|
22
|
+
"sync_shared",
|
|
23
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Shared-node behavior for TipTap documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from ..exceptions import TiptapValidationError
|
|
11
|
+
|
|
12
|
+
from .. import codec
|
|
13
|
+
from ..content import Content
|
|
14
|
+
from ..contract import key
|
|
15
|
+
from ..tree import node_at_path, replace_at_path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def shared_families(content: str | Dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
19
|
+
"""Return canonical node bodies grouped by sharedId."""
|
|
20
|
+
tiptap = Content.require(content)
|
|
21
|
+
families: dict[str, dict[str, Any]] = {}
|
|
22
|
+
fingerprints: dict[str, str] = {}
|
|
23
|
+
|
|
24
|
+
for ref in tiptap.refs(parseable=True):
|
|
25
|
+
node = ref.node.raw()
|
|
26
|
+
node_shared_id = normalize_shared_id(node.get(key.ATTRS, {}).get(key.SHARED_ID))
|
|
27
|
+
if not node_shared_id:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
fingerprint = fingerprint_shared(node)
|
|
31
|
+
if (
|
|
32
|
+
node_shared_id in fingerprints
|
|
33
|
+
and fingerprints[node_shared_id] != fingerprint
|
|
34
|
+
):
|
|
35
|
+
raise TiptapValidationError(
|
|
36
|
+
f"Conflicting node bodies detected for sharedId '{node_shared_id}'"
|
|
37
|
+
)
|
|
38
|
+
if node_shared_id not in families:
|
|
39
|
+
families[node_shared_id] = deepcopy(node)
|
|
40
|
+
fingerprints[node_shared_id] = fingerprint
|
|
41
|
+
|
|
42
|
+
return families
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def stamp_shared(
|
|
46
|
+
node: str | Dict[str, Any],
|
|
47
|
+
shared_id: str,
|
|
48
|
+
local_id: Optional[str] = None,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
"""Return a deep-copied node with sharedId and optional local id stamped."""
|
|
51
|
+
parsed = codec.read_node_input(node, label="Node content").raw()
|
|
52
|
+
attrs = dict(parsed.get(key.ATTRS, {}))
|
|
53
|
+
|
|
54
|
+
if local_id is not None:
|
|
55
|
+
attrs[key.ID] = local_id
|
|
56
|
+
attrs[key.SHARED_ID] = shared_id
|
|
57
|
+
parsed[key.ATTRS] = attrs
|
|
58
|
+
return parsed
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def has_shared(content: str | Dict[str, Any], shared_id: str) -> bool:
|
|
62
|
+
tiptap = Content.require(content)
|
|
63
|
+
for ref in tiptap.refs(parseable=True):
|
|
64
|
+
if normalize_shared_id(ref.node.attrs.get(key.SHARED_ID)) == shared_id:
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def shared_id(node: str | Dict[str, Any]) -> Optional[str]:
|
|
70
|
+
parsed = codec.read_node_input(node, label="Node content")
|
|
71
|
+
return normalize_shared_id(parsed.attrs.get(key.SHARED_ID))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def new_shared_id() -> str:
|
|
75
|
+
return f"shared-{uuid4().hex}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fingerprint_shared(node: dict[str, Any]) -> str:
|
|
79
|
+
normalized = deepcopy(node)
|
|
80
|
+
attrs = dict(normalized.get(key.ATTRS, {}))
|
|
81
|
+
attrs.pop(key.ID, None)
|
|
82
|
+
attrs.pop(key.SHARED_ID, None)
|
|
83
|
+
if attrs:
|
|
84
|
+
normalized[key.ATTRS] = attrs
|
|
85
|
+
else:
|
|
86
|
+
normalized.pop(key.ATTRS, None)
|
|
87
|
+
return json.dumps(normalized, sort_keys=True)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def sync_shared(
|
|
91
|
+
content: str | Dict[str, Any],
|
|
92
|
+
families: dict[str, dict[str, Any]],
|
|
93
|
+
) -> tuple[str, bool]:
|
|
94
|
+
"""Rewrite matching shared nodes using canonical bodies."""
|
|
95
|
+
tiptap = Content.require(content)
|
|
96
|
+
updated_root = tiptap._require_root()
|
|
97
|
+
changed = False
|
|
98
|
+
|
|
99
|
+
refs = tuple(tiptap.refs(parseable=True))
|
|
100
|
+
for ref in sorted(refs, key=lambda item: len(item.path), reverse=True):
|
|
101
|
+
current = node_at_path(updated_root, ref.path)
|
|
102
|
+
current_raw = current.raw()
|
|
103
|
+
current_shared_id = normalize_shared_id(
|
|
104
|
+
current_raw.get(key.ATTRS, {}).get(key.SHARED_ID)
|
|
105
|
+
)
|
|
106
|
+
if not current_shared_id or current_shared_id not in families:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
replacement_raw = _merge_preserving_identity(
|
|
110
|
+
current_raw,
|
|
111
|
+
families[current_shared_id],
|
|
112
|
+
)
|
|
113
|
+
if replacement_raw == current_raw:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
replacement = codec.read_node_input(replacement_raw, label="Node content")
|
|
117
|
+
updated_root = replace_at_path(updated_root, ref.path, replacement)
|
|
118
|
+
changed = True
|
|
119
|
+
|
|
120
|
+
return tiptap._with_root(updated_root).dump(), changed
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def normalize_shared_id(value: Any) -> Optional[str]:
|
|
124
|
+
if value is None:
|
|
125
|
+
return None
|
|
126
|
+
if isinstance(value, str):
|
|
127
|
+
stripped = value.strip()
|
|
128
|
+
return stripped or None
|
|
129
|
+
return str(value)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _merge_preserving_identity(
|
|
133
|
+
target_node: dict[str, Any],
|
|
134
|
+
canonical_node: dict[str, Any],
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
replacement = deepcopy(canonical_node)
|
|
137
|
+
target_attrs = target_node.get(key.ATTRS, {})
|
|
138
|
+
replacement_attrs = dict(replacement.get(key.ATTRS, {}))
|
|
139
|
+
|
|
140
|
+
if isinstance(target_attrs, dict) and target_attrs.get(key.ID):
|
|
141
|
+
replacement_attrs[key.ID] = target_attrs[key.ID]
|
|
142
|
+
target_shared_id = normalize_shared_id(target_attrs.get(key.SHARED_ID))
|
|
143
|
+
if target_shared_id:
|
|
144
|
+
replacement_attrs[key.SHARED_ID] = target_shared_id
|
|
145
|
+
|
|
146
|
+
replacement[key.ATTRS] = replacement_attrs
|
|
147
|
+
return replacement
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Task-specific TipTap behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..content import Content
|
|
6
|
+
from ..model import TaskItem
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def syncable_tasks(items: list[TaskItem]) -> list[TaskItem]:
|
|
10
|
+
return [item for item in items if item.task_item_id]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def open_tasks(items: list[TaskItem]) -> list[TaskItem]:
|
|
14
|
+
return [item for item in items if not item.is_completed]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def has_open_tasks(content: Content) -> bool:
|
|
18
|
+
return bool(open_tasks(content.tasks))
|