codex-overleaf-link 2.3.0 → 2.3.2

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.
Files changed (50) hide show
  1. package/README.md +23 -23
  2. package/extension/bootstrap/manifest.template.json +1 -1
  3. package/extension/src/background.js +20 -0
  4. package/extension/src/content/assetTransferBroker.js +169 -0
  5. package/extension/src/content/composerPanel.js +0 -2
  6. package/extension/src/content/contentRuntime.js +135 -111
  7. package/extension/src/content/generated/content.bundle.js +1066 -345
  8. package/extension/src/content/generated/content.bundle.meta.json +7 -3
  9. package/extension/src/content/legacyGlobalRegistry.js +3 -0
  10. package/extension/src/content/markdownText.js +12 -10
  11. package/extension/src/content/mathText.js +155 -9
  12. package/extension/src/content/modelPicker.js +2 -2
  13. package/extension/src/content/modelPickerSupport.js +3 -3
  14. package/extension/src/content/nativeCompatibilityController.js +2 -2
  15. package/extension/src/content/otWarmMirror.js +1 -1
  16. package/extension/src/content/pageBridgeClient.js +1 -0
  17. package/extension/src/content/runResultActions.js +114 -0
  18. package/extension/src/content/runTimelineView.js +23 -0
  19. package/extension/src/content/sessionForkController.js +120 -0
  20. package/extension/src/content/writebackController.js +3 -0
  21. package/extension/src/content/writebackOrchestrator.js +5 -50
  22. package/extension/src/page/binaryAssetUploader.js +343 -0
  23. package/extension/src/page/trackedChangesLifecycle.js +12 -4
  24. package/extension/src/page/writebackRouter.js +6 -2
  25. package/extension/src/pageBridge.js +30 -1
  26. package/extension/src/shared/agentTranscript.js +10 -3
  27. package/extension/src/shared/compatibility.js +4 -2
  28. package/extension/src/shared/i18n.js +18 -4
  29. package/extension/src/shared/models.js +13 -2
  30. package/extension/src/shared/pageRpcContract.js +6 -2
  31. package/extension/src/shared/runExecutionSnapshot.js +14 -4
  32. package/extension/src/shared/runExecutionSnapshotCodec.js +5 -2
  33. package/extension/src/shared/scopedPersistenceQueuePolicy.js +20 -1
  34. package/extension/src/shared/scopedPersistenceReceipt.js +60 -0
  35. package/extension/src/shared/scopedPersistenceTransaction.js +50 -47
  36. package/extension/src/shared/sessionState.js +58 -15
  37. package/extension/src/shared/storageDb.js +6 -5
  38. package/extension/src/shared/storageRunActions.js +11 -1
  39. package/extension/src/shared/writebackOperationEvidence.js +16 -6
  40. package/extension/src/shared/writebackSettlement.js +28 -6
  41. package/extension/styles/panel.css +79 -8
  42. package/native-host/src/codexModels.js +6 -2
  43. package/native-host/src/codexPromptAssembly.js +1 -1
  44. package/native-host/src/codexSessionRunner.js +5 -1
  45. package/native-host/src/codexThreadFork.js +103 -0
  46. package/native-host/src/mirrorWorkspace.js +5 -19
  47. package/native-host/src/nativeAssetTransfer.js +183 -0
  48. package/native-host/src/taskRunnerRuntime.js +33 -81
  49. package/native-host/src/updateManager.js +36 -1
  50. package/package.json +1 -1
