prosereflect 0.1.1 → 0.3.0
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.
- checksums.yaml +4 -4
- data/.github/workflows/docs.yml +63 -0
- data/.github/workflows/links.yml +97 -0
- data/.github/workflows/rake.yml +4 -0
- data/.github/workflows/release.yml +5 -0
- data/.gitignore +4 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +119 -183
- data/CLAUDE.md +78 -0
- data/Gemfile +8 -4
- data/README.adoc +2 -0
- data/Rakefile +3 -3
- data/docs/Gemfile +10 -0
- data/docs/INDEX.adoc +45 -0
- data/docs/_advanced/index.adoc +15 -0
- data/docs/_advanced/schema.adoc +112 -0
- data/docs/_advanced/step-map.adoc +66 -0
- data/docs/_advanced/steps.adoc +88 -0
- data/docs/_advanced/test-builder.adoc +61 -0
- data/docs/_advanced/transform.adoc +92 -0
- data/docs/_config.yml +174 -0
- data/docs/_features/html-input.adoc +69 -0
- data/docs/_features/html-output.adoc +45 -0
- data/docs/_features/index.adoc +15 -0
- data/docs/_features/marks.adoc +86 -0
- data/docs/_features/node-types.adoc +124 -0
- data/docs/_features/user-mentions.adoc +47 -0
- data/docs/_guides/custom-nodes.adoc +107 -0
- data/docs/_guides/index.adoc +13 -0
- data/docs/_guides/round-trip-html.adoc +91 -0
- data/docs/_guides/serialization.adoc +109 -0
- data/docs/_pages/index.adoc +67 -0
- data/docs/_reference/document-api.adoc +49 -0
- data/docs/_reference/index.adoc +14 -0
- data/docs/_reference/node-api.adoc +79 -0
- data/docs/_reference/schema-api.adoc +95 -0
- data/docs/_reference/transform-api.adoc +77 -0
- data/docs/_understanding/document-model.adoc +65 -0
- data/docs/_understanding/fragment.adoc +52 -0
- data/docs/_understanding/index.adoc +14 -0
- data/docs/_understanding/resolved-position.adoc +53 -0
- data/docs/_understanding/slice.adoc +54 -0
- data/docs/lychee.toml +63 -0
- data/lib/prosereflect/attribute/base.rb +4 -6
- data/lib/prosereflect/attribute/bold.rb +2 -4
- data/lib/prosereflect/attribute/href.rb +1 -3
- data/lib/prosereflect/attribute/id.rb +7 -7
- data/lib/prosereflect/attribute.rb +4 -7
- data/lib/prosereflect/blockquote.rb +19 -11
- data/lib/prosereflect/bullet_list.rb +36 -29
- data/lib/prosereflect/code_block.rb +23 -27
- data/lib/prosereflect/code_block_wrapper.rb +12 -13
- data/lib/prosereflect/document.rb +14 -22
- data/lib/prosereflect/fragment.rb +249 -0
- data/lib/prosereflect/hard_break.rb +6 -6
- data/lib/prosereflect/heading.rb +14 -15
- data/lib/prosereflect/horizontal_rule.rb +23 -14
- data/lib/prosereflect/image.rb +32 -23
- data/lib/prosereflect/input/html.rb +179 -104
- data/lib/prosereflect/input.rb +7 -0
- data/lib/prosereflect/list_item.rb +11 -12
- data/lib/prosereflect/mark/base.rb +9 -11
- data/lib/prosereflect/mark/bold.rb +1 -3
- data/lib/prosereflect/mark/code.rb +1 -3
- data/lib/prosereflect/mark/italic.rb +1 -3
- data/lib/prosereflect/mark/link.rb +1 -3
- data/lib/prosereflect/mark/strike.rb +1 -3
- data/lib/prosereflect/mark/subscript.rb +1 -3
- data/lib/prosereflect/mark/superscript.rb +1 -3
- data/lib/prosereflect/mark/underline.rb +1 -3
- data/lib/prosereflect/mark.rb +9 -5
- data/lib/prosereflect/node.rb +171 -33
- data/lib/prosereflect/ordered_list.rb +17 -14
- data/lib/prosereflect/output/html.rb +279 -50
- data/lib/prosereflect/output.rb +7 -0
- data/lib/prosereflect/paragraph.rb +11 -13
- data/lib/prosereflect/parser.rb +56 -66
- data/lib/prosereflect/resolved_pos.rb +256 -0
- data/lib/prosereflect/schema/attribute.rb +57 -0
- data/lib/prosereflect/schema/content_match.rb +656 -0
- data/lib/prosereflect/schema/fragment.rb +166 -0
- data/lib/prosereflect/schema/mark.rb +121 -0
- data/lib/prosereflect/schema/mark_type.rb +130 -0
- data/lib/prosereflect/schema/node.rb +236 -0
- data/lib/prosereflect/schema/node_type.rb +274 -0
- data/lib/prosereflect/schema/schema_main.rb +190 -0
- data/lib/prosereflect/schema/spec.rb +92 -0
- data/lib/prosereflect/schema.rb +39 -0
- data/lib/prosereflect/table.rb +12 -13
- data/lib/prosereflect/table_cell.rb +13 -13
- data/lib/prosereflect/table_header.rb +17 -17
- data/lib/prosereflect/table_row.rb +12 -12
- data/lib/prosereflect/text.rb +35 -11
- data/lib/prosereflect/transform/attr_step.rb +157 -0
- data/lib/prosereflect/transform/insert_step.rb +115 -0
- data/lib/prosereflect/transform/mapping.rb +82 -0
- data/lib/prosereflect/transform/mark_step.rb +269 -0
- data/lib/prosereflect/transform/replace_around_step.rb +181 -0
- data/lib/prosereflect/transform/replace_step.rb +157 -0
- data/lib/prosereflect/transform/slice.rb +91 -0
- data/lib/prosereflect/transform/step.rb +89 -0
- data/lib/prosereflect/transform/step_map.rb +126 -0
- data/lib/prosereflect/transform/structure.rb +120 -0
- data/lib/prosereflect/transform/transform.rb +341 -0
- data/lib/prosereflect/transform.rb +26 -0
- data/lib/prosereflect/user.rb +15 -15
- data/lib/prosereflect/version.rb +1 -1
- data/lib/prosereflect.rb +30 -17
- data/prosereflect.gemspec +17 -16
- data/spec/fixtures/documents/formatted_text.yaml +14 -0
- data/spec/fixtures/documents/heading_paragraph.yaml +16 -0
- data/spec/fixtures/documents/lists_doc.yaml +32 -0
- data/spec/fixtures/documents/mixed_content.yaml +40 -0
- data/spec/fixtures/documents/nested_doc.yaml +20 -0
- data/spec/fixtures/documents/simple_doc.yaml +6 -0
- data/spec/fixtures/documents/table_doc.yaml +32 -0
- data/spec/fixtures/documents/transform_test.yaml +14 -0
- data/spec/fixtures/schema/custom_schema.rb +37 -0
- data/spec/fixtures/schema/test_schema.rb +46 -0
- data/spec/fixtures/test_builder/helpers.rb +212 -0
- data/spec/prosereflect/document_spec.rb +332 -330
- data/spec/prosereflect/fragment_spec.rb +273 -0
- data/spec/prosereflect/hard_break_spec.rb +125 -125
- data/spec/prosereflect/input/html_spec.rb +718 -522
- data/spec/prosereflect/node_spec.rb +311 -182
- data/spec/prosereflect/output/html_spec.rb +105 -105
- data/spec/prosereflect/output/whitespace_spec.rb +248 -0
- data/spec/prosereflect/paragraph_spec.rb +275 -274
- data/spec/prosereflect/parser/round_trip_spec.rb +472 -0
- data/spec/prosereflect/parser_spec.rb +185 -180
- data/spec/prosereflect/resolved_pos_spec.rb +74 -0
- data/spec/prosereflect/schema/conftest.rb +68 -0
- data/spec/prosereflect/schema/content_match_spec.rb +237 -0
- data/spec/prosereflect/schema/mark_spec.rb +274 -0
- data/spec/prosereflect/schema/mark_type_spec.rb +86 -0
- data/spec/prosereflect/schema/node_type_spec.rb +142 -0
- data/spec/prosereflect/schema/schema_spec.rb +194 -0
- data/spec/prosereflect/table_cell_spec.rb +183 -183
- data/spec/prosereflect/table_row_spec.rb +149 -149
- data/spec/prosereflect/table_spec.rb +320 -318
- data/spec/prosereflect/test_builder/marks_spec.rb +127 -0
- data/spec/prosereflect/text_spec.rb +133 -132
- data/spec/prosereflect/transform/equivalence_spec.rb +487 -0
- data/spec/prosereflect/transform/mapping_spec.rb +226 -0
- data/spec/prosereflect/transform/replace_spec.rb +832 -0
- data/spec/prosereflect/transform/replace_step_spec.rb +157 -0
- data/spec/prosereflect/transform/slice_spec.rb +48 -0
- data/spec/prosereflect/transform/step_map_spec.rb +70 -0
- data/spec/prosereflect/transform/step_spec.rb +211 -0
- data/spec/prosereflect/transform/structure_spec.rb +98 -0
- data/spec/prosereflect/transform/transform_spec.rb +238 -0
- data/spec/prosereflect/user_spec.rb +31 -28
- data/spec/prosereflect_spec.rb +28 -26
- data/spec/spec_helper.rb +7 -6
- data/spec/support/matchers.rb +6 -6
- data/spec/support/shared_examples.rb +49 -49
- metadata +96 -5
- data/spec/prosereflect/version_spec.rb +0 -11
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "step"
|
|
4
|
+
require_relative "step_map"
|
|
5
|
+
|
|
6
|
+
module Prosereflect
|
|
7
|
+
module Transform
|
|
8
|
+
# Base class for mark-related steps
|
|
9
|
+
class MarkStep < Step
|
|
10
|
+
attr_reader :from, :to, :mark
|
|
11
|
+
|
|
12
|
+
def initialize(from, to, mark)
|
|
13
|
+
super()
|
|
14
|
+
@from = from
|
|
15
|
+
@to = to
|
|
16
|
+
@mark = mark
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def get_map
|
|
20
|
+
StepMap.new
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Add a mark to all content in a range
|
|
25
|
+
class AddMarkStep < MarkStep
|
|
26
|
+
def apply(doc)
|
|
27
|
+
return Result.fail("Invalid positions") if @from > @to || @from.negative?
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
new_doc = add_mark_to_range(doc)
|
|
31
|
+
Result.ok(new_doc)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
Result.fail(e.message)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def invert(_doc)
|
|
38
|
+
RemoveMarkStep.new(@from, @to, @mark)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def merge(other)
|
|
42
|
+
return nil unless other.is_a?(AddMarkStep)
|
|
43
|
+
return nil unless other.mark == @mark
|
|
44
|
+
|
|
45
|
+
if @to == other.from
|
|
46
|
+
AddMarkStep.new(@from, other.to, @mark)
|
|
47
|
+
elsif @from == other.to
|
|
48
|
+
AddMarkStep.new(other.from, @to, @mark)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def step_type
|
|
53
|
+
"addMark"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_json(*_args)
|
|
57
|
+
json = super
|
|
58
|
+
json["mark"] = @mark.to_h
|
|
59
|
+
json
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.from_json(_schema, json)
|
|
63
|
+
mark = Prosereflect::Mark.from_h(json["mark"])
|
|
64
|
+
new(json["from"], json["to"], mark)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def add_mark_to_range(doc)
|
|
70
|
+
new_content = doc.content.map { |node| apply_mark_to_node(node) }
|
|
71
|
+
doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def apply_mark_to_node(node)
|
|
75
|
+
return node unless node.is_a?(Prosereflect::Text)
|
|
76
|
+
|
|
77
|
+
Prosereflect::Text.new(
|
|
78
|
+
text: node.text,
|
|
79
|
+
marks: (node.marks || []) + [@mark],
|
|
80
|
+
attrs: node.attrs.dup,
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def remove_mark_from_range(doc)
|
|
85
|
+
new_content = doc.content.map { |node| remove_mark_from_node_single(node) }
|
|
86
|
+
doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def remove_mark_from_node_single(node)
|
|
90
|
+
return node unless node.is_a?(Prosereflect::Text)
|
|
91
|
+
|
|
92
|
+
new_marks = (node.marks || []).reject { |m| m.type == @mark.type }
|
|
93
|
+
Prosereflect::Text.new(
|
|
94
|
+
text: node.text,
|
|
95
|
+
marks: new_marks,
|
|
96
|
+
attrs: node.attrs.dup,
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Remove a mark from all content in a range
|
|
102
|
+
class RemoveMarkStep < MarkStep
|
|
103
|
+
def apply(doc)
|
|
104
|
+
return Result.fail("Invalid positions") if @from > @to || @from.negative?
|
|
105
|
+
|
|
106
|
+
begin
|
|
107
|
+
new_doc = remove_mark_from_range(doc)
|
|
108
|
+
Result.ok(new_doc)
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
Result.fail(e.message)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def invert(_doc)
|
|
115
|
+
AddMarkStep.new(@from, @to, @mark)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def merge(other)
|
|
119
|
+
return nil unless other.is_a?(RemoveMarkStep)
|
|
120
|
+
return nil unless other.mark == @mark
|
|
121
|
+
|
|
122
|
+
if @to == other.from
|
|
123
|
+
RemoveMarkStep.new(@from, other.to, @mark)
|
|
124
|
+
elsif @from == other.to
|
|
125
|
+
RemoveMarkStep.new(other.from, @to, @mark)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def step_type
|
|
130
|
+
"removeMark"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def to_json(*_args)
|
|
134
|
+
json = super
|
|
135
|
+
json["mark"] = @mark.to_h
|
|
136
|
+
json
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.from_json(_schema, json)
|
|
140
|
+
mark = Prosereflect::Mark.from_h(json["mark"])
|
|
141
|
+
new(json["from"], json["to"], mark)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Add mark to a specific node (not range-based)
|
|
146
|
+
class AddNodeMarkStep < Step
|
|
147
|
+
attr_reader :pos, :mark
|
|
148
|
+
|
|
149
|
+
def initialize(pos, mark)
|
|
150
|
+
super()
|
|
151
|
+
@pos = pos
|
|
152
|
+
@mark = mark
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def apply(doc)
|
|
156
|
+
return Result.fail("Invalid position") if @pos.negative? || @pos > doc.node_size
|
|
157
|
+
|
|
158
|
+
begin
|
|
159
|
+
new_doc = add_mark_to_node(doc)
|
|
160
|
+
Result.ok(new_doc)
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
Result.fail(e.message)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def get_map
|
|
167
|
+
StepMap.new
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def invert(_doc)
|
|
171
|
+
RemoveNodeMarkStep.new(@pos, @mark)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def step_type
|
|
175
|
+
"addNodeMark"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def to_json(*_args)
|
|
179
|
+
json = super
|
|
180
|
+
json["pos"] = @pos
|
|
181
|
+
json["mark"] = @mark.to_h
|
|
182
|
+
json
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.from_json(_schema, json)
|
|
186
|
+
mark = Prosereflect::Mark.from_h(json["mark"])
|
|
187
|
+
new(json["pos"], mark)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def add_mark_to_node(doc)
|
|
193
|
+
new_content = doc.content.map { |node| add_mark_to_single_node(node) }
|
|
194
|
+
doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def add_mark_to_single_node(node)
|
|
198
|
+
new_marks = (node.marks || []) + [@mark]
|
|
199
|
+
node.class.new(
|
|
200
|
+
content: node.content,
|
|
201
|
+
marks: new_marks,
|
|
202
|
+
attrs: node.attrs.dup,
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Remove mark from a specific node
|
|
208
|
+
class RemoveNodeMarkStep < Step
|
|
209
|
+
attr_reader :pos, :mark
|
|
210
|
+
|
|
211
|
+
def initialize(pos, mark)
|
|
212
|
+
super()
|
|
213
|
+
@pos = pos
|
|
214
|
+
@mark = mark
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def apply(doc)
|
|
218
|
+
return Result.fail("Invalid position") if @pos.negative? || @pos > doc.node_size
|
|
219
|
+
|
|
220
|
+
begin
|
|
221
|
+
new_doc = remove_mark_from_node(doc)
|
|
222
|
+
Result.ok(new_doc)
|
|
223
|
+
rescue StandardError => e
|
|
224
|
+
Result.fail(e.message)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def get_map
|
|
229
|
+
StepMap.new
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def invert(_doc)
|
|
233
|
+
AddNodeMarkStep.new(@pos, @mark)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def step_type
|
|
237
|
+
"removeNodeMark"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def to_json(*_args)
|
|
241
|
+
json = super
|
|
242
|
+
json["pos"] = @pos
|
|
243
|
+
json["mark"] = @mark.to_h
|
|
244
|
+
json
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def self.from_json(_schema, json)
|
|
248
|
+
mark = Prosereflect::Mark.from_h(json["mark"])
|
|
249
|
+
new(json["pos"], mark)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
def remove_mark_from_node(doc)
|
|
255
|
+
new_content = doc.content.map { |node| remove_mark_from_single_node(node) }
|
|
256
|
+
doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def remove_mark_from_single_node(node)
|
|
260
|
+
new_marks = (node.marks || []).reject { |m| m.type == @mark.type }
|
|
261
|
+
node.class.new(
|
|
262
|
+
content: node.content,
|
|
263
|
+
marks: new_marks,
|
|
264
|
+
attrs: node.attrs.dup,
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "step"
|
|
4
|
+
|
|
5
|
+
module Prosereflect
|
|
6
|
+
module Transform
|
|
7
|
+
# Replaces a range of the document with a slice of content,
|
|
8
|
+
# but also replaces the content before and after the gap.
|
|
9
|
+
# Used by lift and wrap operations.
|
|
10
|
+
class ReplaceAroundStep < Step
|
|
11
|
+
attr_reader :from, :to, :gap_from, :gap_to, :slice, :insert, :structure
|
|
12
|
+
|
|
13
|
+
def initialize(from, to, gap_from, gap_to, slice, insert, structure: false)
|
|
14
|
+
super()
|
|
15
|
+
@from = from
|
|
16
|
+
@to = to
|
|
17
|
+
@gap_from = gap_from
|
|
18
|
+
@gap_to = gap_to
|
|
19
|
+
@slice = slice
|
|
20
|
+
@insert = insert
|
|
21
|
+
@structure = structure
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def apply(doc)
|
|
25
|
+
# Check structure constraint
|
|
26
|
+
if @structure && (content_between(doc, @from, @gap_from) || content_between(doc, @gap_to, @to))
|
|
27
|
+
return Result.fail("Structure gap-replace would overwrite content")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get the gap content
|
|
31
|
+
gap = doc.slice(@gap_from, @gap_to)
|
|
32
|
+
if gap.open_start || gap.open_end
|
|
33
|
+
return Result.fail("Gap is not a flat range")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Try to insert slice into gap
|
|
37
|
+
inserted = @slice.insert_at(@insert, gap.content)
|
|
38
|
+
unless inserted
|
|
39
|
+
return Result.fail("Content does not fit in gap")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Apply the replacement
|
|
43
|
+
new_doc = apply_replace_around(doc, inserted)
|
|
44
|
+
Result.ok(new_doc)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Result.fail(e.message)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def get_map
|
|
50
|
+
StepMap.new([
|
|
51
|
+
@from,
|
|
52
|
+
@gap_from - @from,
|
|
53
|
+
@insert,
|
|
54
|
+
@gap_to,
|
|
55
|
+
@to - @gap_to,
|
|
56
|
+
@slice.size - @insert,
|
|
57
|
+
])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def invert(doc)
|
|
61
|
+
gap = @gap_to - @gap_from
|
|
62
|
+
removed = doc.slice(@from, @to).remove_between(
|
|
63
|
+
@gap_from - @from,
|
|
64
|
+
@gap_to - @from,
|
|
65
|
+
)
|
|
66
|
+
ReplaceAroundStep.new(
|
|
67
|
+
@from,
|
|
68
|
+
@from + @slice.size + gap,
|
|
69
|
+
@from + @insert,
|
|
70
|
+
@from + @insert + gap,
|
|
71
|
+
removed,
|
|
72
|
+
@gap_from - @from,
|
|
73
|
+
@structure,
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def map(mapping)
|
|
78
|
+
from_mapped = mapping.map_result(@from, 1)
|
|
79
|
+
to_mapped = mapping.map_result(@to, -1)
|
|
80
|
+
|
|
81
|
+
gap_from_mapped = if @from == @gap_from
|
|
82
|
+
from_mapped.pos
|
|
83
|
+
else
|
|
84
|
+
mapping.map(@gap_from, -1)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
gap_to_mapped = if @to == @gap_to
|
|
88
|
+
to_mapped.pos
|
|
89
|
+
else
|
|
90
|
+
mapping.map(@gap_to, 1)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if (from_mapped.deleted && to_mapped.deleted) || gap_from_mapped < from_mapped.pos || gap_to_mapped > to_mapped.pos
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
ReplaceAroundStep.new(
|
|
98
|
+
from_mapped.pos,
|
|
99
|
+
to_mapped.pos,
|
|
100
|
+
gap_from_mapped,
|
|
101
|
+
gap_to_mapped,
|
|
102
|
+
@slice,
|
|
103
|
+
@insert,
|
|
104
|
+
@structure,
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def step_type
|
|
109
|
+
"replaceAround"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def to_json(*_args)
|
|
113
|
+
json = super
|
|
114
|
+
json["from"] = @from
|
|
115
|
+
json["to"] = @to
|
|
116
|
+
json["gapFrom"] = @gap_from
|
|
117
|
+
json["gapTo"] = @gap_to
|
|
118
|
+
json["slice"] = @slice.content.to_a.map(&:to_h)
|
|
119
|
+
json["insert"] = @insert
|
|
120
|
+
json["structure"] = @structure
|
|
121
|
+
json
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.from_json(_schema, json)
|
|
125
|
+
from_val = json["from"]
|
|
126
|
+
to_val = json["to"]
|
|
127
|
+
gap_from_val = json["gapFrom"]
|
|
128
|
+
gap_to_val = json["gapTo"]
|
|
129
|
+
insert_val = json["insert"]
|
|
130
|
+
structure_val = json["structure"] || false
|
|
131
|
+
|
|
132
|
+
slice_json = json["slice"] || []
|
|
133
|
+
slice_content = slice_json.map { |h| Prosereflect::Node.from_h(h) }
|
|
134
|
+
slice = Slice.new(Fragment.new(slice_content))
|
|
135
|
+
|
|
136
|
+
new(from_val, to_val, gap_from_val, gap_to_val, slice, insert_val, structure: structure_val)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def content_between(doc, from, to)
|
|
142
|
+
return nil if from >= to
|
|
143
|
+
|
|
144
|
+
result = []
|
|
145
|
+
doc.nodes_between(from, to) { |node| result << node }
|
|
146
|
+
result.empty? ? nil : Fragment.new(result)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def apply_replace_around(doc, inserted)
|
|
150
|
+
# Get content before and after the replaced range
|
|
151
|
+
before = content_before(doc, @from)
|
|
152
|
+
after = content_after(doc, @to)
|
|
153
|
+
|
|
154
|
+
# Build new document
|
|
155
|
+
new_content = []
|
|
156
|
+
new_content.concat(before) unless before.empty?
|
|
157
|
+
new_content.concat(inserted.content.to_a) unless inserted.empty?
|
|
158
|
+
new_content.concat(after) unless after.empty?
|
|
159
|
+
|
|
160
|
+
rebuild_doc(doc, new_content)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def content_before(doc, pos)
|
|
164
|
+
result = []
|
|
165
|
+
doc.nodes_between(0, pos) { |node| result << node }
|
|
166
|
+
result
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def content_after(doc, pos)
|
|
170
|
+
result = []
|
|
171
|
+
doc.nodes_between(pos, doc.node_size) { |node| result << node }
|
|
172
|
+
result
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def rebuild_doc(doc, new_content)
|
|
176
|
+
attrs = doc.attrs.dup
|
|
177
|
+
doc.class.new(content: Fragment.new(new_content), attrs: attrs)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "step"
|
|
4
|
+
|
|
5
|
+
module Prosereflect
|
|
6
|
+
module Transform
|
|
7
|
+
# Replaces a range of the document with a slice of content.
|
|
8
|
+
class ReplaceStep < Step
|
|
9
|
+
attr_reader :from, :to, :slice
|
|
10
|
+
|
|
11
|
+
def initialize(from, to, slice = Slice.empty)
|
|
12
|
+
super()
|
|
13
|
+
@from = from
|
|
14
|
+
@to = to
|
|
15
|
+
@slice = slice
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def apply(doc)
|
|
19
|
+
# Validate positions
|
|
20
|
+
return Result.fail("Invalid positions") if @from > @to
|
|
21
|
+
return Result.fail("from < 0") if @from.negative?
|
|
22
|
+
return Result.fail("to > doc size") if @to > doc.node_size
|
|
23
|
+
|
|
24
|
+
# Build the new document
|
|
25
|
+
new_doc = apply_replace(doc)
|
|
26
|
+
Result.ok(new_doc)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
Result.fail(e.message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get_map
|
|
32
|
+
delta = @slice.size - (@to - @from)
|
|
33
|
+
StepMap.new([[@from, @to, @from, @from + delta]])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def invert(doc)
|
|
37
|
+
# Find what was removed
|
|
38
|
+
removed = content_between(doc, @from, @to)
|
|
39
|
+
ReplaceStep.new(@from, @from + @slice.size, removed)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def merge(other)
|
|
43
|
+
return nil unless other.is_a?(ReplaceStep)
|
|
44
|
+
|
|
45
|
+
return extend_deletion(other) if can_extend_deletion?(other)
|
|
46
|
+
return prepend_deletion(other) if can_prepend_deletion?(other)
|
|
47
|
+
return append_content(other) if can_append_content?(other)
|
|
48
|
+
return prepend_content(other) if can_prepend_content?(other)
|
|
49
|
+
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def can_extend_deletion?(other)
|
|
54
|
+
@to == other.from && @slice.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extend_deletion(other)
|
|
58
|
+
ReplaceStep.new(@from, other.to, Slice.empty)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def can_prepend_deletion?(other)
|
|
62
|
+
other.to == @from && other.slice.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def prepend_deletion(other)
|
|
66
|
+
ReplaceStep.new(other.from, @to, Slice.empty)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def can_append_content?(other)
|
|
70
|
+
@to == other.from && !other.slice.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def append_content(other)
|
|
74
|
+
new_content = join_slices(@slice, other.slice)
|
|
75
|
+
ReplaceStep.new(@from, other.to, new_content)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def can_prepend_content?(other)
|
|
79
|
+
other.to == @from && !other.slice.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def prepend_content(other)
|
|
83
|
+
new_content = join_slices(other.slice, @slice)
|
|
84
|
+
ReplaceStep.new(other.from, @to, new_content)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def step_type
|
|
88
|
+
"replace"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def to_json(*_args)
|
|
92
|
+
json = super
|
|
93
|
+
json["from"] = @from
|
|
94
|
+
json["to"] = @to
|
|
95
|
+
json["slice"] = @slice.content.map(&:to_h)
|
|
96
|
+
json
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.from_json(_schema, json)
|
|
100
|
+
from_val = json["from"]
|
|
101
|
+
to_val = json["to"]
|
|
102
|
+
slice_json = json["slice"] || []
|
|
103
|
+
slice_content = slice_json.map { |h| Prosereflect::Node.from_h(h) }
|
|
104
|
+
slice = Slice.new(Fragment.new(slice_content))
|
|
105
|
+
new(from_val, to_val, slice)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def apply_replace(doc)
|
|
111
|
+
# Get content before, during, and after the replaced range
|
|
112
|
+
before = content_before(doc, @from)
|
|
113
|
+
after = content_after(doc, @to)
|
|
114
|
+
|
|
115
|
+
# Build new document
|
|
116
|
+
new_content = []
|
|
117
|
+
new_content.concat(before) unless before.empty?
|
|
118
|
+
new_content.concat(@slice.content.to_a) unless @slice.empty?
|
|
119
|
+
new_content.concat(after) unless after.empty?
|
|
120
|
+
|
|
121
|
+
rebuild_doc(doc, new_content)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def content_before(doc, pos)
|
|
125
|
+
result = []
|
|
126
|
+
doc.nodes_between(0, pos) { |node| result << node }
|
|
127
|
+
result
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def content_after(doc, pos)
|
|
131
|
+
result = []
|
|
132
|
+
doc.nodes_between(pos, doc.node_size) { |node| result << node }
|
|
133
|
+
result
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def content_between(doc, from, to)
|
|
137
|
+
result = []
|
|
138
|
+
doc.nodes_between(from, to) { |node| result << node }
|
|
139
|
+
Fragment.new(result)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def join_slices(left, right)
|
|
143
|
+
new_content = Fragment.new(left.content.to_a + right.content.to_a)
|
|
144
|
+
Slice.new(new_content, left.open_start, right.open_end)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def rebuild_doc(doc, new_content)
|
|
148
|
+
# Create a new document with the same structure but new content
|
|
149
|
+
attrs = doc.attrs.dup
|
|
150
|
+
Fragment.new(new_content)
|
|
151
|
+
# For simplicity, return a new Document with the new content
|
|
152
|
+
# In reality this would preserve the doc type
|
|
153
|
+
doc.class.new(content: Fragment.new(new_content), attrs: attrs)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosereflect
|
|
4
|
+
module Transform
|
|
5
|
+
# Represents a slice of a document - a contiguous portion that can be
|
|
6
|
+
# inserted, deleted, or moved. Tracks open boundaries for proper joining.
|
|
7
|
+
class Slice
|
|
8
|
+
attr_reader :content, :open_start, :open_end
|
|
9
|
+
|
|
10
|
+
def initialize(content, open_start = 0, open_end = 0)
|
|
11
|
+
@content = content
|
|
12
|
+
@open_start = open_start
|
|
13
|
+
@open_end = open_end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Check if this slice is empty (no content and no open boundaries)
|
|
17
|
+
def empty?
|
|
18
|
+
@content.empty? && @open_start.zero? && @open_end.zero?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Total size of the slice including open boundaries
|
|
22
|
+
def size
|
|
23
|
+
content_size + @open_start + @open_end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Size of just the content
|
|
27
|
+
def content_size
|
|
28
|
+
size = 0
|
|
29
|
+
@content.each { |node| size += node.node_size }
|
|
30
|
+
size
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Cut the slice at given boundaries
|
|
34
|
+
def cut(from = 0, to = nil)
|
|
35
|
+
to ||= size
|
|
36
|
+
|
|
37
|
+
if from.zero? && to == size
|
|
38
|
+
return self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
result = cut_internal(from, to)
|
|
42
|
+
Slice.new(result[:content], result[:open_start], result[:open_end])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check equality
|
|
46
|
+
def eq?(other)
|
|
47
|
+
return false unless other.is_a?(Slice)
|
|
48
|
+
|
|
49
|
+
@open_start == other.open_start &&
|
|
50
|
+
@open_end == other.open_end &&
|
|
51
|
+
@content.to_a.map(&:to_h) == other.content.to_a.map(&:to_h)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
alias == eq?
|
|
55
|
+
|
|
56
|
+
def to_s
|
|
57
|
+
"<Slice open_start=#{@open_start} open_end=#{@open_end} content=#{@content.length} items>"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def inspect
|
|
61
|
+
to_s
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Create an empty slice
|
|
65
|
+
def self.empty
|
|
66
|
+
new(Fragment.new([]), 0, 0)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def cut_internal(from, to)
|
|
72
|
+
return { content: @content, open_start: @open_start, open_end: @open_end } if from >= to
|
|
73
|
+
|
|
74
|
+
# Simplified cut - just adjusts open flags
|
|
75
|
+
new_open_start = @open_start
|
|
76
|
+
new_open_end = @open_end
|
|
77
|
+
new_content = @content
|
|
78
|
+
|
|
79
|
+
if from.positive?
|
|
80
|
+
new_open_start = [new_open_start - from, 0].max
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if to < size
|
|
84
|
+
new_open_end = [new_open_end - (size - to), 0].max
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
{ content: new_content, open_start: new_open_start, open_end: new_open_end }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|