codex-overleaf-link 1.8.5 → 1.9.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.
- package/README.md +45 -21
- package/bin/codex-overleaf-link.mjs +58 -0
- package/extension/assets/icons/codex-overleaf-dialog-icon.png +0 -0
- package/extension/assets/icons/codex-overleaf-icon.png +0 -0
- package/extension/assets/icons/icon128.png +0 -0
- package/extension/assets/icons/icon16.png +0 -0
- package/extension/assets/icons/icon32.png +0 -0
- package/extension/assets/icons/icon48.png +0 -0
- package/extension/bootstrap/background.js +404 -0
- package/extension/bootstrap/manifest.template.json +51 -0
- package/extension/bootstrap/popup.css +26 -0
- package/extension/bootstrap/popup.html +38 -0
- package/extension/bootstrap/popup.js +44 -0
- package/extension/bootstrap/runtimeContext.js +2 -0
- package/extension/runtime-manifest.json +60 -0
- package/extension/src/background.js +532 -0
- package/extension/src/content/applyResultFormatters.js +387 -0
- package/extension/src/content/composerAttachments.js +522 -0
- package/extension/src/content/composerPanel.js +254 -0
- package/extension/src/content/contentRuntime.js +8895 -0
- package/extension/src/content/contextTray.js +686 -0
- package/extension/src/content/diagnosticsController.js +659 -0
- package/extension/src/content/diagnosticsPanel.js +342 -0
- package/extension/src/content/diffReviewPanel.js +797 -0
- package/extension/src/content/localSkillsPanel.js +280 -0
- package/extension/src/content/markdownText.js +606 -0
- package/extension/src/content/mirrorHealth.js +92 -0
- package/extension/src/content/modelPicker.js +489 -0
- package/extension/src/content/nativeChannel.js +62 -0
- package/extension/src/content/otWarmMirror.js +964 -0
- package/extension/src/content/otWarmMirrorController.js +174 -0
- package/extension/src/content/panelMaintenance.js +367 -0
- package/extension/src/content/panelRenderer.js +253 -0
- package/extension/src/content/recentProjects.js +1063 -0
- package/extension/src/content/reviewHunks.js +247 -0
- package/extension/src/content/runController.js +242 -0
- package/extension/src/content/runTimelineView.js +1126 -0
- package/extension/src/content/sessionManager.js +487 -0
- package/extension/src/content/sessionPanel.js +263 -0
- package/extension/src/content/settingsPanel.js +527 -0
- package/extension/src/content/themeController.js +91 -0
- package/extension/src/content/updateIdle.js +67 -0
- package/extension/src/content/writebackController.js +300 -0
- package/extension/src/content/writebackOrchestrator.js +850 -0
- package/extension/src/contentScript.js +8 -0
- package/extension/src/page/compileBridge.js +460 -0
- package/extension/src/page/overleafCapabilities.js +100 -0
- package/extension/src/page/overleafEditor.js +282 -0
- package/extension/src/page/overleafProjectSnapshot.js +234 -0
- package/extension/src/page/overleafRealtimeObserver.js +491 -0
- package/extension/src/page/pageBridgeCapability.js +71 -0
- package/extension/src/page/snapshotRouter.js +948 -0
- package/extension/src/page/trackedChangesLifecycle.js +1506 -0
- package/extension/src/page/treeOperations.js +1584 -0
- package/extension/src/page/writeGuard.js +142 -0
- package/extension/src/page/writebackRouter.js +1934 -0
- package/extension/src/pageBridge.js +2224 -0
- package/extension/src/popup.js +247 -0
- package/extension/src/shared/compatibility.js +1 -1
- package/extension/styles/panel.css +4048 -0
- package/native-host/src/index.js +9 -2
- package/native-host/src/launcher.js +2 -0
- package/native-host/src/managedInstall.js +393 -0
- package/native-host/src/managedLauncherRuntime.js +71 -0
- package/native-host/src/nativeEnvironment.js +4 -0
- package/native-host/src/nativeHostPlatform.js +16 -0
- package/native-host/src/runtimeInstaller.js +6 -1
- package/native-host/src/taskRunnerRuntime.js +8 -0
- package/native-host/src/updateArchive.js +253 -0
- package/native-host/src/updateManager.js +548 -0
- package/native-host/src/updateTrust.js +156 -0
- package/package.json +11 -2
- package/scripts/install-managed.mjs +64 -0
- package/scripts/uninstall-managed.mjs +48 -0
|
@@ -0,0 +1,1506 @@
|
|
|
1
|
+
(function initCodexOverleafTrackedChangesLifecycle(root, factory) {
|
|
2
|
+
if (typeof module === 'object' && module.exports) {
|
|
3
|
+
module.exports = factory();
|
|
4
|
+
} else {
|
|
5
|
+
root.CodexOverleafTrackedChangesLifecycle = factory();
|
|
6
|
+
}
|
|
7
|
+
})(typeof window !== 'undefined' ? window : globalThis, function trackedChangesLifecycleFactory() {
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
// Tracked-changes lifecycle — carved out of writebackRouter.js in v1.8.0
|
|
11
|
+
// (structural-debt phase 7, #117): the accept/reject flows, the
|
|
12
|
+
// accept-replay + editor-undo recovery machinery, the reviewing/editing
|
|
13
|
+
// blocked-result builders, and the DOM collectors for Overleaf's
|
|
14
|
+
// tracked-change widgets. Code moved verbatim (original indentation kept);
|
|
15
|
+
// page-side collaborators are factory-injected by writebackRouter, which
|
|
16
|
+
// remains the only consumer and the owner of applyOperations itself.
|
|
17
|
+
function create(deps = {}) {
|
|
18
|
+
const {
|
|
19
|
+
window,
|
|
20
|
+
checkWritebackRunProjectId,
|
|
21
|
+
buildNoTraceUndoBlockedResult,
|
|
22
|
+
buildReviewingRequiredBlockedResult,
|
|
23
|
+
buildEditingRequiredBlockedResult,
|
|
24
|
+
compileBridge,
|
|
25
|
+
applyOperationsCore,
|
|
26
|
+
clickNode,
|
|
27
|
+
compact,
|
|
28
|
+
invalidProjectPathResult,
|
|
29
|
+
isEditingConfirmedForNoTraceUndo,
|
|
30
|
+
isReviewingConfirmedForWrite,
|
|
31
|
+
uniqueNodes,
|
|
32
|
+
normalizeTrackedChangeRefs,
|
|
33
|
+
applyOperationsWithNoTraceUndo,
|
|
34
|
+
applyTextPatches,
|
|
35
|
+
buildPageFailure,
|
|
36
|
+
delay,
|
|
37
|
+
getActiveFilePath,
|
|
38
|
+
getReviewingState,
|
|
39
|
+
isEditorUndoControl,
|
|
40
|
+
isInsideCodexPanel,
|
|
41
|
+
normalizeOperationPaths,
|
|
42
|
+
normalizeReviewingSignalText,
|
|
43
|
+
normalizeSafeProjectPath,
|
|
44
|
+
openFileByPath,
|
|
45
|
+
readActiveEditorText,
|
|
46
|
+
readNodeSignalText,
|
|
47
|
+
setReviewingEnabled,
|
|
48
|
+
summarizeReviewingToggleResult,
|
|
49
|
+
verifyActiveEditorText,
|
|
50
|
+
collectElements,
|
|
51
|
+
} = deps;
|
|
52
|
+
|
|
53
|
+
async function rejectTrackedChanges(params = {}) {
|
|
54
|
+
// Welcome-panel + write-guard: defense-in-depth.
|
|
55
|
+
// The pageBridge wrapper already runs the runProjectId guard, but a
|
|
56
|
+
// future caller could reach the router directly. A missing or empty
|
|
57
|
+
// `runProjectId` blocks the reject with the same shape the page-side
|
|
58
|
+
// guard uses. Existing tests that target individual reject behaviors set
|
|
59
|
+
// `runProjectId` on params; tests that intentionally omit it land here.
|
|
60
|
+
const writeGuardBlock = checkWritebackRunProjectId(params);
|
|
61
|
+
if (writeGuardBlock) return writeGuardBlock;
|
|
62
|
+
const trackedChanges = normalizeTrackedChangeRefs(params.trackedChanges || []);
|
|
63
|
+
const expectedFiles = Array.isArray(params.expectedFiles) ? params.expectedFiles : [];
|
|
64
|
+
const postFiles = Array.isArray(params.postFiles) ? params.postFiles : [];
|
|
65
|
+
const applied = [];
|
|
66
|
+
const skipped = [];
|
|
67
|
+
const appliedPaths = new Set();
|
|
68
|
+
const invalidTrackedChange = trackedChanges.find(trackedChange => trackedChange.invalidProjectPath);
|
|
69
|
+
if (invalidTrackedChange) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
applied,
|
|
73
|
+
skipped: [{
|
|
74
|
+
trackedChange: invalidTrackedChange,
|
|
75
|
+
result: invalidProjectPathResult('tracked-change path')
|
|
76
|
+
}]
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const editorUndo = await rejectTrackedChangesViaEditorUndo(expectedFiles, postFiles, applied);
|
|
81
|
+
if (editorUndo.ok) {
|
|
82
|
+
if (applied.length > 0) {
|
|
83
|
+
compileBridge.markSourceEdited();
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
applied,
|
|
88
|
+
skipped
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const snapshotUndo = await restoreExpectedFilesWithNoTraceUndo(expectedFiles, postFiles);
|
|
92
|
+
if (snapshotUndo.attempted) {
|
|
93
|
+
applied.push(...snapshotUndo.applied);
|
|
94
|
+
skipped.push(...snapshotUndo.skipped);
|
|
95
|
+
if (applied.length > 0) {
|
|
96
|
+
compileBridge.markSourceEdited();
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
ok: skipped.length === 0,
|
|
100
|
+
applied,
|
|
101
|
+
skipped
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (editorUndo.attempted) {
|
|
105
|
+
skipped.push({
|
|
106
|
+
trackedChange: null,
|
|
107
|
+
result: editorUndo
|
|
108
|
+
});
|
|
109
|
+
if (applied.length > 0) {
|
|
110
|
+
compileBridge.markSourceEdited();
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
applied,
|
|
115
|
+
skipped
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!trackedChanges.length) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
applied,
|
|
123
|
+
skipped: [{
|
|
124
|
+
trackedChange: null,
|
|
125
|
+
result: {
|
|
126
|
+
ok: false,
|
|
127
|
+
code: 'missing_tracked_changes',
|
|
128
|
+
reason: '这轮写入没有可识别的 Overleaf 留痕记录;为避免制造新的红线,Codex 没有执行文本补丁撤销。'
|
|
129
|
+
}
|
|
130
|
+
}]
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const trackedChange of orderTrackedChangesForReviewAction(trackedChanges)) {
|
|
135
|
+
if (trackedChange.path && getActiveFilePath() !== trackedChange.path) {
|
|
136
|
+
const opened = await openFileByPath(trackedChange.path);
|
|
137
|
+
if (!opened.ok) {
|
|
138
|
+
if (expectedFiles.length || trackedChange.path) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
skipped.push({
|
|
142
|
+
trackedChange,
|
|
143
|
+
result: {
|
|
144
|
+
ok: false,
|
|
145
|
+
code: 'tracked_change_file_open_failed',
|
|
146
|
+
reason: `无法打开 ${trackedChange.path} 来查找这轮写入的留痕记录;Codex 没有用文本补丁伪撤销。`
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let node = findTrackedChangeNode(trackedChange);
|
|
154
|
+
let actualTrackedChange = trackedChange;
|
|
155
|
+
if (!node && trackedChange.path && appliedPaths.has(trackedChange.path)) {
|
|
156
|
+
node = findNextTrackedChangeNodeForPath(trackedChange.path);
|
|
157
|
+
if (node) {
|
|
158
|
+
actualTrackedChange = trackedChangeRefFromNode(node, trackedChange.path);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!node) {
|
|
162
|
+
if (expectedFiles.length || trackedChange.path) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
skipped.push({
|
|
166
|
+
trackedChange,
|
|
167
|
+
result: {
|
|
168
|
+
ok: false,
|
|
169
|
+
code: 'tracked_change_not_found',
|
|
170
|
+
reason: '没有在 Overleaf 页面里找到这轮写入对应的留痕记录;Codex 没有用文本补丁伪撤销。'
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const rejectControl = findRejectControlForTrackedChangeNode(node);
|
|
177
|
+
if (!rejectControl) {
|
|
178
|
+
if (expectedFiles.length || trackedChange.path) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
skipped.push({
|
|
182
|
+
trackedChange,
|
|
183
|
+
result: {
|
|
184
|
+
ok: false,
|
|
185
|
+
code: 'tracked_change_reject_control_not_found',
|
|
186
|
+
reason: '找到了这轮写入的留痕记录,但没有找到对应的 Reject/拒绝按钮;Codex 没有用文本补丁伪撤销。'
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
clickNode(rejectControl);
|
|
193
|
+
await delay(180);
|
|
194
|
+
|
|
195
|
+
if (findTrackedChangeNode(trackedChange)) {
|
|
196
|
+
if (expectedFiles.length || trackedChange.path) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
skipped.push({
|
|
200
|
+
trackedChange,
|
|
201
|
+
result: {
|
|
202
|
+
ok: false,
|
|
203
|
+
code: 'tracked_change_reject_not_confirmed',
|
|
204
|
+
reason: 'Codex 点击了 Reject/拒绝,但 Overleaf 页面仍显示这条留痕记录;请在 Overleaf 审阅面板手动拒绝。'
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
applied.push({
|
|
211
|
+
trackedChange: actualTrackedChange,
|
|
212
|
+
result: {
|
|
213
|
+
ok: true,
|
|
214
|
+
method: 'overleaf-review-reject'
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
if (actualTrackedChange.path) {
|
|
218
|
+
appliedPaths.add(actualTrackedChange.path);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (expectedFiles.length) {
|
|
223
|
+
const completion = await rejectRemainingTrackedChangesForExpectedFiles(expectedFiles, applied);
|
|
224
|
+
if (!completion.ok) {
|
|
225
|
+
skipped.push({
|
|
226
|
+
trackedChange: null,
|
|
227
|
+
result: completion
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
const completion = await rejectRemainingTrackedChangesForTrackedPaths(trackedChanges, applied);
|
|
232
|
+
if (!completion.ok) {
|
|
233
|
+
skipped.push({
|
|
234
|
+
trackedChange: null,
|
|
235
|
+
result: completion
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (applied.length > 0) {
|
|
241
|
+
compileBridge.markSourceEdited();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
ok: skipped.length === 0,
|
|
246
|
+
applied,
|
|
247
|
+
skipped
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Accept All — instead of hunting Overleaf's per-change Accept controls (which
|
|
252
|
+
// is unreliable in real browser use), reuse two proven-reliable mechanisms:
|
|
253
|
+
//
|
|
254
|
+
// 1. Editor-undo the run's tracked writeback back to its pre-write content
|
|
255
|
+
// (the reject path's primary mechanism), which makes every one of the
|
|
256
|
+
// run's tracked changes vanish.
|
|
257
|
+
// 2. Switch Overleaf to Editing mode (Track Changes OFF) and re-apply the
|
|
258
|
+
// run's post-write content as a plain, untracked edit.
|
|
259
|
+
//
|
|
260
|
+
// Net result: the run's new content lands as permanent, untracked text — the
|
|
261
|
+
// run is decisively accepted, with no DOM-control hunting.
|
|
262
|
+
//
|
|
263
|
+
// If the editor-undo cannot reach the pre-write state (content drifted — e.g.
|
|
264
|
+
// the user edited after the run), this bails WITHOUT re-writing so it never
|
|
265
|
+
// makes the document worse, mirroring Undo's safety stance.
|
|
266
|
+
async function acceptTrackedChanges(params = {}) {
|
|
267
|
+
// Welcome-panel + write-guard: defense-in-depth.
|
|
268
|
+
// The pageBridge wrapper already runs the runProjectId guard, but a
|
|
269
|
+
// future caller could reach the router directly. A missing or empty
|
|
270
|
+
// `runProjectId` blocks the accept with the same shape the page-side
|
|
271
|
+
// guard uses.
|
|
272
|
+
const writeGuardBlock = checkWritebackRunProjectId(params);
|
|
273
|
+
if (writeGuardBlock) return writeGuardBlock;
|
|
274
|
+
const expectedFiles = Array.isArray(params.expectedFiles) ? params.expectedFiles : [];
|
|
275
|
+
const postFiles = Array.isArray(params.postFiles) ? params.postFiles : [];
|
|
276
|
+
const applied = [];
|
|
277
|
+
const skipped = [];
|
|
278
|
+
// diagnostics: a per-step trace returned to the caller so the run card can
|
|
279
|
+
// surface exactly what happened on each Accept All step. Each entry is
|
|
280
|
+
// `{ step, info }`; the content runtime translates `step` via i18n.
|
|
281
|
+
const diagnostics = [];
|
|
282
|
+
const pushDiagnostic = (step, info) => {
|
|
283
|
+
diagnostics.push({ step, info: info || {} });
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const expectedByPath = new Map(expectedFiles
|
|
287
|
+
.filter(file => file?.path && typeof file.content === 'string')
|
|
288
|
+
.map(file => [normalizeSafeProjectPath(file.path), file.content])
|
|
289
|
+
.filter(([path]) => path));
|
|
290
|
+
const postByPath = new Map(postFiles
|
|
291
|
+
.filter(file => file?.path && typeof file.content === 'string')
|
|
292
|
+
.map(file => [normalizeSafeProjectPath(file.path), file.content])
|
|
293
|
+
.filter(([path]) => path));
|
|
294
|
+
const paths = Array.from(expectedByPath.keys()).filter(path => postByPath.has(path));
|
|
295
|
+
if (!paths.length) {
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
applied,
|
|
299
|
+
skipped: [{
|
|
300
|
+
trackedChange: null,
|
|
301
|
+
result: {
|
|
302
|
+
ok: false,
|
|
303
|
+
code: 'accept_missing_run_content',
|
|
304
|
+
reason: '这轮写入没有可识别的写入前/写入后内容;Codex 没有把留痕改动接受为永久文本。'
|
|
305
|
+
}
|
|
306
|
+
}],
|
|
307
|
+
diagnostics
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Step 1: editor-undo the run's tracked writeback back to its pre-write
|
|
312
|
+
// content, removing every tracked change. This reuses the exact mechanism
|
|
313
|
+
// the reject path relies on (its primary path).
|
|
314
|
+
const appliedBeforeUndo = applied.length;
|
|
315
|
+
const editorUndo = await rejectTrackedChangesViaEditorUndo(expectedFiles, postFiles, applied);
|
|
316
|
+
pushDiagnostic('editorUndo', {
|
|
317
|
+
ok: editorUndo.ok === true,
|
|
318
|
+
attempted: editorUndo.attempted === true,
|
|
319
|
+
code: editorUndo.code || '',
|
|
320
|
+
reason: editorUndo.reason || '',
|
|
321
|
+
pathsProcessed: applied.length - appliedBeforeUndo
|
|
322
|
+
});
|
|
323
|
+
if (!editorUndo.ok) {
|
|
324
|
+
// Drift bail: the editor-undo could not reach the pre-write state. Do NOT
|
|
325
|
+
// re-write — that would only make the document worse. Return not-ok so the
|
|
326
|
+
// run stays pending and the user can retry, same as Undo.
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
applied: [],
|
|
330
|
+
skipped: [{
|
|
331
|
+
trackedChange: null,
|
|
332
|
+
result: editorUndo.attempted || editorUndo.code
|
|
333
|
+
? editorUndo
|
|
334
|
+
: {
|
|
335
|
+
ok: false,
|
|
336
|
+
code: 'accept_editor_undo_unavailable',
|
|
337
|
+
reason: '没有可执行的 Overleaf 原生撤销来清掉本轮留痕;Codex 没有接受这轮改动。'
|
|
338
|
+
}
|
|
339
|
+
}],
|
|
340
|
+
diagnostics
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Step 2: switch to Editing mode (Track Changes OFF) so the replay lands as
|
|
345
|
+
// plain, untracked text.
|
|
346
|
+
//
|
|
347
|
+
// ensureEditing() short-circuits on isEditingConfirmedForNoTraceUndo, whose
|
|
348
|
+
// "already Editing" detection is negation-based and false-positives while
|
|
349
|
+
// Track Changes is actually ON (e.g. the Reviewing mode shows only as a
|
|
350
|
+
// dropdown-trigger label with no active aria attribute, so reviewing is not
|
|
351
|
+
// positively confirmed and a stray "Editing" menu option is read as the
|
|
352
|
+
// current mode). Trusting that short-circuit replays the run while tracked.
|
|
353
|
+
//
|
|
354
|
+
// So do not trust the short-circuit here: read the reviewing state
|
|
355
|
+
// explicitly, and if Reviewing/Track Changes is on — or Editing is not
|
|
356
|
+
// positively confirmed OFF — force the toggle via setReviewingEnabled(false)
|
|
357
|
+
// rather than ensureEditing's lenient path.
|
|
358
|
+
const modeBefore = getReviewingState({});
|
|
359
|
+
pushDiagnostic('modeBefore', summarizeReviewingStateForDiagnostics(modeBefore));
|
|
360
|
+
const editingSwitch = await forceEditingForAcceptReplay();
|
|
361
|
+
pushDiagnostic('forceEditing', {
|
|
362
|
+
ok: editingSwitch.ok === true,
|
|
363
|
+
activated: editingSwitch.activated === true,
|
|
364
|
+
code: editingSwitch.code || '',
|
|
365
|
+
reason: editingSwitch.reason || ''
|
|
366
|
+
});
|
|
367
|
+
if (!editingSwitch.ok) {
|
|
368
|
+
// The undo already reverted the writeback; without a *confirmed* Editing
|
|
369
|
+
// mode the replay would itself be tracked. Bail rather than re-introduce
|
|
370
|
+
// tracked changes. The structured failure from forceEditingForAcceptReplay
|
|
371
|
+
// (editing_not_confirmed, changedDocument:true) is preserved verbatim.
|
|
372
|
+
return {
|
|
373
|
+
ok: false,
|
|
374
|
+
applied: [],
|
|
375
|
+
skipped: [{
|
|
376
|
+
trackedChange: null,
|
|
377
|
+
result: {
|
|
378
|
+
ok: false,
|
|
379
|
+
code: editingSwitch.code || 'accept_editing_not_confirmed',
|
|
380
|
+
reason: editingSwitch.reason || '无法确认 Overleaf 已切换到 Editing 模式;Codex 没有把本轮改动重写为永久文本。',
|
|
381
|
+
failure: editingSwitch.failure || buildPageFailure('editing_not_confirmed', {
|
|
382
|
+
changedDocument: true,
|
|
383
|
+
userMessage: 'Codex could not confirm Editing mode for the untracked Accept replay, so the run was not finalized.',
|
|
384
|
+
evidence: {
|
|
385
|
+
originalCode: editingSwitch.code || 'accept_editing_not_confirmed',
|
|
386
|
+
writeStarted: false
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
}],
|
|
391
|
+
diagnostics
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
// Tracks whether THIS flow toggled Reviewing off (either initially via
|
|
395
|
+
// forceEditingForAcceptReplay, or later inside the per-op re-confirm loop).
|
|
396
|
+
// The final restore only fires if this flow owned the toggle.
|
|
397
|
+
let weToggledOff = editingSwitch.activated === true;
|
|
398
|
+
const stableAfterSwitch = await waitForStableEditingForAcceptReplay({
|
|
399
|
+
waitMs: 2400,
|
|
400
|
+
intervalMs: 160
|
|
401
|
+
});
|
|
402
|
+
if (!stableAfterSwitch.ok) {
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
applied: [],
|
|
406
|
+
skipped: [{
|
|
407
|
+
trackedChange: null,
|
|
408
|
+
result: stableAfterSwitch
|
|
409
|
+
}],
|
|
410
|
+
diagnostics
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Step 3: re-apply each file's changed fragments as a plain edit. With
|
|
415
|
+
// tracking off these land as permanent, untracked text. The replay must
|
|
416
|
+
// write only the minimal changed fragments via the `patches` path of
|
|
417
|
+
// applyOperationsCore — never a whole-file replaceAll, which would clobber
|
|
418
|
+
// any unrelated content and produce one giant tracked change if anything
|
|
419
|
+
// about the mode switch were imperfect.
|
|
420
|
+
const operationsResult = buildAcceptReplayOperations(paths, expectedByPath, postByPath, params.appliedOperations);
|
|
421
|
+
if (!operationsResult.ok) {
|
|
422
|
+
if (weToggledOff) {
|
|
423
|
+
await setReviewingEnabled(true, { waitMs: 1800 });
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
applied: [],
|
|
428
|
+
skipped: [{
|
|
429
|
+
trackedChange: null,
|
|
430
|
+
result: operationsResult
|
|
431
|
+
}],
|
|
432
|
+
diagnostics
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
const operations = operationsResult.operations;
|
|
436
|
+
// Per-op replay loop with sticky-Editing re-confirm and a short stable
|
|
437
|
+
// window before every actual CodeMirror write.
|
|
438
|
+
//
|
|
439
|
+
// Bug C: even after forceEditingForAcceptReplay confirmed Editing once,
|
|
440
|
+
// Overleaf has been observed flipping back to Reviewing between the
|
|
441
|
+
// confirm and the next CodeMirror write (Overleaf-side override, per-user
|
|
442
|
+
// "Track Changes for me" setting, or a positive-confirm false-positive
|
|
443
|
+
// window). The fix: re-verify Editing immediately BEFORE every single
|
|
444
|
+
// operation, and if Reviewing has slipped back on, force the toggle off
|
|
445
|
+
// again and positively re-confirm before writing. If Editing still cannot
|
|
446
|
+
// be positively confirmed after the re-toggle, bail the rest of the loop
|
|
447
|
+
// so we never land a tracked write.
|
|
448
|
+
let bailedReason = null;
|
|
449
|
+
for (const operation of operations) {
|
|
450
|
+
const opPath = operation?.path || '';
|
|
451
|
+
const preState = getReviewingState({});
|
|
452
|
+
const reviewingOnBefore = isReviewingConfirmedForWrite(preState);
|
|
453
|
+
const editingConfirmedBefore = isEditingPositivelyConfirmed(preState);
|
|
454
|
+
let reToggled = false;
|
|
455
|
+
let reToggleResult = null;
|
|
456
|
+
|
|
457
|
+
if (reviewingOnBefore || !editingConfirmedBefore) {
|
|
458
|
+
reToggled = true;
|
|
459
|
+
reToggleResult = await setReviewingEnabled(false, { waitMs: 1800 });
|
|
460
|
+
if (reToggleResult.ok) {
|
|
461
|
+
weToggledOff = true;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const postToggleState = reToggled ? getReviewingState({}) : preState;
|
|
466
|
+
const editingConfirmedAfter = reToggled
|
|
467
|
+
? isEditingPositivelyConfirmed(postToggleState)
|
|
468
|
+
: editingConfirmedBefore;
|
|
469
|
+
|
|
470
|
+
pushDiagnostic('replayStart', {
|
|
471
|
+
path: opPath,
|
|
472
|
+
isReviewingPositivelyOn: reviewingOnBefore,
|
|
473
|
+
isEditingPositivelyConfirmed: editingConfirmedBefore,
|
|
474
|
+
reToggled,
|
|
475
|
+
reToggleOk: reToggleResult ? reToggleResult.ok === true : null,
|
|
476
|
+
reToggleCode: reToggleResult?.code || '',
|
|
477
|
+
editingConfirmedAfterReToggle: editingConfirmedAfter
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
if (!editingConfirmedAfter) {
|
|
481
|
+
// Bail the rest of the loop with the existing bail semantics — never
|
|
482
|
+
// land a tracked write because Editing slipped.
|
|
483
|
+
const bailResult = {
|
|
484
|
+
ok: false,
|
|
485
|
+
code: reToggleResult?.code || 'accept_editing_not_confirmed',
|
|
486
|
+
reason: reToggleResult?.reason
|
|
487
|
+
|| '本次操作前未能确认 Overleaf 处于 Editing 模式;为避免重写又留下留痕,Codex 没有继续重放本轮剩余改动。',
|
|
488
|
+
failure: buildPageFailure('editing_not_confirmed', {
|
|
489
|
+
file: opPath || '',
|
|
490
|
+
operationType: 'accept-replay',
|
|
491
|
+
changedDocument: true,
|
|
492
|
+
userMessage: `Codex could not re-confirm Editing mode before replaying ${opPath || 'the next file'}; Codex stopped to avoid landing a tracked write.`,
|
|
493
|
+
evidence: {
|
|
494
|
+
originalCode: reToggleResult?.code || 'accept_editing_not_confirmed',
|
|
495
|
+
reToggled,
|
|
496
|
+
writeStarted: false
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
};
|
|
500
|
+
skipped.push({
|
|
501
|
+
trackedChange: {
|
|
502
|
+
key: `accept-replay:${opPath}`,
|
|
503
|
+
id: '',
|
|
504
|
+
path: opPath,
|
|
505
|
+
label: 'Accept All (untracked replay)'
|
|
506
|
+
},
|
|
507
|
+
result: bailResult
|
|
508
|
+
});
|
|
509
|
+
pushDiagnostic('replayDone', {
|
|
510
|
+
path: opPath,
|
|
511
|
+
ok: false,
|
|
512
|
+
bailed: true,
|
|
513
|
+
code: bailResult.code,
|
|
514
|
+
reason: bailResult.reason
|
|
515
|
+
});
|
|
516
|
+
bailedReason = bailResult;
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const stableBeforeReplay = await waitForStableEditingForAcceptReplay({
|
|
521
|
+
waitMs: reToggled ? 2400 : 1400,
|
|
522
|
+
intervalMs: 160
|
|
523
|
+
});
|
|
524
|
+
if (!stableBeforeReplay.ok) {
|
|
525
|
+
skipped.push({
|
|
526
|
+
trackedChange: {
|
|
527
|
+
key: `accept-replay:${opPath}`,
|
|
528
|
+
id: '',
|
|
529
|
+
path: opPath,
|
|
530
|
+
label: 'Accept All (untracked replay)'
|
|
531
|
+
},
|
|
532
|
+
result: stableBeforeReplay
|
|
533
|
+
});
|
|
534
|
+
pushDiagnostic('replayDone', {
|
|
535
|
+
path: opPath,
|
|
536
|
+
ok: false,
|
|
537
|
+
bailed: true,
|
|
538
|
+
code: stableBeforeReplay.code,
|
|
539
|
+
reason: stableBeforeReplay.reason
|
|
540
|
+
});
|
|
541
|
+
bailedReason = stableBeforeReplay;
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const opReplay = await applyOperationsCore([operation], {
|
|
546
|
+
baseFiles: [{
|
|
547
|
+
path: opPath,
|
|
548
|
+
content: expectedByPath.get(normalizeSafeProjectPath(opPath))
|
|
549
|
+
}],
|
|
550
|
+
forbidTrackedChanges: true
|
|
551
|
+
});
|
|
552
|
+
for (const item of opReplay.applied || []) {
|
|
553
|
+
applied.push({
|
|
554
|
+
trackedChange: {
|
|
555
|
+
key: `accept-replay:${item.operation?.path || ''}`,
|
|
556
|
+
id: '',
|
|
557
|
+
path: item.operation?.path || '',
|
|
558
|
+
label: 'Accept All (untracked replay)'
|
|
559
|
+
},
|
|
560
|
+
result: {
|
|
561
|
+
...item.result,
|
|
562
|
+
method: 'overleaf-accept-untracked-replay'
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
for (const item of opReplay.skipped || []) {
|
|
567
|
+
skipped.push({
|
|
568
|
+
trackedChange: {
|
|
569
|
+
key: `accept-replay:${item.operation?.path || ''}`,
|
|
570
|
+
id: '',
|
|
571
|
+
path: item.operation?.path || '',
|
|
572
|
+
label: 'Accept All (untracked replay)'
|
|
573
|
+
},
|
|
574
|
+
result: item.result
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
const opAppliedEntry = (opReplay.applied || [])[0];
|
|
578
|
+
const opSkippedEntry = (opReplay.skipped || [])[0];
|
|
579
|
+
const opResult = opAppliedEntry?.result || opSkippedEntry?.result || {};
|
|
580
|
+
if (opResult.code === 'accept_replay_created_tracked_changes') {
|
|
581
|
+
opResult.rollback = await rollbackAcceptReplayTrackedWrite(opPath, expectedByPath, postByPath);
|
|
582
|
+
}
|
|
583
|
+
pushDiagnostic('replayDone', {
|
|
584
|
+
path: opPath,
|
|
585
|
+
ok: opAppliedEntry ? true : false,
|
|
586
|
+
verified: opResult.verified === true,
|
|
587
|
+
verifiedContentLength: typeof opResult.verifiedContent === 'string'
|
|
588
|
+
? opResult.verifiedContent.length
|
|
589
|
+
: null,
|
|
590
|
+
trackedChangesDetected: Array.isArray(opResult.trackedChanges) ? opResult.trackedChanges.length : 0,
|
|
591
|
+
rollbackOk: opResult.rollback ? opResult.rollback.ok === true : null,
|
|
592
|
+
code: opResult.code || '',
|
|
593
|
+
reason: opResult.reason || ''
|
|
594
|
+
});
|
|
595
|
+
if (!opReplay.ok) {
|
|
596
|
+
bailedReason = opResult;
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Step 4 intentionally does NOT restore Reviewing. Accept replay is a
|
|
602
|
+
// no-trace write transaction: restoring Track Changes immediately after the
|
|
603
|
+
// CodeMirror dispatch can race with Overleaf's collaboration/reviewing
|
|
604
|
+
// settlement and cause the replay itself to land as fresh tracked changes.
|
|
605
|
+
// Leave the editor in Editing after a successful replay; the user can turn
|
|
606
|
+
// Reviewing back on manually after Overleaf has saved the accepted text.
|
|
607
|
+
if (weToggledOff) {
|
|
608
|
+
pushDiagnostic('restoreReviewing', {
|
|
609
|
+
ok: true,
|
|
610
|
+
skipped: true,
|
|
611
|
+
enabled: false,
|
|
612
|
+
reason: 'Accept All left Overleaf in Editing mode to avoid re-tracking the accepted replay.'
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (applied.length > 0) {
|
|
617
|
+
compileBridge.markSourceEdited();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
ok: skipped.length === 0 && !bailedReason,
|
|
622
|
+
applied,
|
|
623
|
+
skipped,
|
|
624
|
+
diagnostics
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Summarizes a reviewing state for the per-step diagnostics. Surfaces
|
|
629
|
+
// exactly the bits the user needs to diagnose a sticky-Editing slip: the
|
|
630
|
+
// reviewing detector's verdict (ok/status/source), a compact controls
|
|
631
|
+
// summary, and the two strict gates (isReviewingPositivelyOn /
|
|
632
|
+
// isEditingPositivelyConfirmed). Self-contained — the run card can render
|
|
633
|
+
// this verbatim without re-reading any page state.
|
|
634
|
+
function summarizeReviewingStateForDiagnostics(state = {}) {
|
|
635
|
+
const reviewing = state.reviewing || {};
|
|
636
|
+
const controls = (state.signals?.controls || []).slice(0, 6).map(control => {
|
|
637
|
+
const text = [control?.text, control?.innerText, control?.ariaLabel, control?.title]
|
|
638
|
+
.map(value => String(value || '').replace(/\s+/g, ' ').trim())
|
|
639
|
+
.filter(Boolean)
|
|
640
|
+
.join(' | ');
|
|
641
|
+
return {
|
|
642
|
+
text: text.length > 80 ? text.slice(0, 77) + '...' : text,
|
|
643
|
+
ariaPressed: control?.ariaPressed || '',
|
|
644
|
+
ariaSelected: control?.ariaSelected || '',
|
|
645
|
+
ariaCurrent: control?.ariaCurrent || ''
|
|
646
|
+
};
|
|
647
|
+
});
|
|
648
|
+
return {
|
|
649
|
+
reviewingOk: reviewing.ok === true,
|
|
650
|
+
reviewingStatus: reviewing.status || '',
|
|
651
|
+
reviewingSource: reviewing.source || '',
|
|
652
|
+
controlsCount: (state.signals?.controls || []).length,
|
|
653
|
+
controls,
|
|
654
|
+
isReviewingPositivelyOn: isReviewingConfirmedForWrite(state),
|
|
655
|
+
isEditingPositivelyConfirmed: isEditingPositivelyConfirmed(state)
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Track Changes is positively confirmed OFF only when Editing is positively
|
|
660
|
+
// detected AND Reviewing is NOT positively confirmed on. This is the strict
|
|
661
|
+
// gate the Accept replay needs: it must never replay while tracked, so
|
|
662
|
+
// anything short of a positive "Editing on / Reviewing off" is rejected.
|
|
663
|
+
function isEditingPositivelyConfirmed(state = {}) {
|
|
664
|
+
return isEditingConfirmedForNoTraceUndo(state) && !isReviewingConfirmedForWrite(state);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function waitForStableEditingForAcceptReplay(options = {}) {
|
|
668
|
+
const waitMs = Number.isFinite(Number(options.waitMs)) ? Number(options.waitMs) : 2000;
|
|
669
|
+
const intervalMs = Number.isFinite(Number(options.intervalMs)) ? Number(options.intervalMs) : 160;
|
|
670
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
671
|
+
let samples = 0;
|
|
672
|
+
let lastState = getReviewingState({});
|
|
673
|
+
while (Date.now() <= deadline) {
|
|
674
|
+
lastState = getReviewingState({});
|
|
675
|
+
samples += 1;
|
|
676
|
+
if (!isEditingPositivelyConfirmed(lastState)) {
|
|
677
|
+
return {
|
|
678
|
+
ok: false,
|
|
679
|
+
code: 'accept_editing_not_stable',
|
|
680
|
+
reason: 'Overleaf Editing mode did not remain stable long enough for an untracked Accept All replay; Codex did not replay this write.',
|
|
681
|
+
samples,
|
|
682
|
+
waitMs,
|
|
683
|
+
reviewing: summarizeReviewingStateForDiagnostics(lastState)
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
await delay(intervalMs);
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
ok: true,
|
|
690
|
+
samples,
|
|
691
|
+
waitMs
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function rollbackAcceptReplayTrackedWrite(path, expectedByPath, postByPath) {
|
|
696
|
+
const normalizedPath = normalizeSafeProjectPath(path);
|
|
697
|
+
const expectedContent = expectedByPath.get(normalizedPath);
|
|
698
|
+
const postContent = postByPath.get(normalizedPath);
|
|
699
|
+
if (!normalizedPath || typeof expectedContent !== 'string' || typeof postContent !== 'string') {
|
|
700
|
+
return {
|
|
701
|
+
ok: false,
|
|
702
|
+
code: 'accept_replay_rollback_missing_content',
|
|
703
|
+
reason: 'Accept All detected fresh tracked changes, but could not roll back because the pre/post content was unavailable.'
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
const rollbackApplied = [];
|
|
707
|
+
const rollback = await rejectTrackedChangesViaEditorUndo(
|
|
708
|
+
[{ path: normalizedPath, content: expectedContent }],
|
|
709
|
+
[{ path: normalizedPath, content: postContent }],
|
|
710
|
+
rollbackApplied
|
|
711
|
+
);
|
|
712
|
+
return {
|
|
713
|
+
ok: rollback.ok === true,
|
|
714
|
+
attempted: rollback.attempted === true,
|
|
715
|
+
code: rollback.code || '',
|
|
716
|
+
reason: rollback.reason || '',
|
|
717
|
+
applied: rollbackApplied.length
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Robustly switch Overleaf to Editing (Track Changes OFF) for the Accept
|
|
722
|
+
// replay.
|
|
723
|
+
//
|
|
724
|
+
// Bug B root cause: the accept flow used ensureEditing, which short-circuits
|
|
725
|
+
// on isEditingConfirmedForNoTraceUndo. That detector is negation-based — it
|
|
726
|
+
// returns true whenever Reviewing is not *positively* confirmed-for-write
|
|
727
|
+
// (which requires an active aria attribute the real Overleaf reviewing
|
|
728
|
+
// control does not always carry) AND a control loosely matches an Editing
|
|
729
|
+
// pattern. So while Track Changes was actually ON, it false-positived
|
|
730
|
+
// "already Editing", ensureEditing returned activated:false without toggling,
|
|
731
|
+
// and the replay landed as tracked changes.
|
|
732
|
+
//
|
|
733
|
+
// The fix here does NOT trust that lenient short-circuit: it reads the
|
|
734
|
+
// reviewing state explicitly and treats Editing as confirmed only when it is
|
|
735
|
+
// *positively* confirmed (Editing detected AND Reviewing not confirmed on).
|
|
736
|
+
// Whenever that strict gate is not met it forces setReviewingEnabled(false),
|
|
737
|
+
// then positively re-confirms Editing before returning ok; if it still cannot
|
|
738
|
+
// confirm Editing it returns not-ok so the caller bails instead of replaying
|
|
739
|
+
// while tracked.
|
|
740
|
+
async function forceEditingForAcceptReplay() {
|
|
741
|
+
const initial = getReviewingState({});
|
|
742
|
+
if (isEditingPositivelyConfirmed(initial)) {
|
|
743
|
+
// Positively confirmed already in Editing — no toggle needed.
|
|
744
|
+
return { ok: true, activated: false };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Reviewing is on, or Editing is not positively confirmed off. Force the
|
|
748
|
+
// toggle rather than trusting ensureEditing's lenient detection.
|
|
749
|
+
const switched = await setReviewingEnabled(false, { waitMs: 1800 });
|
|
750
|
+
if (!switched.ok) {
|
|
751
|
+
// §9.4 editing_not_confirmed: the document is at pre-write state after
|
|
752
|
+
// the editor-undo succeeded; the mode toggle failed. The Accept replay
|
|
753
|
+
// was rolled back so the user-visible doc state has moved from the
|
|
754
|
+
// post-write tracked state back to pre-write — set changedDocument:true
|
|
755
|
+
// per the design spec's "is there text the user might want to inspect"
|
|
756
|
+
// contract for accept-side failures after a rollback.
|
|
757
|
+
return {
|
|
758
|
+
ok: false,
|
|
759
|
+
code: switched.code || 'accept_editing_not_confirmed',
|
|
760
|
+
reason: switched.reason || '无法确认 Overleaf 已切换到 Editing 模式;Codex 没有把本轮改动重写为永久文本。',
|
|
761
|
+
failure: buildPageFailure('editing_not_confirmed', {
|
|
762
|
+
changedDocument: true,
|
|
763
|
+
userMessage: 'Codex could not switch Overleaf to Editing mode for the untracked Accept replay, so the run was not finalized.',
|
|
764
|
+
technicalMessage: switched.reason || '',
|
|
765
|
+
evidence: {
|
|
766
|
+
originalCode: switched.code || 'accept_editing_not_confirmed',
|
|
767
|
+
toggleAttempted: true,
|
|
768
|
+
writeStarted: false
|
|
769
|
+
}
|
|
770
|
+
})
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Positively confirm Track Changes is now off before replaying. If the
|
|
775
|
+
// post-toggle state does not positively confirm Editing, bail — do not
|
|
776
|
+
// replay while potentially still tracked.
|
|
777
|
+
const after = getReviewingState({});
|
|
778
|
+
if (!isEditingPositivelyConfirmed(after)) {
|
|
779
|
+
return {
|
|
780
|
+
ok: false,
|
|
781
|
+
code: 'accept_editing_not_confirmed',
|
|
782
|
+
reason: '切换后仍未能确认 Overleaf 处于 Editing 模式;为避免重写又留下留痕,Codex 没有重放本轮改动。',
|
|
783
|
+
failure: buildPageFailure('editing_not_confirmed', {
|
|
784
|
+
changedDocument: true,
|
|
785
|
+
userMessage: 'Codex toggled Overleaf to Editing, but the post-toggle state did not positively confirm Editing — Codex did not replay the run.',
|
|
786
|
+
evidence: {
|
|
787
|
+
originalCode: 'accept_editing_not_confirmed',
|
|
788
|
+
toggleAttempted: true,
|
|
789
|
+
writeStarted: false
|
|
790
|
+
}
|
|
791
|
+
})
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return { ok: true, activated: true };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Builds the minimal replay operations for Accept All. The replay must write
|
|
799
|
+
// only the changed fragments, never a whole-file replaceAll.
|
|
800
|
+
//
|
|
801
|
+
// Preferred source: the run's own original forward writeback operations
|
|
802
|
+
// (carrying their `patches`). After the editor-undo the document is back at
|
|
803
|
+
// the pre-write content, so those patches' `expected` slices match and
|
|
804
|
+
// re-apply cleanly.
|
|
805
|
+
//
|
|
806
|
+
// Fallback: when a path has no usable original patch operation, compute a
|
|
807
|
+
// minimal pre->post diff (trim the common prefix/suffix to a single targeted
|
|
808
|
+
// {from,to,insert} patch).
|
|
809
|
+
function buildAcceptReplayOperations(paths, expectedByPath, postByPath, appliedOperations) {
|
|
810
|
+
const patchesByPath = collectAppliedEditPatchesByPath(appliedOperations);
|
|
811
|
+
const operations = [];
|
|
812
|
+
for (const path of paths) {
|
|
813
|
+
const preContent = expectedByPath.get(path);
|
|
814
|
+
const postContent = postByPath.get(path);
|
|
815
|
+
if (typeof preContent !== 'string' || typeof postContent !== 'string') {
|
|
816
|
+
return {
|
|
817
|
+
ok: false,
|
|
818
|
+
code: 'accept_missing_run_content',
|
|
819
|
+
reason: `${path} 缺少写入前/写入后内容;Codex 没有重放本轮改动。`
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Preferred: re-apply the run's original forward patches verbatim.
|
|
824
|
+
const originalPatches = patchesByPath.get(path);
|
|
825
|
+
if (originalPatches && originalPatches.length && patchesApplyCleanly(preContent, originalPatches)) {
|
|
826
|
+
operations.push({
|
|
827
|
+
type: 'edit',
|
|
828
|
+
path,
|
|
829
|
+
patches: originalPatches,
|
|
830
|
+
reason: 'Accept tracked edit (replay untracked)'
|
|
831
|
+
});
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Fallback: minimal pre->post diff trimmed to a single targeted patch.
|
|
836
|
+
const diffPatch = buildMinimalDiffPatch(preContent, postContent);
|
|
837
|
+
if (!diffPatch) {
|
|
838
|
+
// pre === post: nothing to replay for this file.
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
operations.push({
|
|
842
|
+
type: 'edit',
|
|
843
|
+
path,
|
|
844
|
+
patches: [diffPatch],
|
|
845
|
+
reason: 'Accept tracked edit (replay untracked)'
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
return { ok: true, operations };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Collects the per-path `patches` arrays from the run's applied edit
|
|
852
|
+
// operations. Only edit operations that actually carried `patches` are
|
|
853
|
+
// usable; whole-file replaceAll / verifiedContent operations are skipped so
|
|
854
|
+
// the replay never falls back to a whole-file write.
|
|
855
|
+
function collectAppliedEditPatchesByPath(appliedOperations) {
|
|
856
|
+
const patchesByPath = new Map();
|
|
857
|
+
for (const rawOperation of Array.isArray(appliedOperations) ? appliedOperations : []) {
|
|
858
|
+
if (!rawOperation || rawOperation.type !== 'edit') {
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
const path = typeof rawOperation.path === 'string'
|
|
862
|
+
? normalizeSafeProjectPath(rawOperation.path)
|
|
863
|
+
: '';
|
|
864
|
+
if (!path || !Array.isArray(rawOperation.patches) || !rawOperation.patches.length) {
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
const normalized = rawOperation.patches.map(patch => ({
|
|
868
|
+
from: Number(patch?.from),
|
|
869
|
+
to: Number(patch?.to),
|
|
870
|
+
expected: String(patch?.expected ?? ''),
|
|
871
|
+
insert: String(patch?.insert ?? '')
|
|
872
|
+
}));
|
|
873
|
+
const existing = patchesByPath.get(path) || [];
|
|
874
|
+
patchesByPath.set(path, existing.concat(normalized));
|
|
875
|
+
}
|
|
876
|
+
return patchesByPath;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Confirms a patch set re-applies cleanly against the given (pre-write) text:
|
|
880
|
+
// every patch range is valid and its `expected` slice matches.
|
|
881
|
+
function patchesApplyCleanly(text, patches) {
|
|
882
|
+
const applied = applyTextPatches(text, patches);
|
|
883
|
+
return applied.ok === true;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Computes a single targeted {from,to,insert} patch describing the pre->post
|
|
887
|
+
// change by trimming the common prefix and suffix. Returns null when the two
|
|
888
|
+
// strings are identical.
|
|
889
|
+
function buildMinimalDiffPatch(preContent, postContent) {
|
|
890
|
+
if (preContent === postContent) {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
const preLen = preContent.length;
|
|
894
|
+
const postLen = postContent.length;
|
|
895
|
+
let prefix = 0;
|
|
896
|
+
const maxPrefix = Math.min(preLen, postLen);
|
|
897
|
+
while (prefix < maxPrefix && preContent[prefix] === postContent[prefix]) {
|
|
898
|
+
prefix += 1;
|
|
899
|
+
}
|
|
900
|
+
let suffix = 0;
|
|
901
|
+
const maxSuffix = Math.min(preLen, postLen) - prefix;
|
|
902
|
+
while (
|
|
903
|
+
suffix < maxSuffix
|
|
904
|
+
&& preContent[preLen - 1 - suffix] === postContent[postLen - 1 - suffix]
|
|
905
|
+
) {
|
|
906
|
+
suffix += 1;
|
|
907
|
+
}
|
|
908
|
+
const from = prefix;
|
|
909
|
+
const to = preLen - suffix;
|
|
910
|
+
return {
|
|
911
|
+
from,
|
|
912
|
+
to,
|
|
913
|
+
expected: preContent.slice(from, to),
|
|
914
|
+
insert: postContent.slice(from, postLen - suffix)
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async function rejectTrackedChangesViaEditorUndo(expectedFiles, postFiles, applied) {
|
|
919
|
+
const expectedByPath = new Map((expectedFiles || [])
|
|
920
|
+
.filter(file => file?.path && typeof file.content === 'string')
|
|
921
|
+
.map(file => [normalizeSafeProjectPath(file.path), file.content])
|
|
922
|
+
.filter(([path]) => path));
|
|
923
|
+
const postByPath = new Map((postFiles || [])
|
|
924
|
+
.filter(file => file?.path && typeof file.content === 'string')
|
|
925
|
+
.map(file => [normalizeSafeProjectPath(file.path), file.content])
|
|
926
|
+
.filter(([path]) => path));
|
|
927
|
+
const paths = Array.from(expectedByPath.keys()).filter(path => postByPath.has(path));
|
|
928
|
+
if (!paths.length) {
|
|
929
|
+
return { ok: false, attempted: false };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
for (const path of paths) {
|
|
933
|
+
if (path && getActiveFilePath() !== path) {
|
|
934
|
+
const opened = await openFileByPath(path);
|
|
935
|
+
if (!opened.ok) {
|
|
936
|
+
return {
|
|
937
|
+
ok: false,
|
|
938
|
+
attempted: false,
|
|
939
|
+
code: 'tracked_change_editor_undo_open_failed',
|
|
940
|
+
reason: `无法打开 ${path} 来执行 Overleaf 原生撤销。`
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const postContent = postByPath.get(path);
|
|
946
|
+
const postReady = await waitForActiveEditorExpectedText(path, postContent, 2500);
|
|
947
|
+
if (!postReady.ok) {
|
|
948
|
+
return {
|
|
949
|
+
ok: false,
|
|
950
|
+
attempted: false,
|
|
951
|
+
code: 'tracked_change_editor_undo_current_mismatch',
|
|
952
|
+
reason: `${path} 当前内容已经不是本轮写入后的内容;为避免撤掉你的后续修改,Codex 不使用 Overleaf 原生撤销。`
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const result = await undoEditorHistoryUntilContent(expectedByPath.get(path), path);
|
|
957
|
+
if (!result.ok) {
|
|
958
|
+
return result;
|
|
959
|
+
}
|
|
960
|
+
applied.push({
|
|
961
|
+
trackedChange: {
|
|
962
|
+
path,
|
|
963
|
+
key: `editor-undo:${path}`,
|
|
964
|
+
id: '',
|
|
965
|
+
label: `Overleaf editor undo (${result.clicks} step${result.clicks === 1 ? '' : 's'})`
|
|
966
|
+
},
|
|
967
|
+
result: {
|
|
968
|
+
ok: true,
|
|
969
|
+
method: 'overleaf-editor-undo',
|
|
970
|
+
undoClicks: result.clicks
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return { ok: true, attempted: true };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async function restoreExpectedFilesWithNoTraceUndo(expectedFiles, postFiles) {
|
|
979
|
+
const expectedByPath = new Map((expectedFiles || [])
|
|
980
|
+
.filter(file => file?.path && typeof file.content === 'string')
|
|
981
|
+
.map(file => [normalizeSafeProjectPath(file.path), file.content])
|
|
982
|
+
.filter(([path]) => path));
|
|
983
|
+
const postByPath = new Map((postFiles || [])
|
|
984
|
+
.filter(file => file?.path && typeof file.content === 'string')
|
|
985
|
+
.map(file => [normalizeSafeProjectPath(file.path), file.content])
|
|
986
|
+
.filter(([path]) => path));
|
|
987
|
+
const paths = Array.from(expectedByPath.keys()).filter(path => postByPath.has(path));
|
|
988
|
+
if (!paths.length) {
|
|
989
|
+
return {
|
|
990
|
+
ok: false,
|
|
991
|
+
attempted: false,
|
|
992
|
+
applied: [],
|
|
993
|
+
skipped: []
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
for (const path of paths) {
|
|
998
|
+
if (path && getActiveFilePath() !== path) {
|
|
999
|
+
const opened = await openFileByPath(path);
|
|
1000
|
+
if (!opened.ok) {
|
|
1001
|
+
return {
|
|
1002
|
+
ok: false,
|
|
1003
|
+
attempted: false,
|
|
1004
|
+
applied: [],
|
|
1005
|
+
skipped: [],
|
|
1006
|
+
code: 'snapshot_undo_open_failed',
|
|
1007
|
+
reason: `无法打开 ${path} 来执行快照撤销。`
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
const ready = await waitForActiveEditorExpectedText(path, postByPath.get(path), 1500);
|
|
1012
|
+
if (!ready.ok) {
|
|
1013
|
+
return {
|
|
1014
|
+
ok: false,
|
|
1015
|
+
attempted: false,
|
|
1016
|
+
applied: [],
|
|
1017
|
+
skipped: [],
|
|
1018
|
+
code: 'snapshot_undo_current_mismatch',
|
|
1019
|
+
reason: `${path} 当前内容已经不是本轮写入后的内容;为避免覆盖你的后续修改,Codex 不执行快照撤销。`
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const operations = paths.map(path => ({
|
|
1025
|
+
type: 'edit',
|
|
1026
|
+
path,
|
|
1027
|
+
replaceAll: expectedByPath.get(path),
|
|
1028
|
+
reason: 'Undo tracked edit'
|
|
1029
|
+
}));
|
|
1030
|
+
const result = await applyOperationsWithNoTraceUndo(operations, {
|
|
1031
|
+
baseFiles: paths.map(path => ({
|
|
1032
|
+
path,
|
|
1033
|
+
content: postByPath.get(path)
|
|
1034
|
+
}))
|
|
1035
|
+
});
|
|
1036
|
+
const toTrackedResult = item => ({
|
|
1037
|
+
trackedChange: {
|
|
1038
|
+
key: `snapshot-undo:${item.operation?.path || ''}`,
|
|
1039
|
+
id: '',
|
|
1040
|
+
path: item.operation?.path || '',
|
|
1041
|
+
label: 'No-trace snapshot undo'
|
|
1042
|
+
},
|
|
1043
|
+
result: item.result
|
|
1044
|
+
});
|
|
1045
|
+
return {
|
|
1046
|
+
ok: !(result.skipped || []).length,
|
|
1047
|
+
attempted: true,
|
|
1048
|
+
applied: (result.applied || []).map(toTrackedResult),
|
|
1049
|
+
skipped: (result.skipped || []).map(toTrackedResult)
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function waitForActiveEditorExpectedText(filePath, expectedContent, timeoutMs) {
|
|
1054
|
+
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
|
|
1055
|
+
let actual = readActiveEditorText();
|
|
1056
|
+
while (Date.now() < deadline) {
|
|
1057
|
+
const activeFileMatches = !filePath || getActiveFilePath() === filePath;
|
|
1058
|
+
actual = readActiveEditorText();
|
|
1059
|
+
if (activeFileMatches && actual === expectedContent) {
|
|
1060
|
+
return {
|
|
1061
|
+
ok: true,
|
|
1062
|
+
text: actual
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
await delay(60);
|
|
1066
|
+
}
|
|
1067
|
+
actual = readActiveEditorText();
|
|
1068
|
+
return {
|
|
1069
|
+
ok: false,
|
|
1070
|
+
text: actual,
|
|
1071
|
+
activePath: getActiveFilePath()
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function undoEditorHistoryUntilContent(expectedContent, path) {
|
|
1076
|
+
const maxClicks = 200;
|
|
1077
|
+
for (let clicks = 0; clicks <= maxClicks; clicks += 1) {
|
|
1078
|
+
if (readActiveEditorText() === expectedContent) {
|
|
1079
|
+
return {
|
|
1080
|
+
ok: true,
|
|
1081
|
+
attempted: clicks > 0,
|
|
1082
|
+
clicks
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
if (clicks === maxClicks) {
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const undoControl = findEditorUndoControl();
|
|
1090
|
+
if (!undoControl) {
|
|
1091
|
+
return {
|
|
1092
|
+
ok: false,
|
|
1093
|
+
attempted: clicks > 0,
|
|
1094
|
+
code: 'editor_undo_control_not_found',
|
|
1095
|
+
reason: `没有找到 Overleaf 编辑器自己的 Undo/撤销按钮,无法一次性撤销 ${path} 的本轮留痕。`
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const beforeText = readActiveEditorText();
|
|
1100
|
+
clickNode(undoControl);
|
|
1101
|
+
await waitForEditorTextProgress(beforeText, expectedContent, 1000);
|
|
1102
|
+
if (readActiveEditorText() === beforeText) {
|
|
1103
|
+
return {
|
|
1104
|
+
ok: false,
|
|
1105
|
+
attempted: clicks > 0,
|
|
1106
|
+
code: 'editor_undo_no_progress',
|
|
1107
|
+
reason: `Codex 点击了 Overleaf Undo/撤销,但 ${path} 内容没有变化。`
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
return {
|
|
1113
|
+
ok: false,
|
|
1114
|
+
attempted: true,
|
|
1115
|
+
code: 'editor_undo_max_iterations',
|
|
1116
|
+
reason: `${path} 经过多次 Overleaf 原生撤销后仍未回到本轮写入前内容,已停止以避免撤销其它修改。`
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
async function waitForEditorTextProgress(beforeText, expectedContent, timeoutMs) {
|
|
1121
|
+
const startedAt = Date.now();
|
|
1122
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
1123
|
+
const current = readActiveEditorText();
|
|
1124
|
+
if (current !== beforeText || current === expectedContent) {
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
await delay(60);
|
|
1128
|
+
}
|
|
1129
|
+
return false;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
async function rejectRemainingTrackedChangesForExpectedFiles(expectedFiles, applied) {
|
|
1133
|
+
for (const file of expectedFiles || []) {
|
|
1134
|
+
if (!file?.path || typeof file.content !== 'string') {
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
const result = await rejectRemainingTrackedChangesForExpectedFile(file, applied);
|
|
1138
|
+
if (!result.ok) {
|
|
1139
|
+
return result;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
return { ok: true };
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
async function rejectRemainingTrackedChangesForTrackedPaths(trackedChanges, applied) {
|
|
1146
|
+
const paths = Array.from(new Set((trackedChanges || [])
|
|
1147
|
+
.map(change => normalizeSafeProjectPath(change?.path || ''))
|
|
1148
|
+
.filter(Boolean)));
|
|
1149
|
+
if (!paths.length) {
|
|
1150
|
+
const activePath = normalizeSafeProjectPath(getActiveFilePath());
|
|
1151
|
+
if (!activePath || !applied.length) {
|
|
1152
|
+
return { ok: true };
|
|
1153
|
+
}
|
|
1154
|
+
paths.push(activePath);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
for (const path of paths) {
|
|
1158
|
+
const result = await rejectRemainingTrackedChangesForPath(path, applied);
|
|
1159
|
+
if (!result.ok) {
|
|
1160
|
+
return result;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return { ok: true };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
async function rejectRemainingTrackedChangesForPath(path, applied) {
|
|
1167
|
+
if (path && getActiveFilePath() !== path) {
|
|
1168
|
+
const opened = await openFileByPath(path);
|
|
1169
|
+
if (!opened.ok) {
|
|
1170
|
+
return {
|
|
1171
|
+
ok: false,
|
|
1172
|
+
code: 'tracked_change_file_open_failed',
|
|
1173
|
+
reason: `无法打开 ${path} 来继续拒绝这轮留痕记录;请在 Overleaf 审阅面板手动处理。`
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const appliedCountBefore = applied.length;
|
|
1179
|
+
const maxAttempts = 200;
|
|
1180
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
1181
|
+
const node = findLastTrackedChangeNodeForPath(path);
|
|
1182
|
+
if (!node) {
|
|
1183
|
+
if (applied.length > appliedCountBefore) {
|
|
1184
|
+
return { ok: true };
|
|
1185
|
+
}
|
|
1186
|
+
return {
|
|
1187
|
+
ok: false,
|
|
1188
|
+
code: 'tracked_change_not_found',
|
|
1189
|
+
reason: '没有在 Overleaf 页面里找到这轮写入对应的留痕记录;Codex 没有用文本补丁伪撤销。'
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const rejectControl = findRejectControlForTrackedChangeNode(node);
|
|
1194
|
+
if (!rejectControl) {
|
|
1195
|
+
return {
|
|
1196
|
+
ok: false,
|
|
1197
|
+
code: 'tracked_change_reject_control_not_found',
|
|
1198
|
+
reason: `还有 ${path || '当前文件'} 的留痕记录未处理,但没有找到对应的 Reject/拒绝按钮;请在 Overleaf 审阅面板手动拒绝。`
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const trackedChange = trackedChangeRefFromNode(node, path);
|
|
1203
|
+
const beforeText = readActiveEditorText();
|
|
1204
|
+
clickNode(rejectControl);
|
|
1205
|
+
await waitForTrackedChangeRejectProgress(trackedChange, path, beforeText, 1200);
|
|
1206
|
+
applied.push({
|
|
1207
|
+
trackedChange,
|
|
1208
|
+
result: {
|
|
1209
|
+
ok: true,
|
|
1210
|
+
method: 'overleaf-review-reject-sweep'
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
return {
|
|
1216
|
+
ok: false,
|
|
1217
|
+
code: 'tracked_change_undo_max_iterations',
|
|
1218
|
+
reason: `${path || '当前文件'} 仍有未完成的留痕记录;Codex 已停止以避免误拒绝其它改动。`,
|
|
1219
|
+
failure: buildPageFailure('tracked_changes_remain', {
|
|
1220
|
+
file: path || '',
|
|
1221
|
+
operationType: 'reject',
|
|
1222
|
+
changedDocument: true,
|
|
1223
|
+
terminalState: 'needs_review',
|
|
1224
|
+
userMessage: `${path || 'The target file'} still has tracked changes after the reject sweep, so Codex stopped to avoid mis-rejecting other edits.`,
|
|
1225
|
+
evidence: {
|
|
1226
|
+
originalCode: 'tracked_change_undo_max_iterations',
|
|
1227
|
+
maxAttemptsReached: true,
|
|
1228
|
+
writeStarted: true
|
|
1229
|
+
}
|
|
1230
|
+
})
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
async function rejectRemainingTrackedChangesForExpectedFile(file, applied) {
|
|
1235
|
+
const opened = await openFileByPath(file.path);
|
|
1236
|
+
if (!opened.ok) {
|
|
1237
|
+
return {
|
|
1238
|
+
ok: false,
|
|
1239
|
+
code: 'tracked_change_undo_verify_open_failed',
|
|
1240
|
+
reason: `撤销后无法打开 ${file.path} 验证内容;请刷新 Overleaf 后检查。`
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const appliedCountBefore = applied.length;
|
|
1245
|
+
const maxAttempts = 200;
|
|
1246
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
1247
|
+
const node = findLastTrackedChangeNodeForPath(file.path);
|
|
1248
|
+
if (!node) {
|
|
1249
|
+
const verified = await verifyActiveEditorText(file.content, file.path, 800);
|
|
1250
|
+
if (verified.ok) {
|
|
1251
|
+
return { ok: true };
|
|
1252
|
+
}
|
|
1253
|
+
const rejectedAnyForFile = applied.length > appliedCountBefore;
|
|
1254
|
+
return {
|
|
1255
|
+
...verified,
|
|
1256
|
+
code: rejectedAnyForFile ? 'tracked_change_undo_verify_failed' : 'tracked_change_not_found',
|
|
1257
|
+
reason: rejectedAnyForFile
|
|
1258
|
+
? `${file.path} 拒绝留痕后内容没有回到写入前状态;请在 Overleaf 审阅面板检查这轮修改。`
|
|
1259
|
+
: '没有在 Overleaf 页面里找到这轮写入对应的留痕记录;Codex 没有用文本补丁伪撤销。'
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const rejectControl = findRejectControlForTrackedChangeNode(node);
|
|
1264
|
+
if (!rejectControl) {
|
|
1265
|
+
return {
|
|
1266
|
+
ok: false,
|
|
1267
|
+
code: 'tracked_change_reject_control_not_found',
|
|
1268
|
+
reason: `还有 ${file.path} 的留痕记录未处理,但没有找到对应的 Reject/拒绝按钮;请在 Overleaf 审阅面板手动拒绝。`
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const trackedChange = trackedChangeRefFromNode(node, file.path);
|
|
1273
|
+
const beforeText = readActiveEditorText();
|
|
1274
|
+
clickNode(rejectControl);
|
|
1275
|
+
await waitForTrackedChangeRejectProgress(trackedChange, file.path, beforeText, 1200);
|
|
1276
|
+
applied.push({
|
|
1277
|
+
trackedChange,
|
|
1278
|
+
result: {
|
|
1279
|
+
ok: true,
|
|
1280
|
+
method: 'overleaf-review-reject-sweep'
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
return {
|
|
1286
|
+
ok: false,
|
|
1287
|
+
code: 'tracked_change_undo_max_iterations',
|
|
1288
|
+
reason: `${file.path} 仍有未完成的留痕记录;Codex 已停止以避免误拒绝其它改动。`,
|
|
1289
|
+
failure: buildPageFailure('tracked_changes_remain', {
|
|
1290
|
+
file: file.path || '',
|
|
1291
|
+
operationType: 'reject',
|
|
1292
|
+
changedDocument: true,
|
|
1293
|
+
terminalState: 'needs_review',
|
|
1294
|
+
userMessage: `${file.path || 'The target file'} still has tracked changes after the per-expected-file reject sweep, so Codex stopped to avoid mis-rejecting other edits.`,
|
|
1295
|
+
evidence: {
|
|
1296
|
+
originalCode: 'tracked_change_undo_max_iterations',
|
|
1297
|
+
maxAttemptsReached: true,
|
|
1298
|
+
writeStarted: true
|
|
1299
|
+
}
|
|
1300
|
+
})
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
async function waitForTrackedChangeRejectProgress(trackedChange, path, beforeText, timeoutMs) {
|
|
1305
|
+
const startedAt = Date.now();
|
|
1306
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
1307
|
+
const currentText = readActiveEditorText();
|
|
1308
|
+
if (currentText !== beforeText) {
|
|
1309
|
+
return true;
|
|
1310
|
+
}
|
|
1311
|
+
if (trackedChange?.key && !findTrackedChangeNode({ ...trackedChange, path })) {
|
|
1312
|
+
return true;
|
|
1313
|
+
}
|
|
1314
|
+
await delay(80);
|
|
1315
|
+
}
|
|
1316
|
+
return false;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
async function waitForTrackedChangeDiff(trackedBefore, paths, options = {}) {
|
|
1323
|
+
const waitMs = Number.isFinite(Number(options.waitMs)) ? Number(options.waitMs) : 3000;
|
|
1324
|
+
const intervalMs = Number.isFinite(Number(options.intervalMs)) ? Number(options.intervalMs) : 180;
|
|
1325
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
1326
|
+
let latest = [];
|
|
1327
|
+
while (Date.now() <= deadline) {
|
|
1328
|
+
const trackedAfter = collectTrackedChangeRefsForPaths(paths);
|
|
1329
|
+
latest = diffTrackedChangeRefs(trackedBefore, trackedAfter);
|
|
1330
|
+
if (latest.length > 0) {
|
|
1331
|
+
return {
|
|
1332
|
+
ok: true,
|
|
1333
|
+
trackedChanges: latest,
|
|
1334
|
+
waitMs
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
await delay(intervalMs);
|
|
1338
|
+
}
|
|
1339
|
+
return {
|
|
1340
|
+
ok: true,
|
|
1341
|
+
trackedChanges: latest,
|
|
1342
|
+
waitMs
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function collectTrackedChangeRefsForPaths(paths = []) {
|
|
1347
|
+
const pathSet = new Set((paths || []).filter(Boolean));
|
|
1348
|
+
const activePath = getActiveFilePath();
|
|
1349
|
+
return collectTrackedChangeNodes()
|
|
1350
|
+
.map(node => trackedChangeRefFromNode(node, activePath))
|
|
1351
|
+
.filter(ref => ref.key)
|
|
1352
|
+
.filter(ref => !pathSet.size || !ref.path || pathSet.has(ref.path));
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function collectTrackedChangeNodes() {
|
|
1356
|
+
const selector = [
|
|
1357
|
+
'[data-change-id]',
|
|
1358
|
+
'[data-review-id]',
|
|
1359
|
+
'[data-track-change-id]',
|
|
1360
|
+
'[data-ol-change-id]',
|
|
1361
|
+
'[data-path][class*="change" i]',
|
|
1362
|
+
'[class*="track-change" i]',
|
|
1363
|
+
'[class*="review-change" i]',
|
|
1364
|
+
'[class*="suggest" i]',
|
|
1365
|
+
'[aria-label*="change" i]',
|
|
1366
|
+
'[title*="change" i]'
|
|
1367
|
+
].join(',');
|
|
1368
|
+
return uniqueNodes([
|
|
1369
|
+
...collectElements(selector, 1200),
|
|
1370
|
+
...collectElements('*', 3500).filter(isTrackedChangeNode)
|
|
1371
|
+
]).filter(isTrackedChangeNode);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function isTrackedChangeNode(node) {
|
|
1375
|
+
if (!node || isInsideCodexPanel(node)) {
|
|
1376
|
+
return false;
|
|
1377
|
+
}
|
|
1378
|
+
const signal = normalizeReviewingSignalText(readNodeSignalText(node));
|
|
1379
|
+
const tag = (node.tagName || '').toLowerCase();
|
|
1380
|
+
if (tag === 'button' && /\b(?:accept|reject|decline|discard|批准|接受|拒绝|丢弃)\b/i.test(signal)) {
|
|
1381
|
+
return false;
|
|
1382
|
+
}
|
|
1383
|
+
if (readTrackedChangeId(node)) {
|
|
1384
|
+
return true;
|
|
1385
|
+
}
|
|
1386
|
+
return /\b(?:tracked change|track change|review change|suggestion|insert(?:ion)?|delet(?:e|ion)|change)\b/i.test(signal)
|
|
1387
|
+
|| /留痕|建议|插入|删除|更改|修改/.test(signal);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function trackedChangeRefFromNode(node, fallbackPath = '') {
|
|
1391
|
+
const id = readTrackedChangeId(node);
|
|
1392
|
+
const key = id ? `id:${id}` : `sig:${compact(readNodeSignalText(node), 180)}`;
|
|
1393
|
+
const path = normalizeSafeProjectPath(
|
|
1394
|
+
node.getAttribute?.('data-path')
|
|
1395
|
+
|| node.getAttribute?.('data-file-path')
|
|
1396
|
+
|| node.getAttribute?.('data-doc-path')
|
|
1397
|
+
|| fallbackPath
|
|
1398
|
+
|| ''
|
|
1399
|
+
);
|
|
1400
|
+
return {
|
|
1401
|
+
key,
|
|
1402
|
+
id,
|
|
1403
|
+
path,
|
|
1404
|
+
label: compact(readNodeSignalText(node), 180)
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function readTrackedChangeId(node) {
|
|
1409
|
+
for (const attribute of [
|
|
1410
|
+
'data-change-id',
|
|
1411
|
+
'data-review-id',
|
|
1412
|
+
'data-track-change-id',
|
|
1413
|
+
'data-ol-change-id',
|
|
1414
|
+
'data-id',
|
|
1415
|
+
'id'
|
|
1416
|
+
]) {
|
|
1417
|
+
const value = node.getAttribute?.(attribute) || '';
|
|
1418
|
+
if (value && /\b(?:change|review|track|suggest)|\d|[a-f0-9-]{8,}/i.test(value)) {
|
|
1419
|
+
return String(value);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return '';
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function orderTrackedChangesForReviewAction(refs = []) {
|
|
1426
|
+
return (refs || []).slice().reverse();
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function findTrackedChangeNode(ref = {}) {
|
|
1430
|
+
const targetKey = ref.key || '';
|
|
1431
|
+
if (!targetKey || ref.invalidProjectPath) {
|
|
1432
|
+
return null;
|
|
1433
|
+
}
|
|
1434
|
+
return collectTrackedChangeNodes()
|
|
1435
|
+
.find(node => trackedChangeRefFromNode(node, ref.path || getActiveFilePath()).key === targetKey)
|
|
1436
|
+
|| null;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function findNextTrackedChangeNodeForPath(path) {
|
|
1440
|
+
const nodes = findTrackedChangeNodesForPath(path);
|
|
1441
|
+
return nodes[0] || null;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function findLastTrackedChangeNodeForPath(path) {
|
|
1445
|
+
const nodes = findTrackedChangeNodesForPath(path);
|
|
1446
|
+
return nodes[nodes.length - 1] || null;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function findTrackedChangeNodesForPath(path) {
|
|
1450
|
+
const targetPath = normalizeSafeProjectPath(path || getActiveFilePath());
|
|
1451
|
+
return collectTrackedChangeNodes()
|
|
1452
|
+
.filter(node => {
|
|
1453
|
+
const ref = trackedChangeRefFromNode(node, targetPath);
|
|
1454
|
+
return !targetPath || !ref.path || ref.path === targetPath;
|
|
1455
|
+
})
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function findRejectControlForTrackedChangeNode(node) {
|
|
1459
|
+
const scopes = [];
|
|
1460
|
+
let current = node;
|
|
1461
|
+
for (let index = 0; current && index < 6; index += 1) {
|
|
1462
|
+
scopes.push(current);
|
|
1463
|
+
current = current.parentElement;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
for (const scope of scopes) {
|
|
1467
|
+
const candidates = scope.querySelectorAll
|
|
1468
|
+
? Array.from(scope.querySelectorAll('button,[role="button"],[aria-label],[title]'))
|
|
1469
|
+
: [];
|
|
1470
|
+
const reject = candidates.find(isRejectTrackedChangeControl);
|
|
1471
|
+
if (reject) {
|
|
1472
|
+
return reject;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
return isRejectTrackedChangeControl(node) ? node : null;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function findEditorUndoControl() {
|
|
1479
|
+
return collectElements('button,[role="button"],[aria-label],[title]', 1200)
|
|
1480
|
+
.find(isEditorUndoControl)
|
|
1481
|
+
|| null;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function isRejectTrackedChangeControl(node) {
|
|
1485
|
+
if (!node || node.disabled) {
|
|
1486
|
+
return false;
|
|
1487
|
+
}
|
|
1488
|
+
if (/^(true|disabled)$/i.test(node.getAttribute?.('aria-disabled') || '')) {
|
|
1489
|
+
return false;
|
|
1490
|
+
}
|
|
1491
|
+
const signal = normalizeReviewingSignalText(readNodeSignalText(node));
|
|
1492
|
+
if (/\b(?:accept|approve|apply|resolve|接受|批准|应用)\b/i.test(signal)) {
|
|
1493
|
+
return false;
|
|
1494
|
+
}
|
|
1495
|
+
return /\b(?:reject|decline|discard|revert|拒绝|丢弃|还原)\b/i.test(signal);
|
|
1496
|
+
}
|
|
1497
|
+
return {
|
|
1498
|
+
acceptTrackedChanges,
|
|
1499
|
+
rejectTrackedChanges,
|
|
1500
|
+
collectTrackedChangeRefsForPaths,
|
|
1501
|
+
waitForTrackedChangeDiff
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
return { create };
|
|
1506
|
+
});
|