@@ -0,0 +1,120 @@
1
+ (function initCodexOverleafSessionForkController(root, factory) {
2
+ const api = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = api;
4
+ if (root) root.CodexOverleafSessionForkController = api;
5
+ })(typeof window !== 'undefined' ? window : globalThis, function sessionForkControllerFactory() {
6
+ 'use strict';
7
+
8
+ function create(deps = {}) {
9
+ const {
10
+ SessionState, getState, setState, sendBackgroundNative, saveState,
11
+ applyStateToPanel, showPluginToast, tr
12
+ } = deps;
13
+
14
+ async function forkRunFromNode(runId) {
15
+ const previousState = getState();
16
+ const source = findSource(previousState, runId);
17
+ if (!source) return failFork('session_fork_source_missing', tr('forkRunUnavailable'));
18
+ const { session, run, runIndex } = source;
19
+ if (run.status === 'running' || !session.codexThreadId || !run.codexTurnId) {
20
+ return failFork('session_fork_unavailable', tr('forkRunUnavailable'));
21
+ }
22
+ const response = await sendBackgroundNative({
23
+ method: 'codex.thread.fork',
24
+ params: {
25
+ threadId: session.codexThreadId,
26
+ lastTurnId: run.codexTurnId,
27
+ speedTier: session.speedTier,
28
+ loadCodexLocalSkills: previousState.loadCodexLocalSkills !== false,
29
+ loadCodexOverleafSkills: previousState.loadCodexOverleafSkills !== false
30
+ }
31
+ });
32
+ if (!response?.ok || !response.result?.threadId) {
33
+ return failFork(response?.error?.code || 'session_fork_failed', response?.error?.message || tr('forkRunFailed'));
34
+ }
35
+ const copiedRuns = session.runs.slice(0, runIndex + 1).map(copyRunSnapshot);
36
+ const titleBase = String(session.title || copiedRuns[0]?.task || tr('newSessionFallback')).trim();
37
+ const forkTitle = buildUniqueForkTitle(previousState.sessions, titleBase);
38
+ const forked = SessionState.createSession({
39
+ title: forkTitle,
40
+ titleSource: 'manual',
41
+ runs: copiedRuns,
42
+ task: '',
43
+ mode: session.mode,
44
+ providerId: session.providerId,
45
+ model: session.model,
46
+ reasoningEffort: session.reasoningEffort,
47
+ speedTier: session.speedTier,
48
+ requireReviewing: session.requireReviewing,
49
+ focusFiles: session.focusFiles,
50
+ projectReferenceFiles: session.projectReferenceFiles,
51
+ codexThreadId: response.result.threadId,
52
+ pendingInputs: [],
53
+ forkedFromSessionId: session.id,
54
+ forkedFromRunId: run.id
55
+ });
56
+ setState(SessionState.normalizePanelState({
57
+ ...previousState,
58
+ sessions: [...(previousState.sessions || []), forked],
59
+ activeSessionId: forked.id
60
+ }));
61
+ try {
62
+ await saveState();
63
+ } catch (error) {
64
+ setState(previousState);
65
+ await sendBackgroundNative({
66
+ method: 'codex.history.clearPlugin',
67
+ params: { sessionId: forked.id, threadId: response.result.threadId }
68
+ }).catch(() => null);
69
+ showPluginToast(error.message || tr('forkRunFailed'), { status: 'failed', sticky: true });
70
+ throw error;
71
+ }
72
+ applyStateToPanel();
73
+ showPluginToast(tr('forkRunDone'), { status: 'completed' });
74
+ return forked;
75
+ }
76
+
77
+ function failFork(code, message) {
78
+ showPluginToast(message, { status: 'failed', sticky: true });
79
+ throw codedError(code, message);
80
+ }
81
+
82
+ return { forkRunFromNode };
83
+ }
84
+
85
+ function buildUniqueForkTitle(sessions = [], sourceTitle = '') {
86
+ const fallback = String(sourceTitle || 'New Session').trim() || 'New Session';
87
+ const baseTitle = fallback
88
+ .replace(/\s+\(fork\)$/i, '')
89
+ .replace(/\s+\(\d+\)$/, '')
90
+ .trim() || fallback;
91
+ const usedTitles = new Set((sessions || []).map(session => String(session?.title || '').trim()));
92
+ let index = 1;
93
+ while (usedTitles.has(`${baseTitle} (${index})`)) index += 1;
94
+ return `${baseTitle} (${index})`;
95
+ }
96
+
97
+ function findSource(state, runId) {
98
+ for (const session of state?.sessions || []) {
99
+ const runIndex = (session.runs || []).findIndex(run => run.id === runId);
100
+ if (runIndex >= 0) return { session, run: session.runs[runIndex], runIndex };
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function copyRunSnapshot(run) {
106
+ const copy = JSON.parse(JSON.stringify(run || {}));
107
+ return {
108
+ ...copy,
109
+ id: `fork_${String(copy.id || 'run')}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
110
+ forkSnapshot: true,
111
+ forkSourceRunId: String(copy.id || ''),
112
+ attachments: [], appliedOperations: [], undoOperations: [], undoBaseFiles: [],
113
+ undoTrackedChanges: [], undoExpectedFiles: [], undoStatus: '',
114
+ trackedChangeStatus: '', nativeRequestId: '', queueItemId: '', interruptedDraft: undefined
115
+ };
116
+ }
117
+
118
+ function codedError(code, message) { const error = new Error(message); error.code = code; return error; }
119
+ return { copyRunSnapshot, create };
120
+ });
@@ -15,6 +15,9 @@
15
15
  type: change.type,
16
16
  path: change.path,
17
17
  contentBase64: change.contentBase64 || '',
18
+ assetRef: change.assetRef && typeof change.assetRef === 'object' ? { ...change.assetRef } : undefined,
19
+ sha256: change.sha256 || change.assetRef?.sha256 || '',
20
+ mimeType: change.mimeType || change.assetRef?.mimeType || '',
18
21
  size: Number.isFinite(Number(change.size)) ? Number(change.size) : undefined,
19
22
  previousExists: change.previousExists === true,
20
23
  previousKind: change.previousKind || '',
@@ -25,7 +25,6 @@
25
25
  let appendOperationsPreview;
26
26
  let appendPartialWritebackWarning;
27
27
  let appendApplyResult;
28
- let renderDiffReview;
29
28
  let renderReadOnlyDiffReview;
30
29
  let showPluginConfirm;
31
30
  let callPageBridge;
@@ -62,6 +61,7 @@
62
61
  let onMirrorRefreshSettled;
63
62
  let writebackSettlement;
64
63
  let injectedWritebackSettlement;
64
+ let assetTransferBroker;
65
65
 
66
66
  function buildSettlement(input = {}) {
67
67
  return writebackSettlement?.settle instanceof Function
@@ -96,8 +96,8 @@
96
96
  applyResults: [],
97
97
  mode: runMode,
98
98
  nextStep: tx(
99
- 'Switch to Suggest or Auto only when you want Codex to edit files.',
100
- '只有希望 Codex 修改文件时,才需要切换到建议修改或自动写入。'
99
+ 'Switch to Auto only when you want Codex to edit files.',
100
+ '只有希望 Codex 修改文件时,才需要切换到自动写入。'
101
101
  )
102
102
  });
103
103
  return {
@@ -178,51 +178,6 @@
178
178
  };
179
179
  }
180
180
 
181
- if (runMode === 'confirm') {
182
- appendRunEvent({
183
- title: tx(
184
- `Local Codex produced ${operations.length} change(s). Review the diff before applying.`,
185
- `本地 Codex 产生了 ${operations.length} 项改动,请查看差异后确认。`
186
- ),
187
- status: 'running'
188
- });
189
- const accepted = await renderDiffReview(visibleSyncChanges);
190
- if (!accepted.length) {
191
- appendRunEvent({
192
- title: tx('Sync cancelled: Overleaf was not modified.', '已取消同步:Overleaf 没有被修改。'),
193
- status: 'completed'
194
- });
195
- appendCompletionReport({
196
- conclusion: tx('You cancelled syncing. Local Codex changes were not written back to Overleaf.', '你取消了同步,本地 Codex 改动没有写回 Overleaf。'),
197
- status: 'rejected',
198
- operations: [],
199
- applyResults: [],
200
- mode: runMode,
201
- nextStep: tx('Run the task again, or switch to Auto if you want changes synced directly.', '可以重新运行任务,或切换到自动写入后再同步。')
202
- });
203
- return {
204
- summaryLine: tx('Sync cancelled', '已取消同步'),
205
- hasSkippedOperations: additionalSkippedEntries.length > 0,
206
- settlement: buildSettlement({
207
- operations,
208
- applyResult: { ok: false, applied: [], skipped: additionalSkippedEntries }
209
- }),
210
- audit: buildAuditSummaryFromApply({
211
- operations,
212
- applyResults: additionalSkippedEntries.length ? [{ ok: false, applied: [], skipped: additionalSkippedEntries }] : [],
213
- blockedFiles: additionalSkippedEntries.map(item => summarizeOperationForAudit(item.operation, item.result, 'blocked')),
214
- resultStatus: 'rejected'
215
- })
216
- };
217
- }
218
- operations = buildSyncApplyOperations(accepted, project);
219
- const acceptedPathSafety = partitionUnsafeProjectPathOperations(operations);
220
- if (acceptedPathSafety.skipped.length) {
221
- additionalSkippedEntries.push(...acceptedPathSafety.skipped);
222
- operations = acceptedPathSafety.safe;
223
- }
224
- }
225
-
226
181
  const deleteOperations = operations.filter(operation => operation.type === 'delete');
227
182
  if (deleteOperations.length) {
228
183
  const approved = await showPluginConfirm({
@@ -329,7 +284,7 @@
329
284
  };
330
285
  }
331
286
  const applied = operations.length
332
- ? mergeApplyResultSkipped(await callPageBridge('applyOperations', {
287
+ ? mergeApplyResultSkipped(await assetTransferBroker.applyOperations({
333
288
  operations,
334
289
  baseFiles: project?.files || [],
335
290
  requireReviewing: runRequireReviewing,
@@ -872,7 +827,6 @@
872
827
  appendOperationsPreview,
873
828
  appendPartialWritebackWarning,
874
829
  appendApplyResult,
875
- renderDiffReview,
876
830
  renderReadOnlyDiffReview,
877
831
  showPluginConfirm,
878
832
  callPageBridge,
@@ -906,6 +860,7 @@
906
860
  getState,
907
861
  getCurrentRunView,
908
862
  } = deps);
863
+ assetTransferBroker = deps.assetTransferBroker;
909
864
  onMirrorRefreshSettled = deps.onMirrorRefreshSettled;
910
865
  writebackSettlement = deps.writebackSettlement;
911
866
  compileAdapter = deps.compileAdapter;
@@ -0,0 +1,343 @@
1
+ (function initCodexOverleafBinaryAssetUploader(root, factory) {
2
+ const api = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = api;
4
+ if (root) root.CodexOverleafBinaryAssetUploader = api;
5
+ })(typeof window !== 'undefined' ? window : globalThis, function binaryAssetUploaderFactory() {
6
+ 'use strict';
7
+
8
+ const MAX_ASSET_BYTES = 10 * 1024 * 1024;
9
+
10
+ function create(deps = {}) {
11
+ const windowRef = deps.window || globalThis.window;
12
+ const documentRef = deps.document || windowRef?.document;
13
+ const treeOperations = deps.treeOperations;
14
+ const snapshotRouter = deps.snapshotRouter;
15
+ const transfers = new Map();
16
+
17
+ function begin(params = {}) {
18
+ let transferId;
19
+ let projectPath;
20
+ try {
21
+ transferId = cleanTransferId(params.transferId);
22
+ projectPath = normalizePath(params.path);
23
+ } catch (error) {
24
+ return failure('binary_asset_path_invalid', error.message);
25
+ }
26
+ const size = Number(params.size);
27
+ if (!Number.isSafeInteger(size) || size < 0 || size > MAX_ASSET_BYTES) {
28
+ return failure('binary_asset_size_invalid', 'Asset size is invalid or exceeds 10 MiB.');
29
+ }
30
+ if (!/^[a-f0-9]{64}$/i.test(String(params.sha256 || ''))) {
31
+ return failure('binary_asset_hash_invalid', 'Asset SHA-256 is missing or invalid.');
32
+ }
33
+ transfers.set(transferId, {
34
+ id: transferId,
35
+ path: projectPath,
36
+ size,
37
+ sha256: String(params.sha256).toLowerCase(),
38
+ mimeType: String(params.mimeType || 'application/octet-stream'),
39
+ overwrite: params.overwrite === true,
40
+ chunks: [],
41
+ received: 0
42
+ });
43
+ return { ok: true, transferId };
44
+ }
45
+
46
+ function append(params = {}) {
47
+ const transfer = transfers.get(String(params.transferId || ''));
48
+ if (!transfer) return failure('binary_upload_transfer_missing', 'Asset upload transfer was not found.');
49
+ if (Number(params.offset) !== transfer.received) {
50
+ return failure('binary_upload_chunk_order_invalid', 'Asset chunks arrived out of order.');
51
+ }
52
+ let bytes;
53
+ try { bytes = decodeBase64(params.contentBase64, windowRef); } catch (_error) {
54
+ return failure('binary_upload_chunk_invalid', 'Asset chunk is not valid Base64.');
55
+ }
56
+ if (transfer.received + bytes.length > transfer.size) {
57
+ return failure('binary_upload_size_mismatch', 'Asset chunks exceed the declared size.');
58
+ }
59
+ transfer.chunks.push(bytes);
60
+ transfer.received += bytes.length;
61
+ return { ok: true, transferId: transfer.id, received: transfer.received };
62
+ }
63
+
64
+ async function commit(params = {}) {
65
+ const transferId = String(params.transferId || '');
66
+ const transfer = transfers.get(transferId);
67
+ if (!transfer) return failure('binary_upload_transfer_missing', 'Asset upload transfer was not found.');
68
+ try {
69
+ if (transfer.received !== transfer.size) return failure('binary_upload_incomplete', 'Asset upload is incomplete.');
70
+ const bytes = joinChunks(transfer.chunks, transfer.size);
71
+ const digest = await sha256Hex(bytes, windowRef.crypto);
72
+ if (digest !== transfer.sha256) {
73
+ return failure('binary_upload_checksum_mismatch', 'Asset checksum did not match the Native Host descriptor.');
74
+ }
75
+ const fileName = transfer.path.split('/').pop();
76
+ const file = new windowRef.File([bytes], fileName, { type: transfer.mimeType });
77
+ const attempts = [
78
+ // Prefer Overleaf's visible uploader. Its UI contract is more
79
+ // stable than the private multipart endpoint across deployments.
80
+ () => uploadWithDom(file, transfer),
81
+ () => uploadWithMultipart(file, transfer),
82
+ () => uploadWithLegacyManager(file, transfer)
83
+ ];
84
+ const errors = [];
85
+ for (const attempt of attempts) {
86
+ try {
87
+ const result = await attempt();
88
+ const observed = result?.ok ? await waitForPath(transfer.path, 12000) : null;
89
+ const hashVerified = observed ? await verifyRemoteHash(observed, transfer).catch(() => false) : false;
90
+ const overwriteVerified = !transfer.overwrite || hashVerified || result?.confirmedByTransport === true;
91
+ if (result?.ok && observed && overwriteVerified) {
92
+ return { ok: true, written: true, path: transfer.path, method: result.method, changedDocument: true };
93
+ }
94
+ if (result?.ok && observed && !overwriteVerified) errors.push('Overleaf did not expose enough evidence to verify the binary replacement.');
95
+ if (result?.reason) errors.push(result.reason);
96
+ } catch (error) {
97
+ errors.push(error.message);
98
+ }
99
+ }
100
+ return failure('binary_upload_unavailable', errors.filter(Boolean).join('; ') || 'No supported Overleaf asset upload path succeeded.');
101
+ } finally {
102
+ transfers.delete(transferId);
103
+ }
104
+ }
105
+
106
+ function abort(params = {}) {
107
+ return { ok: true, aborted: transfers.delete(String(params.transferId || '')) };
108
+ }
109
+
110
+ async function uploadWithMultipart(file, transfer) {
111
+ const projectId = treeOperations.getProjectId();
112
+ if (!projectId || typeof windowRef.fetch !== 'function') return { ok: false, reason: 'Project upload endpoint is unavailable.' };
113
+ const parentPath = transfer.path.split('/').slice(0, -1).join('/');
114
+ const folderId = await findFolderId(parentPath);
115
+ if (parentPath && !folderId) return { ok: false, reason: 'Target folder id is unavailable for direct upload.' };
116
+ const form = new windowRef.FormData();
117
+ form.append('qqfile', file, file.name);
118
+ form.append('name', file.name);
119
+ if (folderId) form.append('folder_id', folderId);
120
+ const csrf = findCsrfToken();
121
+ const headers = csrf ? { 'X-CSRF-Token': csrf } : {};
122
+ const query = folderId ? `?folder_id=${encodeURIComponent(folderId)}` : '';
123
+ const response = await windowRef.fetch(`/project/${encodeURIComponent(projectId)}/upload${query}`, {
124
+ method: 'POST', body: form, credentials: 'same-origin', headers
125
+ });
126
+ const responseText = await response.text();
127
+ if (!response.ok) throw new Error(formatUploadHttpError(response.status, responseText));
128
+ try {
129
+ const payload = responseText ? JSON.parse(responseText) : null;
130
+ if (payload && (payload.success === false || payload.ok === false || payload.error)) {
131
+ throw new Error(payload.error?.message || payload.error || payload.message || 'Overleaf rejected the asset upload.');
132
+ }
133
+ } catch (error) {
134
+ if (error instanceof SyntaxError) {
135
+ // Some Overleaf deployments return an empty or HTML success body.
136
+ } else {
137
+ throw error;
138
+ }
139
+ }
140
+ return { ok: true, method: 'overleaf.multipart', confirmedByTransport: true };
141
+ }
142
+
143
+ async function uploadWithDom(file, transfer) {
144
+ const parentPath = transfer.path.split('/').slice(0, -1).join('/');
145
+ if (parentPath) {
146
+ const folderNode = treeOperations.findFileTreeNode(parentPath);
147
+ if (!folderNode) return { ok: false, reason: `Target folder ${parentPath} is unavailable in the Overleaf file tree.` };
148
+ folderNode?.dispatchEvent(new windowRef.MouseEvent('click', { bubbles: true }));
149
+ await delay(120);
150
+ }
151
+ let input = findFileInput();
152
+ if (!input) {
153
+ const button = findUploadButton();
154
+ if (!button) return { ok: false, reason: 'Overleaf upload button was not found.' };
155
+ button.click();
156
+ input = await waitForFileInput(4000);
157
+ }
158
+ if (!input) return { ok: false, reason: 'Overleaf upload file input was not found.' };
159
+ const data = new windowRef.DataTransfer();
160
+ data.items.add(file);
161
+ assignFilesToInput(input, data.files, windowRef);
162
+ input.dispatchEvent(new windowRef.Event('input', { bubbles: true }));
163
+ input.dispatchEvent(new windowRef.Event('change', { bubbles: true }));
164
+ await clickReplaceIfShown();
165
+ return { ok: true, method: 'overleaf.file-input' };
166
+ }
167
+
168
+ async function uploadWithLegacyManager(file, transfer) {
169
+ const manager = treeOperations.findFileTreeManager();
170
+ if (!manager) return { ok: false, reason: 'Legacy file-tree manager is unavailable.' };
171
+ for (const name of ['uploadFile', 'uploadAsset', 'createBinaryFile', 'createFile', 'addFile']) {
172
+ if (typeof manager[name] !== 'function') continue;
173
+ try {
174
+ await manager[name](transfer.path, file);
175
+ return { ok: true, method: `fileTreeManager.${name}`, confirmedByTransport: true };
176
+ } catch (_error) { /* try the next adapter */ }
177
+ }
178
+ return { ok: false, reason: 'Legacy file-tree manager has no compatible asset method.' };
179
+ }
180
+
181
+ async function findFolderId(parentPath) {
182
+ const node = parentPath ? treeOperations.findFileTreeNode(parentPath) : null;
183
+ const element = node?.closest?.('[data-entity-id], [data-folder-id], [data-id]') || node;
184
+ const domId = element?.dataset?.entityId || element?.dataset?.folderId || element?.dataset?.id;
185
+ if (domId) return domId;
186
+ for (const root of [windowRef._ide, windowRef.Overleaf, windowRef.overleaf, windowRef.OL]) {
187
+ const folder = root?.project?.rootFolder?.[0] || root?.project?.rootFolder || root?.rootFolder?.[0] || root?.rootFolder;
188
+ if (!parentPath && (folder?._id || folder?.id)) return folder._id || folder.id;
189
+ }
190
+ try {
191
+ const list = await snapshotRouter?.buildProjectFileList({
192
+ force: true, maxAgeMs: 0, preferLightweight: true, allowZipFallback: false
193
+ });
194
+ const folder = (list?.files || []).find(item => item.path === parentPath && /folder/i.test(item.kind || item.type || ''));
195
+ if (folder) return folder.id || folder._id || folder.entityId || '';
196
+ } catch (_error) { /* DOM and legacy adapters remain available */ }
197
+ return '';
198
+ }
199
+
200
+ function findCsrfToken() {
201
+ return documentRef.querySelector('meta[name="ol-csrfToken"], meta[name="csrf-token"], meta[name="_csrf"]')?.content
202
+ || windowRef.csrfToken || windowRef._csrf || '';
203
+ }
204
+ function findFileInput() {
205
+ return chooseOverleafFileInput(Array.from(documentRef.querySelectorAll('input[type="file"]')));
206
+ }
207
+ function findUploadButton() {
208
+ return chooseOverleafUploadButton(Array.from(documentRef.querySelectorAll('button, [role="button"]')));
209
+ }
210
+ function chooseOverleafUploadButton(nodes) {
211
+ return nodes.filter(node => {
212
+ if (!node || node.disabled || isCodexOwnedNode(node)) return false;
213
+ const label = `${node.getAttribute('aria-label') || ''} ${node.getAttribute('title') || ''} ${node.textContent || ''}`;
214
+ return /upload|上传/i.test(label);
215
+ }).sort((left, right) => uploadButtonScore(right) - uploadButtonScore(left))[0] || null;
216
+ }
217
+ async function waitForFileInput(timeoutMs) {
218
+ const deadline = Date.now() + timeoutMs;
219
+ while (Date.now() < deadline) {
220
+ const input = findFileInput();
221
+ if (input) return input;
222
+ await delay(50);
223
+ }
224
+ return null;
225
+ }
226
+ async function clickReplaceIfShown() {
227
+ await delay(180);
228
+ const button = Array.from(documentRef.querySelectorAll('button')).find(node => /replace|overwrite|替换|覆盖/i.test(node.textContent || ''));
229
+ if (button) button.click();
230
+ }
231
+ async function waitForPath(projectPath, timeoutMs) {
232
+ const deadline = Date.now() + timeoutMs;
233
+ while (Date.now() < deadline) {
234
+ if (treeOperations.projectPathExists(projectPath)) {
235
+ const node = treeOperations.findFileTreeNode(projectPath);
236
+ return {
237
+ path: projectPath,
238
+ id: node?.dataset?.entityId || node?.dataset?.fileId || node?.dataset?.id || ''
239
+ };
240
+ }
241
+ await delay(250);
242
+ try {
243
+ const list = await snapshotRouter?.buildProjectFileList({
244
+ force: true, maxAgeMs: 0, preferLightweight: true, allowZipFallback: false
245
+ });
246
+ const file = (list?.files || []).find(item => item.path === projectPath);
247
+ if (file) return file;
248
+ } catch (_error) { /* continue until the deadline */ }
249
+ }
250
+ return null;
251
+ }
252
+ async function verifyRemoteHash(observed, transfer) {
253
+ const fileId = observed?.id || observed?._id || observed?.entityId || observed?.fileId || '';
254
+ const projectId = treeOperations.getProjectId();
255
+ if (!fileId || !projectId || typeof windowRef.fetch !== 'function') return false;
256
+ const response = await windowRef.fetch(`/project/${encodeURIComponent(projectId)}/file/${encodeURIComponent(fileId)}`, {
257
+ credentials: 'same-origin', cache: 'no-store'
258
+ });
259
+ if (!response.ok) return false;
260
+ const digest = await sha256Hex(new Uint8Array(await response.arrayBuffer()), windowRef.crypto);
261
+ return digest === transfer.sha256;
262
+ }
263
+ function delay(ms) { return new Promise(resolve => windowRef.setTimeout(resolve, ms)); }
264
+ return { abort, append, begin, commit };
265
+ }
266
+
267
+ function normalizePath(value) {
268
+ const path = String(value || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
269
+ if (!path || path.split('/').some(part => !part || part === '.' || part === '..')) throw new Error('Asset path is invalid.');
270
+ return path;
271
+ }
272
+ function cleanTransferId(value) {
273
+ const id = String(value || '');
274
+ if (!/^[a-zA-Z0-9_-]{8,120}$/.test(id)) throw new Error('Asset transfer id is invalid.');
275
+ return id;
276
+ }
277
+ function chooseOverleafFileInput(inputs) {
278
+ return Array.from(inputs || [])
279
+ .filter(input => input && !input.disabled && !isCodexOwnedNode(input))
280
+ .sort((left, right) => uploadInputScore(right) - uploadInputScore(left))[0] || null;
281
+ }
282
+ function isCodexOwnedNode(node) {
283
+ return Boolean(node?.closest?.('#codex-overleaf-panel, [data-codex-overleaf-root], .codex-overleaf-panel'));
284
+ }
285
+ function uploadInputScore(input) {
286
+ let score = 0;
287
+ if (input?.closest?.('.uppy-Dashboard, [data-uppy-drag-drop-supported]')) score += 100;
288
+ if (input?.closest?.('[role="dialog"], .modal')) score += 40;
289
+ if (/uppy|upload/i.test(`${input?.className || ''} ${input?.getAttribute?.('data-testid') || ''}`)) score += 30;
290
+ if (input?.multiple) score += 10;
291
+ if (input?.isConnected !== false) score += 1;
292
+ return score;
293
+ }
294
+ function uploadButtonScore(node) {
295
+ let score = 0;
296
+ const label = `${node?.getAttribute?.('aria-label') || ''} ${node?.getAttribute?.('title') || ''} ${node?.textContent || ''}`.trim();
297
+ if (/^(upload|上传)$/i.test(label)) score += 50;
298
+ if (node?.closest?.('[data-testid*="file-tree"], .file-tree, .ide-react-file-tree')) score += 30;
299
+ if (node?.isConnected !== false) score += 1;
300
+ return score;
301
+ }
302
+ function assignFilesToInput(input, files, windowRef) {
303
+ const prototype = windowRef?.HTMLInputElement?.prototype;
304
+ const setter = prototype && Object.getOwnPropertyDescriptor(prototype, 'files')?.set;
305
+ if (setter) {
306
+ setter.call(input, files);
307
+ return;
308
+ }
309
+ Object.defineProperty(input, 'files', { configurable: true, value: files });
310
+ }
311
+ function formatUploadHttpError(status, responseText) {
312
+ const detail = summarizeUploadResponse(responseText);
313
+ return `Overleaf upload endpoint returned HTTP ${status}${detail ? ` (${detail})` : ''}.`;
314
+ }
315
+ function summarizeUploadResponse(value) {
316
+ const text = String(value || '').trim();
317
+ if (!text || /^\s*</.test(text)) return '';
318
+ let message = text;
319
+ try {
320
+ const payload = JSON.parse(text);
321
+ message = payload?.error?.message || payload?.error || payload?.message || text;
322
+ } catch (_error) { /* use the bounded plain-text response */ }
323
+ return String(message || '').replace(/\s+/g, ' ').slice(0, 240);
324
+ }
325
+ function decodeBase64(value, windowRef) {
326
+ const binary = windowRef.atob(String(value || ''));
327
+ const bytes = new Uint8Array(binary.length);
328
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
329
+ return bytes;
330
+ }
331
+ function joinChunks(chunks, size) {
332
+ const output = new Uint8Array(size);
333
+ let offset = 0;
334
+ for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.length; }
335
+ return output;
336
+ }
337
+ async function sha256Hex(bytes, cryptoImpl) {
338
+ const digest = await cryptoImpl.subtle.digest('SHA-256', bytes);
339
+ return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
340
+ }
341
+ function failure(code, reason) { return { ok: false, code, reason, changedDocument: false }; }
342
+ return { chooseOverleafFileInput, create, formatUploadHttpError, joinChunks, normalizePath };
343
+ });
@@ -971,8 +971,15 @@
971
971
  }
972
972
 
973
973
  for (const path of paths) {
974
- if (path && getActiveFilePath() !== path) {
975
- const opened = await openFileByPath(path);
974
+ const postContent = postByPath.get(path);
975
+ const activePathMatches = getActiveFilePath() === path;
976
+ const activeContentMatches = readActiveEditorText() === postContent;
977
+ // Overleaf can update the selected tree path before CodeMirror swaps its
978
+ // document. In that state getActiveFilePath() already names `path`, while
979
+ // readActiveEditorText() still belongs to the previously viewed file.
980
+ // Force-reopen the target whenever either identity signal disagrees.
981
+ if (path && (!activePathMatches || !activeContentMatches)) {
982
+ const opened = await openFileByPath(path, { force: true });
976
983
  if (!opened.ok) {
977
984
  return {
978
985
  ok: false,
@@ -983,8 +990,7 @@
983
990
  }
984
991
  }
985
992
 
986
- const postContent = postByPath.get(path);
987
- const postReady = await waitForActiveEditorExpectedText(path, postContent, 2500);
993
+ const postReady = await waitForActiveEditorExpectedText(path, postContent, 9000);
988
994
  if (!postReady.ok) {
989
995
  return {
990
996
  ok: false,
@@ -1492,6 +1498,8 @@
1492
1498
  '[data-track-change-id]',
1493
1499
  '[data-ol-change-id]',
1494
1500
  '[data-path][class*="change" i]',
1501
+ '.ol-cm-change',
1502
+ '.review-panel-entry-change',
1495
1503
  '[class*="track-change" i]',
1496
1504
  '[class*="review-change" i]',
1497
1505
  '[class*="suggest" i]',
@@ -1667,13 +1667,17 @@
1667
1667
  continue;
1668
1668
  }
1669
1669
  const key = typeof ref.key === 'string' ? ref.key : '';
1670
- if (!key || seen.has(key)) {
1670
+ if (!key) {
1671
1671
  continue;
1672
1672
  }
1673
- seen.add(key);
1674
1673
  const hasPath = typeof ref.path === 'string';
1675
1674
  const hasExplicitPath = hasPath && ref.path.trim() !== '';
1676
1675
  const path = hasPath ? normalizeSafeProjectPath(ref.path) : '';
1676
+ const identity = `${path}\u0000${key}`;
1677
+ if (seen.has(identity)) {
1678
+ continue;
1679
+ }
1680
+ seen.add(identity);
1677
1681
  normalized.push({
1678
1682
  key,
1679
1683
  id: typeof ref.id === 'string' ? ref.id : '',