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,85 @@
1
+ """Public API for TipTap JSON utilities."""
2
+
3
+ from .contract import key, kind
4
+ from .content import Content
5
+ from .edit import append_node, replace_node
6
+ from .exceptions import TiptapValidationError
7
+ from .model import (
8
+ Blockquote,
9
+ BulletList,
10
+ CodeBlock,
11
+ Doc,
12
+ Heading,
13
+ ListItem,
14
+ Node,
15
+ OrderedList,
16
+ Paragraph,
17
+ TableCell,
18
+ TaskItem,
19
+ TaskList,
20
+ Text,
21
+ Unknown,
22
+ registry,
23
+ )
24
+ from .contract.policy import content_id, is_parseable, node_id, tiptap_id
25
+ from .select import Selection
26
+ from .shared import (
27
+ fingerprint_shared,
28
+ has_shared,
29
+ new_shared_id,
30
+ shared_id,
31
+ shared_families,
32
+ stamp_shared,
33
+ sync_shared,
34
+ )
35
+ from .tasks import has_open_tasks, open_tasks, syncable_tasks
36
+ from .text import NodeText, text_slices, visible_text, word_count
37
+ from .walk import Ref, Walker
38
+
39
+ EMPTY_DOCUMENT_CONTENT = '{"type":"doc","content":[]}'
40
+
41
+ __all__ = [
42
+ "Blockquote",
43
+ "BulletList",
44
+ "CodeBlock",
45
+ "Content",
46
+ "Doc",
47
+ "EMPTY_DOCUMENT_CONTENT",
48
+ "Heading",
49
+ "ListItem",
50
+ "Node",
51
+ "NodeText",
52
+ "OrderedList",
53
+ "Paragraph",
54
+ "Ref",
55
+ "Selection",
56
+ "TableCell",
57
+ "TaskItem",
58
+ "TaskList",
59
+ "Text",
60
+ "TiptapValidationError",
61
+ "Unknown",
62
+ "Walker",
63
+ "append_node",
64
+ "content_id",
65
+ "fingerprint_shared",
66
+ "has_open_tasks",
67
+ "has_shared",
68
+ "is_parseable",
69
+ "key",
70
+ "kind",
71
+ "new_shared_id",
72
+ "node_id",
73
+ "open_tasks",
74
+ "registry",
75
+ "replace_node",
76
+ "shared_families",
77
+ "shared_id",
78
+ "stamp_shared",
79
+ "sync_shared",
80
+ "syncable_tasks",
81
+ "text_slices",
82
+ "tiptap_id",
83
+ "visible_text",
84
+ "word_count",
85
+ ]
@@ -0,0 +1,29 @@
1
+ """Raw JSON codec exports."""
2
+
3
+ from .json import (
4
+ dump,
5
+ dumps,
6
+ normalize_text,
7
+ parse_raw,
8
+ raw_node_id,
9
+ raw_text,
10
+ read_children,
11
+ read_doc,
12
+ read_node,
13
+ read_node_input,
14
+ require_object,
15
+ )
16
+
17
+ __all__ = [
18
+ "dump",
19
+ "dumps",
20
+ "normalize_text",
21
+ "parse_raw",
22
+ "raw_node_id",
23
+ "raw_text",
24
+ "read_children",
25
+ "read_doc",
26
+ "read_node",
27
+ "read_node_input",
28
+ "require_object",
29
+ ]
@@ -0,0 +1,116 @@
1
+ """TipTap raw JSON codec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from copy import deepcopy
7
+ from typing import Any, Dict, Mapping, Optional
8
+
9
+ from ..exceptions import TiptapValidationError
10
+ from ..types import DocumentContent
11
+
12
+ from ..contract import key, kind, policy
13
+ from ..model import ContentTuple, Doc, Node, registry
14
+
15
+
16
+ def parse_raw(raw: Optional[DocumentContent]) -> Optional[Dict[str, Any]]:
17
+ """Leniently parse a raw document payload."""
18
+ if isinstance(raw, str):
19
+ try:
20
+ parsed = json.loads(raw)
21
+ except json.JSONDecodeError:
22
+ return None
23
+ else:
24
+ parsed = raw
25
+
26
+ return deepcopy(parsed) if isinstance(parsed, dict) else None
27
+
28
+
29
+ def require_object(
30
+ content: str | Mapping[str, Any],
31
+ *,
32
+ label: str = "content",
33
+ ) -> Dict[str, Any]:
34
+ """Strictly parse a JSON object payload."""
35
+ if isinstance(content, str):
36
+ try:
37
+ parsed = json.loads(content)
38
+ except json.JSONDecodeError as exc:
39
+ raise TiptapValidationError(f"{label} is not valid JSON") from exc
40
+ else:
41
+ parsed = deepcopy(dict(content))
42
+
43
+ if not isinstance(parsed, dict):
44
+ raise TiptapValidationError(f"{label} must be a JSON object")
45
+ return parsed
46
+
47
+
48
+ def read_doc(raw: Mapping[str, Any]) -> Doc | None:
49
+ """Read a raw TipTap document root."""
50
+ if raw.get(key.TYPE) != kind.DOC:
51
+ return None
52
+ parsed = read_node(raw)
53
+ return parsed if isinstance(parsed, Doc) else None
54
+
55
+
56
+ def read_node(raw: Mapping[str, Any]) -> Node:
57
+ """Read a raw TipTap node by delegating to the registry."""
58
+ children = read_children(raw.get(key.CONTENT, []))
59
+ return registry.read(raw, children)
60
+
61
+
62
+ def read_children(raw_children: Any) -> ContentTuple:
63
+ if not isinstance(raw_children, list):
64
+ return ()
65
+ return tuple(read_node(child) for child in raw_children if isinstance(child, dict))
66
+
67
+
68
+ def read_node_input(node_or_raw: Any, *, label: str) -> Node:
69
+ """Read either a typed node or a raw node payload."""
70
+ if isinstance(node_or_raw, Node):
71
+ return node_or_raw
72
+
73
+ parsed = require_object(node_or_raw, label=label)
74
+ node = read_doc(parsed) if parsed.get(key.TYPE) == kind.DOC else read_node(parsed)
75
+ if node is None:
76
+ raise TiptapValidationError(f"{label} must be a valid TipTap node")
77
+ return node
78
+
79
+
80
+ def dump(node: Node) -> Dict[str, Any]:
81
+ return node.raw()
82
+
83
+
84
+ def dumps(node: Node) -> str:
85
+ return json.dumps(dump(node))
86
+
87
+
88
+ def raw_node_id(node: Mapping[str, Any]) -> str:
89
+ return policy.content_id(node.get(key.ATTRS, {}))
90
+
91
+
92
+ def raw_text(node: Mapping[str, Any]) -> str:
93
+ return " ".join(_iter_text(node))
94
+
95
+
96
+ def normalize_text(value: str) -> str:
97
+ return " ".join(value.split())
98
+
99
+
100
+ def _iter_text(node: Mapping[str, Any]):
101
+ if not isinstance(node, dict):
102
+ return
103
+
104
+ if node.get(key.TYPE) == kind.TEXT:
105
+ text = str(node.get(key.TEXT, "")).strip()
106
+ if text:
107
+ yield text
108
+ return
109
+
110
+ content = node.get(key.CONTENT, [])
111
+ if not isinstance(content, list):
112
+ return
113
+
114
+ for child in content:
115
+ if isinstance(child, dict):
116
+ yield from _iter_text(child)
@@ -0,0 +1,174 @@
1
+ """Public TipTap content facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from copy import deepcopy
7
+ from dataclasses import dataclass
8
+ from typing import Any, Dict, Iterator, Optional
9
+
10
+ from .exceptions import TiptapValidationError
11
+ from .types import DocumentContent
12
+
13
+ from . import codec
14
+ from .contract import key, kind, policy
15
+ from .model import (
16
+ Blockquote,
17
+ BulletList,
18
+ CodeBlock,
19
+ Doc,
20
+ Heading,
21
+ ListItem,
22
+ Node,
23
+ OrderedList,
24
+ Paragraph,
25
+ TableCell,
26
+ TaskItem,
27
+ TaskList,
28
+ Text,
29
+ )
30
+ from .select import Selection
31
+ from .walk import Ref, Walker
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Content:
36
+ """Parsed TipTap content aggregate."""
37
+
38
+ _tree: Optional[Dict[str, Any]]
39
+ _root: Optional[Doc]
40
+
41
+ @classmethod
42
+ def parse(cls, raw: Optional[DocumentContent]) -> "Content":
43
+ raw_object = codec.parse_raw(raw)
44
+ if raw_object is None:
45
+ return cls(None, None)
46
+
47
+ root = codec.read_doc(raw_object)
48
+ return cls(raw_object, root)
49
+
50
+ @classmethod
51
+ def require(cls, raw: DocumentContent) -> "Content":
52
+ raw_object = codec.require_object(raw, label="Document content")
53
+ if raw_object.get(key.TYPE) != kind.DOC:
54
+ raise TiptapValidationError("Document content must be a TipTap doc")
55
+
56
+ root = codec.read_doc(raw_object)
57
+ if root is None:
58
+ raise TiptapValidationError("Document content must be a TipTap doc")
59
+ return cls(raw_object, root)
60
+
61
+ @classmethod
62
+ def wrap(cls, raw_node: Optional[DocumentContent]) -> "Content":
63
+ raw_object = codec.parse_raw(raw_node)
64
+ if raw_object is None:
65
+ return cls(None, None)
66
+ if raw_object.get(key.TYPE) == kind.DOC:
67
+ return cls.parse(raw_object)
68
+ return cls.parse({key.TYPE: kind.DOC, key.CONTENT: [raw_object]})
69
+
70
+ @property
71
+ def root(self) -> Optional[Doc]:
72
+ return self._root
73
+
74
+ @property
75
+ def text(self) -> str:
76
+ if self.root is None:
77
+ return ""
78
+ return codec.normalize_text(self.root.text)
79
+
80
+ @property
81
+ def tasks(self) -> list[TaskItem]:
82
+ return self.walker().of_type(TaskItem)
83
+
84
+ @property
85
+ def headings(self) -> list[Heading]:
86
+ return self.walker().of_type(Heading)
87
+
88
+ @property
89
+ def paragraphs(self) -> list[Paragraph]:
90
+ return self.walker().of_type(Paragraph)
91
+
92
+ @property
93
+ def texts(self) -> list[Text]:
94
+ return self.walker().of_type(Text)
95
+
96
+ def walker(self) -> Walker:
97
+ return Walker(self._root)
98
+
99
+ def refs(self, *, parseable: bool = False) -> Iterator[Ref]:
100
+ return self.walker().refs(parseable=parseable)
101
+
102
+ def where_id(self, node_id: str) -> Selection:
103
+ matches = [ref for ref in self.refs(parseable=True) if ref.node_id == node_id]
104
+ if not matches:
105
+ raise TiptapValidationError(
106
+ f"Node with ID {node_id} not found in document content"
107
+ )
108
+ if len(matches) > 1:
109
+ raise TiptapValidationError(
110
+ f"Node with ID {node_id} appears multiple times in document content"
111
+ )
112
+ return Selection(self, tuple(matches))
113
+
114
+ def of(self, node_kind: str) -> Selection:
115
+ return Selection(
116
+ self,
117
+ tuple(ref for ref in self.refs() if ref.node.kind == node_kind),
118
+ )
119
+
120
+ def dump(self) -> str:
121
+ return json.dumps(self.to_dict())
122
+
123
+ def to_dict(self) -> Dict[str, Any]:
124
+ if self._root is not None:
125
+ return self._root.raw()
126
+ if self._tree is not None:
127
+ return deepcopy(self._tree)
128
+ raise TiptapValidationError("Document content is not valid")
129
+
130
+ def word_count(self) -> int:
131
+ return sum(len(node.text.split()) for node in _word_count_nodes(self.root))
132
+
133
+ def _require_root(self) -> Doc:
134
+ if self._root is None:
135
+ raise TiptapValidationError("Document content is not valid")
136
+ return self._root
137
+
138
+ def _with_root(self, root: Doc) -> "Content":
139
+ return Content(root.raw(), root)
140
+
141
+
142
+ def _word_count_nodes(root: Optional[Doc]) -> Iterator[Node]:
143
+ if root is None:
144
+ return
145
+ for child in root.content:
146
+ yield from _iter_word_count_nodes(child)
147
+
148
+
149
+ def _iter_word_count_nodes(node: Node) -> Iterator[Node]:
150
+ if _is_countable(node):
151
+ if _identity(node):
152
+ yield node
153
+ return
154
+
155
+ if _is_container(node):
156
+ for child in node.content:
157
+ yield from _iter_word_count_nodes(child)
158
+
159
+
160
+ def _is_countable(node: Node) -> bool:
161
+ return isinstance(node, (Paragraph, Heading, TaskItem, ListItem, CodeBlock))
162
+
163
+
164
+ def _is_container(node: Node) -> bool:
165
+ return isinstance(
166
+ node,
167
+ (Doc, TaskList, BulletList, OrderedList, Blockquote, TableCell),
168
+ )
169
+
170
+
171
+ def _identity(node: Node) -> str:
172
+ if isinstance(node, TaskItem):
173
+ return node.task_item_id
174
+ return node.id or policy.node_id(node.attrs) or ""
@@ -0,0 +1,5 @@
1
+ """Raw TipTap contract and domain policy."""
2
+
3
+ from . import key, kind, policy
4
+
5
+ __all__ = ["key", "kind", "policy"]
@@ -0,0 +1,14 @@
1
+ """Raw TipTap payload keys."""
2
+
3
+ TYPE = "type"
4
+ ATTRS = "attrs"
5
+ CONTENT = "content"
6
+ TEXT = "text"
7
+ MARKS = "marks"
8
+ ID = "id"
9
+ TIPTAP_ID = "tiptapId"
10
+ SHARED_ID = "sharedId"
11
+ LEVEL = "level"
12
+ CHECKED = "checked"
13
+ STATUS = "status"
14
+ TASK_CANONICAL_ID = "taskCanonicalId"
@@ -0,0 +1,14 @@
1
+ """TipTap node kinds."""
2
+
3
+ DOC = "doc"
4
+ TEXT = "text"
5
+ PARAGRAPH = "paragraph"
6
+ HEADING = "heading"
7
+ TASK_ITEM = "taskItem"
8
+ TASK_LIST = "taskList"
9
+ LIST_ITEM = "listItem"
10
+ BULLET_LIST = "bulletList"
11
+ ORDERED_LIST = "orderedList"
12
+ BLOCKQUOTE = "blockquote"
13
+ CODE_BLOCK = "codeBlock"
14
+ TABLE_CELL = "tableCell"
@@ -0,0 +1,52 @@
1
+ """TipTap identity and parseability policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from . import key, kind
8
+
9
+ _CONTAINER_KINDS = frozenset(
10
+ {
11
+ kind.TASK_ITEM,
12
+ kind.LIST_ITEM,
13
+ kind.TABLE_CELL,
14
+ kind.BLOCKQUOTE,
15
+ }
16
+ )
17
+
18
+
19
+ def is_parseable(node_kind: str, parent_kind: str) -> bool:
20
+ """Return whether a node is tracked as an addressable document node."""
21
+ if node_kind == kind.PARAGRAPH and parent_kind in _CONTAINER_KINDS:
22
+ return False
23
+ return True
24
+
25
+
26
+ def node_id(attrs: Any) -> Optional[str]:
27
+ return _string_attr(attrs, key.ID)
28
+
29
+
30
+ def tiptap_id(attrs: Any) -> Optional[str]:
31
+ return _string_attr(attrs, key.TIPTAP_ID)
32
+
33
+
34
+ def content_id(attrs: Any) -> str:
35
+ return node_id(attrs) or tiptap_id(attrs) or ""
36
+
37
+
38
+ def shared_id(attrs: Any) -> Optional[str]:
39
+ return _string_attr(attrs, key.SHARED_ID)
40
+
41
+
42
+ def _string_attr(attrs: Any, name: str) -> Optional[str]:
43
+ if not isinstance(attrs, dict):
44
+ return None
45
+
46
+ value = attrs.get(name)
47
+ if value is None:
48
+ return None
49
+ if isinstance(value, str):
50
+ stripped = value.strip()
51
+ return stripped or None
52
+ return str(value)
@@ -0,0 +1,12 @@
1
+ """Immutable edit command exports."""
2
+
3
+ from .commands import append_child, append_node, replace_node, set_attr, set_key, set_text
4
+
5
+ __all__ = [
6
+ "append_child",
7
+ "append_node",
8
+ "replace_node",
9
+ "set_attr",
10
+ "set_key",
11
+ "set_text",
12
+ ]
@@ -0,0 +1,163 @@
1
+ """Pure immutable TipTap edit commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from typing import Any
7
+
8
+ from ..exceptions import TiptapValidationError
9
+
10
+ from .. import codec
11
+ from ..contract import key
12
+ from ..model import Doc, Node, Text
13
+
14
+
15
+ def set_text(node: Node, value: Any) -> Node:
16
+ text_value = value if isinstance(value, str) else str(value)
17
+ if isinstance(node, Text):
18
+ return node.with_text(text_value)
19
+ if isinstance(node, Doc):
20
+ raise TiptapValidationError("Document node does not support text updates")
21
+
22
+ direct_texts = [child for child in node.content if isinstance(child, Text)]
23
+ if direct_texts:
24
+ return node.__class__(
25
+ **{
26
+ **_clone_payload(node),
27
+ "content": (direct_texts[0].with_text(text_value),),
28
+ "present": node.present | {key.CONTENT},
29
+ }
30
+ )
31
+
32
+ for index, child in enumerate(node.content):
33
+ try:
34
+ updated_child = set_text(child, text_value)
35
+ except TiptapValidationError:
36
+ continue
37
+ content = list(node.content)
38
+ content[index] = updated_child
39
+ return node.__class__(
40
+ **{
41
+ **_clone_payload(node),
42
+ "content": tuple(content),
43
+ "present": node.present | {key.CONTENT},
44
+ }
45
+ )
46
+
47
+ return node.__class__(
48
+ **{
49
+ **_clone_payload(node),
50
+ "content": (Text(value=text_value),),
51
+ "present": node.present | {key.CONTENT},
52
+ }
53
+ )
54
+
55
+
56
+ def set_key(node: Node, name: str, value: Any) -> Node:
57
+ if name == key.TEXT:
58
+ return set_text(node, value)
59
+ if name == key.TYPE:
60
+ raise TiptapValidationError("TipTap node type cannot be updated")
61
+ if name == key.ATTRS and not isinstance(value, dict):
62
+ raise TiptapValidationError("TipTap attrs must be a JSON object")
63
+ if name == key.CONTENT and not isinstance(value, list):
64
+ raise TiptapValidationError("TipTap content must be a list")
65
+ if name == key.MARKS and isinstance(node, Text):
66
+ if not isinstance(value, list):
67
+ raise TiptapValidationError("TipTap marks must be a list")
68
+ return Text(
69
+ value=node.value,
70
+ marks=tuple(deepcopy(mark) for mark in value),
71
+ content=node.content,
72
+ attrs=node.attrs,
73
+ extra=node.extra,
74
+ present=node.present | {key.MARKS},
75
+ )
76
+
77
+ raw = node.raw()
78
+ raw[name] = deepcopy(value)
79
+ return codec.read_node(raw)
80
+
81
+
82
+ def set_attr(node: Node, name: str, value: Any) -> Node:
83
+ return node.with_attr(name, value)
84
+
85
+
86
+ def append_child(node: Node, child: Node) -> Node:
87
+ if isinstance(node, Text):
88
+ raise TiptapValidationError("Text nodes cannot contain child nodes")
89
+ return node.append(child)
90
+
91
+
92
+ def append_node(content: str | dict[str, Any], node: str | dict[str, Any] | Node) -> str:
93
+ """Append a node to the document root content."""
94
+ from ..content import Content
95
+
96
+ document = codec.require_object(content, label="Document content")
97
+ root_content = document.get(key.CONTENT, [])
98
+ if not isinstance(root_content, list):
99
+ raise TiptapValidationError("Document content must contain a content list")
100
+ return Content.require(document).of("doc").append(node).dump()
101
+
102
+
103
+ def replace_node(
104
+ content: str | dict[str, Any],
105
+ node_id: str,
106
+ node: str | dict[str, Any] | Node,
107
+ ) -> str:
108
+ """Replace one parseable node by local attrs.id."""
109
+ from ..content import Content
110
+
111
+ document = codec.require_object(content, label="Document content")
112
+ replacement_raw = (
113
+ node.raw() if isinstance(node, Node) else codec.require_object(node, label="Node content")
114
+ )
115
+ replacement_id = codec.raw_node_id(replacement_raw)
116
+ if not replacement_id:
117
+ raise TiptapValidationError("Node content must include attrs.id")
118
+ if replacement_id != node_id:
119
+ raise TiptapValidationError("Node content attrs.id must match path node_id")
120
+
121
+ root_content = document.get(key.CONTENT, [])
122
+ if not isinstance(root_content, list):
123
+ raise TiptapValidationError("Document content must contain a content list")
124
+
125
+ tiptap = Content.require(document)
126
+ matches = [
127
+ ref
128
+ for ref in tiptap.refs(parseable=True)
129
+ if ref.node.attrs.get(key.ID) == node_id
130
+ ]
131
+ if not matches:
132
+ raise TiptapValidationError(
133
+ f"Node with ID {node_id} not found in document content"
134
+ )
135
+ if len(matches) > 1:
136
+ raise TiptapValidationError(
137
+ f"Node with ID {node_id} appears multiple times in document content"
138
+ )
139
+
140
+ return tiptap.where_id(node_id).replace(replacement_raw).dump()
141
+
142
+
143
+ def _clone_payload(node: Node) -> dict[str, Any]:
144
+ payload = {
145
+ "id": node.id,
146
+ "attrs": deepcopy(node.attrs),
147
+ "extra": deepcopy(node.extra),
148
+ }
149
+ if hasattr(node, "level"):
150
+ payload["level"] = getattr(node, "level")
151
+ if hasattr(node, "task_item_id"):
152
+ payload.update(
153
+ {
154
+ "task_item_id": getattr(node, "task_item_id"),
155
+ "is_completed": getattr(node, "is_completed"),
156
+ "local_task_item_id": getattr(node, "local_task_item_id"),
157
+ "canonical_task_item_id": getattr(node, "canonical_task_item_id"),
158
+ "is_linked_copy": getattr(node, "is_linked_copy"),
159
+ }
160
+ )
161
+ if hasattr(node, "raw_kind"):
162
+ payload["raw_kind"] = getattr(node, "raw_kind")
163
+ return payload
@@ -0,0 +1,5 @@
1
+ """Package-specific exceptions."""
2
+
3
+
4
+ class TiptapValidationError(ValueError):
5
+ """Raised when TipTap content cannot be parsed or edited safely."""