forked from balisujohn/localwriter
-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathcontent.py
More file actions
1670 lines (1466 loc) · 84.7 KB
/
Copy pathcontent.py
File metadata and controls
1670 lines (1466 loc) · 84.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# WriterAgent - AI Writing Assistant for LibreOffice
# Copyright (c) 2024 John Balis
# Copyright (c) 2026 KeithCu (modifications and relicensing)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Writer content tools — read, apply, find, and paragraph operations."""
import itertools
import logging
import os
import threading
from plugin.framework.tool import ToolBase, ToolBaseDummy
from plugin.framework.prompts import APPLY_DOCUMENT_CONTENT_TOOL_RESEARCH_HINT
from plugin.doc.document_helpers import normalize_linebreaks, get_string_without_tracked_deletions, collect_tracked_changes
from plugin.writer.edit_review import EditReviewSession, edit_review_wait_seconds, review_recording_enabled, get_agent_edit_review_mode
from plugin.framework.errors import safe_json_loads, ToolExecutionError
from plugin.writer import search as search_mod
import re as re_mod
log = logging.getLogger("writeragent.writer")
# Cap for replace-all search (_find_all_ranges).
_MAX_SEARCH_REPLACEMENTS = 200
# Non-breaking / exotic spaces -> ASCII space. Length-preserving (each maps to a
# single BMP char) so character offsets into the document text stay valid. NBSP
# (U+00A0) in particular is a common artifact of prior edits and breaks literal
# search when old_content uses a normal space.
#
# Regenerate the inventory table: python3 -c "..." (see git history / plan doc) or
# run the snippet in the finish-NBSP plan; paste rows here when expanding the map.
#
# | Code | Name | In _SPACE_NORMALIZE_MAP | Follow-up note |
# |--------|------------------------------|-------------------------|----------------|
# | U+0020 | SPACE | no | target; not mapped |
# | U+00A0 | NO-BREAK SPACE | yes | mapped today |
# | U+1680 | OGHAM SPACE MARK | no | OGHAM SPACE MARK; rare in Writer |
# | U+2000 | EN QUAD | yes | mapped today |
# | U+2001 | EM QUAD | yes | mapped today |
# | U+2002 | EN SPACE | yes | mapped today |
# | U+2003 | EM SPACE | yes | mapped today |
# | U+2004 | THREE-PER-EM SPACE | yes | mapped today |
# | U+2005 | FOUR-PER-EM SPACE | yes | mapped today |
# | U+2006 | SIX-PER-EM SPACE | yes | mapped today |
# | U+2007 | FIGURE SPACE | yes | mapped today |
# | U+2008 | PUNCTUATION SPACE | yes | mapped today |
# | U+2009 | THIN SPACE | yes | mapped today |
# | U+200A | HAIR SPACE | yes | mapped today |
# | U+202F | NARROW NO-BREAK SPACE | yes | mapped today |
# | U+205F | MEDIUM MATHEMATICAL SPACE | yes | mapped today |
# | U+3000 | IDEOGRAPHIC SPACE | yes | mapped today |
#
# DEVELOPER DISCUSSION / FUTURE WORK (Intentionally deferred to avoid complexity):
#
# - Full-document replace via target='search' + old_content=entire body:
# DO NOT support this with a body offset fallback. Callers (LLM, tests, translation) must use
# target='full_document' — no old_content, no search. If we ever revisit, git history has
# _find_range_by_offset / phase-3 offset scan removed after the LO-regex unification commit.
#
# - Format.py search-replace helpers:
# Functions like format.find_text_ranges are still LO-literal only. apply_document_content search
# uses _find_chained_range (LO regex + paragraph chaining) with exotic-space flex matching.
# Unifying format.find_text_ranges with that stack is deferred — callers are internal/benchmark-only.
#
# - Casefolding & Unicode length changes:
# Chaining compares via .lower(); LO regex uses SearchCaseSensitive=False. German ß (folds to ss)
# or Turkish I can still mis-match or mis-size cursor ranges. Fixing that needs character mapping
# tracking beyond goRight — deferred for rare edge cases.
#
# - Headers/footers:
# doc.findFirst searches the full document model; we rely on LO for header/footer hits rather than
# enumerating nested XText containers ourselves.
#
# - Markup apply in nested XText:
# When inserting HTML/markup inside a table cell, the HTML import helper (replace_single_range_with_content)
# can sometimes jump the cursor to the end of the document body rather than the cell's end. This is a potential
# real-world bug if the AI attempts to write rich formatting/math inside cells, but we defer it until we
# receive actual user bug reports due to the complexity of relative cursor mapping in nested XText.
_SPACE_CODEPOINTS = (
0x00A0, # NO-BREAK SPACE
0x202F, # NARROW NO-BREAK SPACE
0x2007, # FIGURE SPACE
0x2009, # THIN SPACE
# Typographic spaces
0x2000, # EN QUAD
0x2001, # EM QUAD
0x2002, # EN SPACE
0x2003, # EM SPACE
0x2004, # THREE-PER-EM SPACE
0x2005, # FOUR-PER-EM SPACE
0x2006, # SIX-PER-EM SPACE
0x2008, # PUNCTUATION SPACE
0x200A, # HAIR SPACE
0x205F, # MEDIUM MATHEMATICAL SPACE
# CJK space
0x3000, # IDEOGRAPHIC SPACE
)
_SPACE_NORMALIZE_MAP = {cp: " " for cp in _SPACE_CODEPOINTS}
# Shared horizontal-space class — must stay aligned with _SPACE_CODEPOINTS.
_HORIZONTAL_SPACE_CLASS = r"[ \t" + "".join("\\u%04x" % cp for cp in _SPACE_CODEPOINTS) + "]"
_HORIZONTAL_SPACE_RE = _HORIZONTAL_SPACE_CLASS + "+"
def _search_try_strings(search_string):
"""Literal search string, then newline-collapsed variant (HTML wrap artifact)."""
s = search_string or ""
collapsed = re_mod.sub(r" +", " ", s.replace("\n", " ")).strip()
for candidate in (s, collapsed):
if candidate:
yield candidate
def _escape_for_lo_regex(s):
"""Escape regular expression characters and match any horizontal space sequence."""
s = (s or "").translate(_SPACE_NORMALIZE_MAP)
escaped = re_mod.sub(r'([\\^$.|?*+()\[\]{}])', r'\\\1', s)
return re_mod.sub(r' +', lambda m: _HORIZONTAL_SPACE_CLASS + '+', escaped)
def _compare_normalize(s):
return normalize_linebreaks(s).translate(_SPACE_NORMALIZE_MAP).strip().lower()
def _paragraph_matches_part(para_text, part, *, head=False, tail=False):
"""Compare a paragraph to a search part; return (ok, offset_len).
*head*: part must match the start of the paragraph; offset_len is goRight length.
*tail*: part must match the end; offset_len is goRight start offset from para start.
Neither flag: full paragraph must equal part (after normalize).
"""
expected_norm = _compare_normalize(part)
actual_norm = _compare_normalize(para_text)
if head:
if not actual_norm.startswith(expected_norm):
return False, None
skipped_leading = len(para_text) - len(para_text.lstrip())
return True, skipped_leading + len(part.strip())
if tail:
if not actual_norm.endswith(expected_norm):
return False, None
trimmed_trailing = para_text.rstrip()
return True, max(0, len(trimmed_trailing) - len(part.strip()))
return actual_norm == expected_norm, None
def _find_lo_regex_ranges(doc, candidate, all_matches=False):
"""LO regex findFirst/findNext for one candidate string."""
sd = doc.createSearchDescriptor()
sd.SearchRegularExpression = True
sd.SearchString = _escape_for_lo_regex(candidate)
if not all_matches:
for case_sens in (True, False):
sd.SearchCaseSensitive = case_sens
found = doc.findFirst(sd)
if found is not None:
return found
return None
ranges = []
for case_sens in (True, False):
sd.SearchCaseSensitive = case_sens
found = doc.findFirst(sd)
while found is not None:
if len(ranges) >= _MAX_SEARCH_REPLACEMENTS:
return ranges
ranges.append(found)
found = search_mod.find_next_after_match(doc, found, sd)
if ranges:
return ranges
return ranges
def _find_chained_range(doc, search_string, all_matches=False):
"""Find search_string via LO regex (literal + newline-collapsed retry) then paragraph chaining.
doc.findFirst covers body, table cells, and text frames. Chaining handles real paragraph
breaks that LO regex cannot cross.
Not for whole-document replace: use apply_document_content(target='full_document') instead
of passing the entire body as old_content (see ApplyDocumentContent docstring).
"""
if not search_string:
return [] if all_matches else None
# Phase 1: whole-string LO regex — literal first, then newline-collapsed (HTML wrap artifact).
for candidate in _search_try_strings(search_string):
result = _find_lo_regex_ranges(doc, candidate, all_matches=all_matches)
if all_matches:
if result:
return result
elif result is not None:
return result
# Phase 2: paragraph chaining on the original string (real cross-paragraph intent).
parts = search_string.split('\n')
if len(parts) <= 1:
return [] if all_matches else None
anchor_idx = -1
for idx, part in enumerate(parts):
if part.strip():
anchor_idx = idx
break
if anchor_idx == -1:
return [] if all_matches else None
sd = doc.createSearchDescriptor()
sd.SearchRegularExpression = True
sd.SearchString = _escape_for_lo_regex(parts[anchor_idx])
matched_ranges = []
for case_sens in (True, False):
sd.SearchCaseSensitive = case_sens
found = doc.findFirst(sd)
while found is not None:
text = found.getText()
chain_ok = True
forward_cursor = text.createTextCursorByRange(found)
forward_cursor.gotoRange(found.getEnd(), False)
last_end_cursor = None
for i in range(anchor_idx + 1, len(parts)):
if not forward_cursor.gotoNextParagraph(False):
chain_ok = False
break
check_cursor = text.createTextCursorByRange(forward_cursor)
check_cursor.gotoEndOfParagraph(True)
para_text = get_string_without_tracked_deletions(check_cursor)
is_last = i == len(parts) - 1
ok, offset_len = _paragraph_matches_part(para_text, parts[i], head=is_last)
if not ok:
chain_ok = False
break
if is_last:
last_end_cursor = text.createTextCursorByRange(forward_cursor)
last_end_cursor.goRight(offset_len, False)
if not chain_ok:
found = search_mod.find_next_after_match(doc, found, sd)
continue
backward_cursor = text.createTextCursorByRange(found)
backward_cursor.gotoRange(found.getStart(), False)
first_start_cursor = None
for i in range(anchor_idx - 1, -1, -1):
if not backward_cursor.gotoPreviousParagraph(False):
chain_ok = False
break
check_cursor = text.createTextCursorByRange(backward_cursor)
check_cursor.gotoEndOfParagraph(True)
para_text = get_string_without_tracked_deletions(check_cursor)
is_first = i == 0
ok, offset_len = _paragraph_matches_part(para_text, parts[i], tail=is_first)
if not ok:
chain_ok = False
break
if is_first:
first_start_cursor = text.createTextCursorByRange(backward_cursor)
first_start_cursor.goRight(offset_len, False)
if chain_ok:
start_range = first_start_cursor.getStart() if first_start_cursor else found.getStart()
end_range = last_end_cursor.getStart() if last_end_cursor else found.getEnd()
try:
result_range = text.createTextCursorByRange(start_range)
result_range.gotoRange(end_range, True)
if not all_matches:
return result_range
matched_ranges.append(result_range)
if len(matched_ranges) >= _MAX_SEARCH_REPLACEMENTS:
return matched_ranges
except Exception:
log.debug("Failed creating combined XTextRange", exc_info=True)
found = search_mod.find_next_after_match(doc, found, sd)
if matched_ranges:
return matched_ranges
return matched_ranges if all_matches else None
def _find_first_range(doc, search_string):
"""First match: LO native search with chaining fallback."""
return _find_chained_range(doc, search_string, all_matches=False)
# Re-export find helpers for tests and sibling modules.
_normalize_search_string_for_find = search_mod.normalize_search_string_for_find
_find_ranges_regex_case = search_mod.find_ranges_regex_case
_drawing_shape_object_containing = search_mod.drawing_shape_object_containing
_drawing_shape_containing = search_mod.drawing_shape_containing
_all_start_indices = search_mod.all_start_indices
def _replace_text_in_shape(shape, old, new):
"""Replace the first occurrence of *old* with *new* inside a drawing shape's own text,
preserving the formatting of the surrounding text via a text cursor. Returns True on success.
Uses character-offset navigation (goRight) which aligns with getString() for single-paragraph
shape text (the common case for callouts / text boxes); fully defensive on any UNO failure."""
try:
xtext = shape.getText()
s = xtext.getString()
except Exception:
return False
idx = s.find(old)
if idx < 0:
return False
# goRight counts UTF-16 code units, while str offsets count code points. Convert so astral
# characters (emoji, rare CJK) in the shape text don't misalign the selection / corrupt text.
left_units = len(s[:idx].encode("utf-16-le")) // 2
old_units = len(old.encode("utf-16-le")) // 2
if old_units <= 0:
return False
try:
cursor = xtext.createTextCursorByRange(xtext.getStart())
if left_units:
cursor.goRight(left_units, False)
cursor.goRight(old_units, True)
cursor.setString(new)
return True
except Exception:
return False
def _find_all_ranges(doc, search_string):
"""All occurrences as TextRanges in document order (NBSP-aware native search with chaining)."""
return _find_chained_range(doc, search_string, all_matches=True)
# Agent-edit tuning knobs. These have sensible fixed defaults and are NOT settings-UI options (no
# demonstrated need to tune them per document), so they are plain named constants -- not config keys.
# A power user can still override any of them ONCE at startup via an environment variable: a named
# constant with an escape hatch, read at import time.
def _env_num(name, default, cast, ok):
"""Read tuning knob *name* from the environment, *cast* it, and return it only if *ok(v)*;
otherwise the fixed *default*. Never raises -- a bad env value must never break an edit."""
try:
v = cast(os.environ[name])
return v if ok(v) else default
except (KeyError, ValueError, TypeError):
return default
# Changed-word fraction at/under which a tracked replace is split into surgical sub-edits instead of
# one whole-block Delete+Insert. > threshold -> one block change (today's behaviour); <= threshold ->
# per-changed-run redlines. See plugin/writer/word_diff_split.py.
_WORD_DIFF_THRESHOLD = _env_num(
"WRITERAGENT_AGENT_EDIT_DIFF_THRESHOLD", 0.6, float, lambda v: 0.0 <= v <= 1.0)
# Max per-changed-run sub-edits before a surgical split falls back to ONE whole block. Bounds the
# cost of recording a very scattered edit (each sub-edit re-enumerates redlines).
_MAX_SURGICAL_RUNS = _env_num(
"WRITERAGENT_AGENT_EDIT_MAX_SURGICAL_RUNS", 40, int, lambda v: v >= 1)
# Whether an agent edit's deletion and insertion are authored separately so LibreOffice's by-author
# coloring renders removed vs new text in two distinct colors (on), or as one author / one color
# (off). Off via the env var = any falsey token ("0", "false", "no", "off", "").
_SPLIT_AUTHOR_COLORS = os.environ.get(
"WRITERAGENT_AGENT_EDIT_SPLIT_AUTHOR_COLORS", "1").strip().lower() not in ("0", "false", "no", "off", "")
# goRight takes a C++ short (max 32767); chunk like the rest of the codebase (format.py, ops.py,
# document_helpers.py) so a surgical sub-edit deep in a LONG block (the splitter's target case)
# never overflows the count.
_GO_RIGHT_CHUNK = 8192
def _go_right(cursor, n, expand):
"""Move (expand=False) or extend (expand=True) the cursor right by *n* chars, in chunks of
_GO_RIGHT_CHUNK (UNO caps the count at a C++ short). Returns True only if the FULL *n* was
consumed; False if goRight stopped early (end of text / an unexpected stop), so the caller can
refuse to edit at a wrong offset instead of silently landing short."""
while n > 0:
step = n if n < _GO_RIGHT_CHUNK else _GO_RIGHT_CHUNK
if not cursor.goRight(step, expand):
return False
n -= step
return True
# Portion types whose presence keeps getString() char offsets aligned with the live cursor's
# goRight stops, so surgical sub-edits still land correctly. "SoftPageBreak" is an automatic
# (layout-only) page break: it contributes 0 chars to getString() and is not a goRight stop --
# verified that navigating by getString offsets across one lands exactly right. Without it, any
# paragraph that happens to straddle a page boundary was needlessly forced to whole-block. Real
# content portions (fields, footnotes, ruby, redline marks, bookmarks, ...) DO shift offsets and
# must keep returning False.
_OFFSET_SAFE_PORTION_TYPES = frozenset({"Text", "SoftPageBreak"})
def _block_safe_for_surgical(found):
"""True only when *found* is a SINGLE paragraph whose portions are all offset-safe (plain text
or an automatic page break) and which has no tracked changes -- the case where getString() char
offsets line up with the live cursor's goRight stops. A multi-paragraph block, a struck
(tracked-deletion) run, or a real content portion (field/footnote/etc.) makes them diverge, so
the surgical sub-edits would land in the wrong place. Best-effort: any doubt -> False, and the
caller falls back to the whole-block replace (which handles every case).
"""
from plugin.writer.edit_review import _string_skipping_redline
try:
# A tracked DELETION leaves struck text in getString() (the diff baseline) that the agent's
# old_content never matched -> the split would be computed against the wrong text.
if found.getString() != _string_skipping_redline(found, "Delete"):
return False
paras = found.createEnumeration()
seen = 0
while paras.hasMoreElements():
para = paras.nextElement()
seen += 1
if seen > 1 or not para.supportsService("com.sun.star.text.Paragraph"):
return False # multi-paragraph or a table/other node
portions = para.createEnumeration()
while portions.hasMoreElements():
if str(portions.nextElement().TextPortionType) not in _OFFSET_SAFE_PORTION_TYPES:
return False # field / footnote / ruby / redline mark -> offsets diverge
return seen == 1
except Exception:
log.debug("content: _block_safe_for_surgical check failed; treating as unsafe", exc_info=True)
return False
# Base title for the grouped undo action wrapping a surgical batch. Each batch gets a UNIQUE title
# (base + a monotonic counter, see _next_surgical_undo_title) which both (a) collapses the batch into
# ONE Ctrl+Z and (b) identifies OUR context on the undo stack before rolling it back on a mid-apply
# failure. Uniqueness is load-bearing across the all_matches loop: an earlier match leaves its
# COMPLETED context titled on top of the stack, so a later match whose EMPTY context is discarded on
# leave must NOT mistake that earlier title for its own and undo a prior good edit (atomicity).
_SURGICAL_UNDO_TITLE = "WriterAgent surgical edit"
_surgical_batch_counter = itertools.count(1)
def _next_surgical_undo_title() -> str:
"""A process-unique grouped-undo title for one surgical batch (base + a monotonic counter)."""
return "%s#%d" % (_SURGICAL_UNDO_TITLE, next(_surgical_batch_counter))
# Base title for the grouped-undo action wrapping a whole HTML/import edit (replace_full_document,
# replace_single_range_with_content, selection insert). Shares the surgical counter so EVERY
# WriterAgent context gets a globally-unique title -- load-bearing for the rollback identity check in
# _close_surgical_context (it undoes a failed batch only when ITS unique title is on top of the stack).
_AGENT_EDIT_UNDO_TITLE = "WriterAgent edit"
def _next_agent_edit_undo_title() -> str:
"""A process-unique grouped-undo title for one HTML/import edit (shares the monotonic counter)."""
return "%s#%d" % (_AGENT_EDIT_UNDO_TITLE, next(_surgical_batch_counter))
def _close_surgical_context(undo_mgr, session, changes_before, applied_ok, undo_title):
"""Close the surgical undo context -- pairing the earlier enterUndoContext exactly once -- and,
on failure, roll the partial batch back.
leaveUndoContext is ALWAYS attempted: the XUndoManager contract requires every enter to be matched
by a leave, and skipping it would leave the context open so later edits nest inside our group and
the undo stack drifts. A leave failure is logged at WARNING (it can leave the stack inconsistent),
not swallowed at debug. On the SUCCESS path nothing else is needed -- the edits are valid; the
worst a failed leave does is leave the grouping open, surfaced loudly rather than by undoing
good changes.
On the FAILURE path we additionally roll back: only if the leave succeeded AND the top of the undo
stack is THIS batch's unique ``undo_title`` do we undo it -- reverting even a half-applied
delete+insert -- then drop the partial change records + anchor bookmarks. The per-batch-unique
title is essential: when our context was empty (first sub-edit failed before mutating) it is
discarded on leave, exposing whatever was on top before -- which in an all_matches loop is an
EARLIER successful surgical batch; a constant title would match it and undo that good edit, so we
match the unique title and skip undo when it is not ours. Records are KEPT (not orphaned) when
undo() itself fails. Best-effort throughout; never masks the caller's original error."""
left = False
try:
undo_mgr.leaveUndoContext()
left = True
except Exception:
log.warning("content: leaveUndoContext failed; the undo stack may be inconsistent", exc_info=True)
if applied_ok:
return # success: edits stand; a failed leave was already surfaced above
undone = False
if left:
try:
titles = undo_mgr.getAllUndoActionTitles() # newest-first per XUndoManager
if titles and titles[0] == undo_title:
undo_mgr.undo()
undone = True
except Exception:
log.warning("content: undo of partial surgical batch failed; document may be partially "
"edited", exc_info=True)
# Trim records when the document mutations were reverted, or when nothing was applied at all
# (empty context -> changes unchanged). Keep them when undo() demonstrably failed (or couldn't
# run because the context wouldn't close) so a live partial edit keeps a reviewable record.
kept = len(session.changes) - changes_before
if undone or kept == 0:
try:
session.discard_changes_since(changes_before)
except Exception:
log.debug("content: discarding partial surgical change records failed", exc_info=True)
else:
log.warning("content: surgical rollback could not undo a partial batch; keeping %d change "
"record(s) so the partial edit stays reviewable", kept)
def _apply_in_undo_context(doc, session, run):
"""Run *run(in_undo_context)* -- which must perform exactly ONE session.record_mutation -- inside a
fresh grouped-undo context when one can be opened, so a split-author delete+insert stays atomic:
on failure the whole context is undone (reverting even a half-applied edit) and the partial record
dropped.
Mirrors the surgical batch's context handling: opens a PROCESS-UNIQUE context (so an earlier
batch's completed title on the undo stack is never mistaken for ours when rolling back) only when
the undo manager is usable and UNLOCKED (a locked manager silently ignores enterUndoContext, so we
would hold no real context and no rollback), calls ``run(True)``, and ALWAYS pairs
the enter with exactly one leave via _close_surgical_context. Without a usable, unlocked manager it
falls back to ``run(False)`` -- the single atomic setString, itself all-or-nothing (so the only
cost of the fallback is that the edit renders in one color instead of two)."""
undo_mgr = None
undo_title = _next_surgical_undo_title()
try:
mgr = doc.getUndoManager()
if mgr.isLocked():
raise RuntimeError("undo manager is locked; enterUndoContext would be a no-op")
mgr.enterUndoContext(undo_title)
undo_mgr = mgr
except Exception:
undo_mgr = None
if undo_mgr is None:
log.debug("content: no usable/unlocked undo manager; split-author whole-block edit falls back "
"to the single atomic op (one color)")
run(False)
return
changes_before = len(session.changes)
applied_ok = False
try:
run(True)
applied_ok = True
finally:
# ALWAYS pair the enterUndoContext with exactly one leave (XUndoManager contract); on failure
# this also rolls the partial edit back. The original exception (if any) propagates after.
_close_surgical_context(undo_mgr, session, changes_before, applied_ok, undo_title)
def _record_html_atomically(session, doc, mutate, track_reviewable, **record_kwargs):
"""Record an HTML/import mutation that DELETES before it inserts -- replace_full_document,
replace_single_range_with_content, and the selection branch of insert_content_at_position all do
setString("") and THEN run a separate HTML import that can throw -- so it is ATOMIC: the delete
and the import either both land or neither does.
record_mutation() opens no undo context, so a throwing import would otherwise strand a bare
deletion (a half-applied edit). Unlike the plain-text path there is no atomic single-op variant of
an HTML import, so we wrap the whole mutation in ONE grouped undo context and, on failure, undo it
(reverting the partial deletion) before re-raising. This holds in ALL modes -- review recording ON
or OFF -- because the delete-before-insert is non-atomic either way: e.g. block/rich HTML into a
table cell deletes the match and THEN throws (format.replace_single_range_with_content), which with
review off previously stranded the deletion + flattened text (status "error" but the doc changed).
If no usable/unlocked undo manager is available we REFUSE before mutating (fail closed) rather than
risk a partial edit. ``track_reviewable`` no longer gates atomicity; ``session.record_mutation``
records a reviewable change (or not) from the session's own enabled state."""
undo_title = _next_agent_edit_undo_title()
try:
mgr = doc.getUndoManager()
if mgr.isLocked():
raise RuntimeError("undo manager is locked; enterUndoContext would be a no-op")
mgr.enterUndoContext(undo_title)
except Exception:
# No rollback available -> we cannot guarantee all-or-nothing, and an HTML import has no atomic
# single-op fallback. Refuse BEFORE any mutation so the document is never left half-edited.
raise ToolExecutionError(
"Cannot apply this content edit atomically (no usable undo context); "
"refusing rather than risk a half-applied edit.")
changes_before = len(session.changes)
applied_ok = False
try:
result = session.record_mutation(mutate, **record_kwargs)
applied_ok = True
return result
finally:
# Pair the enter with exactly one leave; on failure undo the partial edit and drop its record.
_close_surgical_context(mgr, session, changes_before, applied_ok, undo_title)
# --- post-edit echo (edited_context) ---------------------------------------
_EDITED_CONTEXT_MAX_CHARS = 700
def _collapsed_anchor(text_range):
"""A collapsed model cursor at *text_range*'s start. Positions (unlike content ranges)
survive a delete-then-import replace, so this anchors the echo window across the edit.
Best-effort: None when the range's text does not support cursors (echo is then skipped)."""
try:
return text_range.getText().createTextCursorByRange(text_range.getStart())
except Exception:
return None
def _selection_anchor(doc):
"""Collapsed anchor at the view cursor's start (the selection insert site)."""
try:
vc = doc.getCurrentController().getViewCursor()
return vc.getText().createTextCursorByRange(vc.getStart())
except Exception:
return None
def _paragraph_window_text(anchor, max_chars=_EDITED_CONTEXT_MAX_CHARS):
"""Plain text of the paragraph around *anchor* plus one neighbor each side, read AFTER the
edit. Returns None instead of guessing when the paragraph walk fails (e.g. exotic nested
text) — the echo must be right or absent, never wrong. In record/wait the returned text is
the current text model, i.e. it includes the pending tracked change."""
if anchor is None:
return None
try:
text = anchor.getText()
start = text.createTextCursorByRange(anchor.getStart())
start.gotoStartOfParagraph(False)
start.gotoPreviousParagraph(False) # False at the first paragraph -> stays put
start.gotoStartOfParagraph(False)
end = text.createTextCursorByRange(anchor.getEnd())
end.gotoEndOfParagraph(False)
end.gotoNextParagraph(False) # False at the last paragraph -> stays put
end.gotoEndOfParagraph(False)
span = text.createTextCursorByRange(start.getStart())
span.gotoRange(end.getEnd(), True)
s = span.getString()
except Exception:
return None
if not s or not s.strip():
return None
if len(s) > max_chars:
head = int(max_chars * 0.6)
tail = max_chars - head - 7
s = s[:head] + " [...] " + s[-tail:]
return s
def _attach_edited_context(result, anchor):
"""Add edited_context (the touched paragraph(s) as they now read) to a successful result."""
snippet = _paragraph_window_text(anchor)
if snippet:
result["edited_context"] = snippet
return result
def _record_preserve_replace(session, doc, found, new_text, uno_ctx, split):
"""Record a format-preserving replace as ONE reviewable change, or -- when *split* (review
recording is on) and only PART of the block changed -- as several SURGICAL sub-changes,
each its own tracked Delete+Insert with its own accept/reject outcome.
Splitting keeps a one- or two-word tweak in a long paragraph from rendering (and having to be
accepted) as a whole-paragraph delete+insert. The agent still issues a single edit; it simply
gets one outcome per sub-change. A large change (> threshold of words changed) stays a single
clean block edit, matching today's behaviour.
"""
from . import format as format_support
# Split-author coloring (deletion authored distinctly from insertion -> two by-author colors) is
# applied CONSISTENTLY to both the whole-block and surgical paths, so an agent edit looks the same
# however it lands. It is meaningful only when recording tracked changes, so it is scoped to
# *split* (review recording on) -- the non-review path is left exactly as before. Default on;
# override with WRITERAGENT_AGENT_EDIT_SPLIT_AUTHOR_COLORS.
split_author = split and _SPLIT_AUTHOR_COLORS
def _bound(s): # this path is plain text (use_preserve); just cap the preview length
s = s or ""
return s if len(s) <= 300 else s[:299] + "…"
def _whole():
# The split-author two-step (delete authored distinctly, then insert) needs an open undo
# context to stay atomic, so when it's on we wrap the single record_mutation in one
# (_apply_in_undo_context; it falls back to the atomic single-op if no usable manager). When
# split-author is off, in_undo_context=False uses the single atomic setString directly (one UNO
# action, never a partial deletion) -- today's behaviour.
original = found.getString()
def _run(in_undo_context):
session.record_mutation(
lambda: format_support.replace_preserving_format(
doc, found, new_text, uno_ctx,
in_undo_context=in_undo_context, split_author=split_author),
original_preview=_bound(original), proposed_preview=_bound(new_text))
if split_author:
_apply_in_undo_context(doc, session, _run)
else:
_run(False)
if not split:
_whole()
return
from plugin.writer.word_diff_split import split_change
result = split_change(found.getString(), new_text, _WORD_DIFF_THRESHOLD)
if not result.is_surgical:
_whole() # big change -> single clean block (today's behaviour)
return
if not result.sub_edits:
return # old == new: nothing changed, record nothing
if len(result.sub_edits) > _MAX_SURGICAL_RUNS:
_whole() # too many scattered runs -> one block (bounded cost)
return
if not _block_safe_for_surgical(found):
_whole() # not plain single-paragraph text -> offsets unsafe; whole-block
return
text = found.getText()
anchor = found.getStart()
def _select(se):
"""A fresh cursor selecting old[se.old_start:se.old_end] within the block, navigating from
the block start by char offset. Returns None if the cursor cannot be positioned EXACTLY
(goRight stopped early), so the caller never edits the wrong span."""
sub = text.createTextCursorByRange(anchor)
if se.old_start and not _go_right(sub, se.old_start, False):
return None
if se.old_end > se.old_start and not _go_right(sub, se.old_end - se.old_start, True):
return None
return sub
# Pre-flight: every sub-edit must select EXACTLY its expected old_text on the still-pristine
# block before we mutate anything. _block_safe_for_surgical already vets the block, but if an
# offset still doesn't line up (a stop it didn't catch), abort surgical and fall back to the
# whole-block replace -- never silently edit the wrong characters.
for se in result.sub_edits:
sub = _select(se)
if sub is None or sub.getString() != se.old_text:
log.debug("content: surgical pre-flight offset mismatch; falling back to whole-block")
_whole()
return
# Validated. Apply right-to-left so each sub-edit's char offsets into the ORIGINAL block stay
# valid (the text to its left is still pristine when we reach it). goRight is chunked.
#
# Atomicity: a sub-edit replace DELETES the old text before inserting the new, and the
# post-edit redline enumeration can also raise -- so a sub-edit CAN fail mid-apply, after earlier
# ones already landed. The pre-flight makes that rare, not impossible. To keep the batch
# all-or-nothing we group every sub-edit in ONE document undo context: on any failure we undo the
# whole context (reverting even a half-applied delete+insert) and drop the partial change records
# plus their anchor bookmarks, then re-raise so the tool reports an honest failure instead of a
# silently half-rewritten paragraph. Without a usable undo manager we can't group a multi-edit
# batch, so we fall back to the whole-block replace -- which is itself atomic (it passes
# in_undo_context=False, so format uses the single atomic setString, never a partial deletion).
undo_mgr = None
undo_title = _next_surgical_undo_title()
try:
mgr = doc.getUndoManager()
# A LOCKED undo manager silently IGNORES enterUndoContext (and would not record the
# mutations), so we'd hold no real context and have no rollback. Detect it and fall back
# rather than apply a multi-edit batch we cannot undo.
if mgr.isLocked():
raise RuntimeError("undo manager is locked; enterUndoContext would be a no-op")
mgr.enterUndoContext(undo_title)
undo_mgr = mgr
except Exception:
undo_mgr = None
if undo_mgr is None:
log.debug("content: no usable/unlocked undo manager; surgical edit falls back to whole-block "
"for atomicity")
_whole()
return
changes_before = len(session.changes)
applied_ok = False
try:
for se in sorted(result.sub_edits, key=lambda e: e.old_start, reverse=True):
def apply_se(se=se):
sub = _select(se)
# Pre-flight proved these offsets on the pristine block and right-to-left order keeps
# the left pristine, so this should always match. If it ever doesn't, fail LOUD rather
# than corrupt the doc or ship a silent partial edit (tripwire).
if sub is None or sub.getString() != se.old_text:
raise RuntimeError(
"surgical sub-edit offset drifted at apply time; aborting to avoid corruption")
# in_undo_context=True: this runs inside the undo context opened below, so the
# split-author delete+insert is safe -- a failed insert is rolled back by the context
# (the explicit flag, NOT a guess from the manager state). split_author
# is threaded through so the surgical path honours the same coloring choice as the
# whole-block path (off -> the atomic single-op, still inside this context).
format_support.replace_preserving_format(doc, sub, se.new_text, uno_ctx,
in_undo_context=True,
split_author=split_author)
session.record_mutation(
apply_se,
original_preview=_bound(se.old_text),
proposed_preview=_bound(se.new_text))
applied_ok = True
finally:
# ALWAYS pair the enterUndoContext with exactly one leave (XUndoManager contract); on failure
# this also rolls the partial batch back. The original exception (if any) propagates after.
_close_surgical_context(undo_mgr, session, changes_before, applied_ok, undo_title)
# ------------------------------------------------------------------
# GetDocumentContent
# ------------------------------------------------------------------
class GetDocumentContent(ToolBase):
"""Export the document (or a portion) as formatted content."""
name = "get_document_content"
description = "Get document (or selection/range) content. Result includes document_length. scope: full, selection, or range (requires start, end)."
parameters = {
"type": "object",
"properties": {
"scope": {"type": "string", "enum": ["full", "selection", "range"], "description": ("Return full document (default), current selection/cursor region, or a character range (requires start and end).")},
"max_chars": {"type": "integer", "description": "Maximum characters to return."},
"start": {"type": "integer", "description": "Start character offset (0-based). Required for scope 'range'."},
"end": {"type": "integer", "description": "End character offset (exclusive). Required for scope 'range'."},
"include_images": {"type": "boolean", "description": "Include embedded image data (base64) in export. Default false."},
},
"required": [],
}
uno_services = ["com.sun.star.text.TextDocument"]
tier = "core"
def execute(self, ctx, **kwargs):
from . import format as format_support
scope = kwargs.get("scope", "full")
max_chars = kwargs.get("max_chars")
range_start = kwargs.get("start") if scope == "range" else None
range_end = kwargs.get("end") if scope == "range" else None
if scope == "range" and (range_start is None or range_end is None):
return self._tool_error("scope 'range' requires start and end.")
include_images = bool(kwargs.get("include_images", False))
content = format_support.document_to_content(
ctx.doc,
ctx.ctx,
ctx.services,
max_chars=max_chars,
scope=scope,
range_start=range_start,
range_end=range_end,
include_images=include_images,
)
doc_len = ctx.services.document.get_document_length(ctx.doc)
result = {"status": "ok", "content": content, "length": len(content), "document_length": doc_len}
# Machine-readable truncation signal: without it the only clue was the in-band marker
# string, which a model must know to look for. (length counts HTML chars; document_length
# and scope='range' offsets are plain-text chars — use those for follow-up range reads.)
if max_chars and isinstance(content, str) and content.endswith("[... truncated ...]"):
result["truncated"] = True
if scope == "range" and range_start is not None and range_end is not None:
result["start"] = int(range_start)
result["end"] = int(range_end)
# The HTML content above hides tracked deletions and gives no sign that changes are pending.
# When the document has tracked changes, surface them explicitly (insertion vs deletion, with
# text) and say they await the user's review — so the model treats them as pending, not errors,
# and never resolves them itself.
try:
if hasattr(ctx.doc, "getRedlines") and ctx.doc.getRedlines().getCount() > 0:
changes = collect_tracked_changes(ctx.doc.getText())
if changes:
result["tracked_changes"] = changes
result["tracked_changes_note"] = (
"This document has %d change(s) recorded as tracked changes (listed in "
"tracked_changes as insertions/deletions). They are PENDING the user's review — "
"not errors and not yet final. Do NOT accept or reject them yourself; that is the "
"user's decision." % len(changes)
)
except Exception:
log.debug("get_document_content: could not collect tracked changes", exc_info=True)
return result
# ------------------------------------------------------------------
# ApplyDocumentContent
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# ApplyDocumentContent
# ------------------------------------------------------------------
class ApplyDocumentContent(ToolBase):
"""Insert or replace content in the document.
Design notes (important for callers and future maintainers):
- **Two edit paths**:
- *Import path* (HTML/markup): for structural rewrites (tables, headings,
page changes) we prepare HTML in `format_support` and import it via
``insertDocumentFromURL``. This is what all of the `insert_*` helpers
use.
- *Format‑preserving path* (plain text): for small textual corrections
we avoid HTML entirely and call `format_support.replace_preserving_format`,
which mutates characters in place so existing character‑level styling
(bold, colors, background fills, etc.) is preserved even when the
replacement text length differs.
- **Decision rule**: we treat content as *plain text* (and thus eligible
for format‑preserving replacement) only when `content_has_markup` is
false. Any obvious HTML/Markdown markers force the import path. This
keeps the heuristic simple and robust: small literal edits naturally
stay plain text; rich formatting naturally uses HTML.
- **Raw vs wrapped content**: `raw_content` is captured *before* any HTML
wrapping or newline normalization and is passed to the preserving path;
the (possibly HTML‑wrapped) `content` value is passed to the import path.
Mixing these up will overwrite document text with serialized HTML rather
than the intended human‑readable string.
- **Search** (``target='search'`` only): ``old_content`` must be a **substring** to find —
a phrase, sentence, or multi-paragraph **block**, not the entire document. To replace
**all** document content, you **must** use ``target='full_document'`` with ``content`` only;
**never** pass the full body as ``old_content``. Search uses ``_find_chained_range`` (LO
regex + paragraph chaining). See ``tests/writer/test_content_search_uno.py``.
"""
name = "apply_document_content"
description = (
"Insert or replace content. "
f"IMPORTANT: {APPLY_DOCUMENT_CONTENT_TOOL_RESEARCH_HINT} "
"To replace the ENTIRE document use target='full_document' with content only — "
"do NOT pass the whole document as old_content. "
"Use target='beginning', 'end', or 'selection' to insert. "
"Use target='search' with old_content for find-and-replace of a specific substring only."
)
parameters = {
"type": "object",
"properties": {
"content": {"type": "array", "items": {"type": "string"}, "description": ("List of HTML fragments or plain-text fragments (one per block); shape and math per the APPLY_DOCUMENT_CONTENT AND HTML rules — the editing-html guidance covers them if they are not already in your context. No Markdown.")},
"target": {"type": "string", "enum": ["beginning", "end", "selection", "full_document", "search"], "description": "Where to apply the content."},
"old_content": {"type": "string", "description": ("Substring to find when target='search'. Not for whole-document replace — use target='full_document' instead.")},
"all_matches": {"type": "boolean", "description": "Replace all occurrences (true) or first only. Default false. Only for target='search' with position='replace'."},
"position": {"type": "string", "enum": ["replace", "before", "after"], "description": ("For target='search': 'replace' (default) replaces the match; 'before'/'after' INSERT the content next to the match and leave the matched text untouched (result reports inserted=true instead of replaced_count).")},
"dry_run": {"type": "boolean", "description": "For target='search': do NOT edit. Return how many times old_content matches and where each match lives, so you can check before committing."},
"regex": {"type": "boolean", "description": "For target='search': treat old_content as a regular expression (default false = literal). Regex mode is single-paragraph (no cross-paragraph chaining)."},
"case_sensitive": {"type": "boolean", "description": "For target='search': force case-sensitive (true) or case-insensitive (false) matching. Omit for the default lenient match."},
},
"required": ["content"],
}
uno_services = ["com.sun.star.text.TextDocument"]
tier = "core"
is_mutation = True
def _review_wait_seconds(self, uno_ctx):
"""Max seconds the edit call should block waiting for review; 0 = don't wait."""
try:
return edit_review_wait_seconds(uno_ctx)
except Exception:
return 0
def _annotate_review_status(self, uno_ctx, result):
"""Tag a successful edit result with the CURRENT review status, so the model gets a fresh,
per-call signal even if the guidance it read earlier (the connect-time pointer, a pulled
review-modes topic, or the sidebar prompt) is stale — that text is static while the user
can toggle the mode mid-session. Only annotates the non-wait path:
with recording on but no blocking wait, the edit landed as a tracked change the agent must
not resolve and whose accept/reject outcome it will not be told."""
if not isinstance(result, dict) or result.get("status") != "ok":
return result
try:
mode = get_agent_edit_review_mode(uno_ctx)
except Exception:
return result
if mode not in ("record", "wait"):
return result # off -> edit is live; nothing to add
result = dict(result)
result["review_mode"] = mode
result["pending_review"] = True
result["message"] = (result.get("message") or "") + (
" Applied as a tracked change pending the user's review — do not accept or reject it"
" yourself, and you will not be notified whether it is later accepted or rejected."
)
return result
def _wait_enabled_globally(self):
"""Config read without a tool context, for long_running/is_async (called by the
MCP/chat shells before execute). False whenever the context isn't available."""
try: