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,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,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,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
|