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.
@@ -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,5 @@
1
+ """Selection API exports."""
2
+
3
+ from .selection import Selection
4
+
5
+ __all__ = ["Selection"]
@@ -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,5 @@
1
+ """Task behavior exports."""
2
+
3
+ from .query import has_open_tasks, open_tasks, syncable_tasks
4
+
5
+ __all__ = ["has_open_tasks", "open_tasks", "syncable_tasks"]
@@ -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))
@@ -0,0 +1,5 @@
1
+ """Text extraction exports."""
2
+
3
+ from .extract import NodeText, text_slices, visible_text, word_count
4
+
5
+ __all__ = ["NodeText", "text_slices", "visible_text", "word_count"]