codex-overleaf-link 2.3.6 → 2.4.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.
Files changed (41) hide show
  1. package/README.md +59 -34
  2. package/README.zh-CN.md +543 -0
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +3 -0
  5. package/extension/src/content/activeTurnControl.js +1 -0
  6. package/extension/src/content/assetTransferBroker.js +2 -0
  7. package/extension/src/content/contentRuntime.js +53 -50
  8. package/extension/src/content/contextTray.js +4 -0
  9. package/extension/src/content/diagnosticsController.js +4 -0
  10. package/extension/src/content/diagnosticsPanel.js +128 -4
  11. package/extension/src/content/generated/content.bundle.js +583 -112
  12. package/extension/src/content/generated/content.bundle.meta.json +4 -3
  13. package/extension/src/content/legacyGlobalRegistry.js +1 -0
  14. package/extension/src/content/modelPicker.js +21 -9
  15. package/extension/src/content/modelPickerSupport.js +38 -1
  16. package/extension/src/content/nativeCompatibilityController.js +4 -1
  17. package/extension/src/content/otWarmMirror.js +34 -30
  18. package/extension/src/content/otWarmMirrorController.js +20 -0
  19. package/extension/src/content/pageBridgeClient.js +2 -0
  20. package/extension/src/content/runResultActions.js +16 -1
  21. package/extension/src/content/runTimelineView.js +1 -1
  22. package/extension/src/content/sessionForkController.js +20 -11
  23. package/extension/src/content/trackedChangeCaptureController.js +153 -0
  24. package/extension/src/content/writebackOrchestrator.js +31 -0
  25. package/extension/src/page/binaryAssetUploader.js +101 -29
  26. package/extension/src/page/overleafRealtimeObserver.js +4 -1
  27. package/extension/src/page/textFileCreator.js +493 -0
  28. package/extension/src/page/trackedChangeCapture.js +342 -0
  29. package/extension/src/page/trackedChangesLifecycle.js +145 -149
  30. package/extension/src/page/treeOperations.js +30 -2
  31. package/extension/src/page/writebackRouter.js +94 -48
  32. package/extension/src/pageBridge.js +62 -25
  33. package/extension/src/shared/compatibility.js +1 -1
  34. package/extension/src/shared/pageRpcContract.js +2 -1
  35. package/extension/src/shared/sessionState.js +6 -2
  36. package/extension/src/shared/storageDb.js +10 -12
  37. package/extension/src/shared/storageRunActions.js +29 -1
  38. package/extension/src/shared/writebackSettlement.js +19 -5
  39. package/extension/styles/panel.css +55 -10
  40. package/native-host/src/taskRunnerRuntime.js +25 -0
  41. package/package.json +5 -1
@@ -36,6 +36,7 @@
36
36
  size,
37
37
  sha256: String(params.sha256).toLowerCase(),
38
38
  mimeType: String(params.mimeType || 'application/octet-stream'),
39
+ projectId: treeOperations.getProjectId(),
39
40
  overwrite: params.overwrite === true,
40
41
  chunks: [],
41
42
  received: 0
@@ -66,6 +67,7 @@
66
67
  const transfer = transfers.get(transferId);
67
68
  if (!transfer) return failure('binary_upload_transfer_missing', 'Asset upload transfer was not found.');
68
69
  try {
70
+ assertTransferProject(transfer);
69
71
  if (transfer.received !== transfer.size) return failure('binary_upload_incomplete', 'Asset upload is incomplete.');
70
72
  const bytes = joinChunks(transfer.chunks, transfer.size);
71
73
  const digest = await sha256Hex(bytes, windowRef.crypto);
@@ -84,9 +86,12 @@
84
86
  const errors = [];
85
87
  for (const attempt of attempts) {
86
88
  try {
89
+ assertTransferProject(transfer);
87
90
  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;
91
+ const evidence = result?.ok ? await waitForPath(transfer, result, 12000) : null;
92
+ const observed = evidence?.observed;
93
+ const hashVerified = evidence?.hashVerified === true;
94
+ assertTransferProject(transfer);
90
95
  const overwriteVerified = !transfer.overwrite || hashVerified || result?.confirmedByTransport === true;
91
96
  if (result?.ok && observed && overwriteVerified) {
92
97
  return { ok: true, written: true, path: transfer.path, method: result.method, changedDocument: true };
@@ -97,7 +102,8 @@
97
102
  errors.push(error.message);
98
103
  }
99
104
  }
100
- return failure('binary_upload_unavailable', errors.filter(Boolean).join('; ') || 'No supported Overleaf asset upload path succeeded.');
105
+ return { ...failure('binary_upload_unavailable', errors.filter(Boolean).join('; ') || 'No supported Overleaf asset upload path succeeded.'),
106
+ changedDocument: transfer.mutationAttempted === true };
101
107
  } finally {
102
108
  transfers.delete(transferId);
103
109
  }
@@ -107,6 +113,13 @@
107
113
  return { ok: true, aborted: transfers.delete(String(params.transferId || '')) };
108
114
  }
109
115
 
116
+ function assertTransferProject(transfer) {
117
+ if (transfers.get(transfer.id) !== transfer) throw new Error('Asset upload was cancelled or replaced.');
118
+ if (!transfer.projectId || treeOperations.getProjectId() !== transfer.projectId) {
119
+ throw new Error('Project changed during asset upload.');
120
+ }
121
+ }
122
+
110
123
  async function uploadWithMultipart(file, transfer) {
111
124
  const projectId = treeOperations.getProjectId();
112
125
  if (!projectId || typeof windowRef.fetch !== 'function') return { ok: false, reason: 'Project upload endpoint is unavailable.' };
@@ -120,6 +133,7 @@
120
133
  const csrf = findCsrfToken();
121
134
  const headers = csrf ? { 'X-CSRF-Token': csrf } : {};
122
135
  const query = folderId ? `?folder_id=${encodeURIComponent(folderId)}` : '';
136
+ assertTransferProject(transfer);
123
137
  const response = await windowRef.fetch(`/project/${encodeURIComponent(projectId)}/upload${query}`, {
124
138
  method: 'POST', body: form, credentials: 'same-origin', headers
125
139
  });
@@ -143,11 +157,16 @@
143
157
  async function uploadWithDom(file, transfer) {
144
158
  const parentPath = transfer.path.split('/').slice(0, -1).join('/');
145
159
  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);
160
+ if (typeof deps.prepareUploadParent !== 'function') {
161
+ return { ok: false, reason: 'Verified folder selection is unavailable for nested asset upload.' };
162
+ }
163
+ await deps.prepareUploadParent(parentPath, {
164
+ createMissing: true,
165
+ onMutation: () => { transfer.mutationAttempted = true; },
166
+ isCurrent: () => treeOperations.getProjectId() === transfer.projectId && transfers.get(transfer.id) === transfer
167
+ });
150
168
  }
169
+ assertTransferProject(transfer);
151
170
  let input = findFileInput();
152
171
  if (!input) {
153
172
  const button = findUploadButton();
@@ -158,10 +177,12 @@
158
177
  if (!input) return { ok: false, reason: 'Overleaf upload file input was not found.' };
159
178
  const data = new windowRef.DataTransfer();
160
179
  data.items.add(file);
180
+ assertTransferProject(transfer);
161
181
  assignFilesToInput(input, data.files, windowRef);
182
+ transfer.mutationAttempted = true;
162
183
  input.dispatchEvent(new windowRef.Event('input', { bubbles: true }));
163
184
  input.dispatchEvent(new windowRef.Event('change', { bubbles: true }));
164
- await clickReplaceIfShown();
185
+ await clickReplaceIfShown(transfer);
165
186
  return { ok: true, method: 'overleaf.file-input' };
166
187
  }
167
188
 
@@ -179,9 +200,9 @@
179
200
  }
180
201
 
181
202
  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;
203
+ const node = parentPath ? (deps.findFolderNode?.(parentPath)
204
+ || findNativeTreeNode(parentPath, 'folder') || treeOperations.findFileTreeNode(parentPath)) : null;
205
+ const domId = readTreeEntityId(node);
185
206
  if (domId) return domId;
186
207
  for (const root of [windowRef._ide, windowRef.Overleaf, windowRef.overleaf, windowRef.OL]) {
187
208
  const folder = root?.project?.rootFolder?.[0] || root?.project?.rootFolder || root?.rootFolder?.[0] || root?.rootFolder;
@@ -223,31 +244,82 @@
223
244
  }
224
245
  return null;
225
246
  }
226
- async function clickReplaceIfShown() {
247
+ async function clickReplaceIfShown(transfer) {
227
248
  await delay(180);
228
- const button = Array.from(documentRef.querySelectorAll('button')).find(node => /replace|overwrite|替换|覆盖/i.test(node.textContent || ''));
249
+ assertTransferProject(transfer);
250
+ if (!transfer.overwrite) return;
251
+ const button = Array.from(documentRef.querySelectorAll('[role="dialog"] button')).find(node => !isCodexOwnedNode(node)
252
+ && /replace|overwrite|替换|覆盖/i.test(node.textContent || ''));
229
253
  if (button) button.click();
230
254
  }
231
- async function waitForPath(projectPath, timeoutMs) {
255
+ function readTreeEntityId(node) {
256
+ const owner = node?.closest?.('[data-entity-id], [data-file-id], [data-folder-id], [data-id]');
257
+ const row = node?.closest?.('[role="treeitem"]') || node;
258
+ const entity = row?.querySelector?.(':scope > .entity[data-file-id]');
259
+ for (const candidate of [node, entity, owner]) {
260
+ const id = candidate?.dataset?.entityId || candidate?.dataset?.fileId
261
+ || candidate?.dataset?.folderId || candidate?.dataset?.id;
262
+ if (id) return id;
263
+ }
264
+ return '';
265
+ }
266
+ function findNativeTreeNode(projectPath, type) {
267
+ const root = documentRef.querySelector?.('[data-testid="file-tree-list-root"]');
268
+ for (const row of root?.querySelectorAll?.('[role="treeitem"]') || []) {
269
+ const entity = row.querySelector?.(':scope > .entity[data-file-id]');
270
+ if (entity?.dataset?.fileType === type && readNativeTreePath(row, root) === projectPath) return row;
271
+ }
272
+ return null;
273
+ }
274
+ function readNativeTreePath(row, root) {
275
+ const parts = [row.getAttribute('aria-label')];
276
+ let group = row.closest('[role="tree"]');
277
+ const seen = new Set();
278
+ while (group && group !== root) {
279
+ if (seen.has(group) || seen.size >= 64) return '';
280
+ seen.add(group);
281
+ const folder = group.previousElementSibling;
282
+ const entity = folder?.querySelector?.(':scope > .entity[data-file-id]');
283
+ if (folder?.getAttribute?.('role') !== 'treeitem' || entity?.dataset?.fileType !== 'folder') return '';
284
+ parts.unshift(folder.getAttribute('aria-label'));
285
+ group = group.parentElement?.closest?.('[role="tree"]');
286
+ }
287
+ if (group !== root || parts.some(part => !part || /[/\\]/.test(part))) return '';
288
+ return parts.join('/');
289
+ }
290
+ async function waitForPath(transfer, result, timeoutMs) {
291
+ const projectPath = transfer.path;
232
292
  const deadline = Date.now() + timeoutMs;
293
+ let lastObserved = null;
233
294
  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
- };
295
+ assertTransferProject(transfer);
296
+ const node = findNativeTreeNode(projectPath, 'file')
297
+ || treeOperations.findFileTreeNode(projectPath, { invalidateCache: true });
298
+ let observed = node || treeOperations.projectPathExists(projectPath)
299
+ ? { path: projectPath, id: readTreeEntityId(node) } : null;
300
+ if (!observed?.id) {
301
+ try {
302
+ const list = await snapshotRouter?.buildProjectFileList({
303
+ force: true, maxAgeMs: 0, preferLightweight: true, allowZipFallback: false
304
+ });
305
+ const file = (list?.files || []).find(item => item.path === projectPath);
306
+ if (file) observed = file;
307
+ } catch (_error) { /* continue until the deadline */ }
308
+ }
309
+ assertTransferProject(transfer);
310
+ if (observed) {
311
+ lastObserved = observed;
312
+ const hashVerified = await verifyRemoteHash(observed, transfer).catch(() => false);
313
+ assertTransferProject(transfer);
314
+ // An existing path is not replacement evidence. Re-read identity and
315
+ // bytes while the native uploader and file-tree events settle.
316
+ if (!transfer.overwrite || hashVerified || result.confirmedByTransport === true) {
317
+ return { observed, hashVerified };
318
+ }
240
319
  }
241
320
  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
321
  }
250
- return null;
322
+ return { observed: lastObserved, hashVerified: false };
251
323
  }
252
324
  async function verifyRemoteHash(observed, transfer) {
253
325
  const fileId = observed?.id || observed?._id || observed?.entityId || observed?.fileId || '';
@@ -339,5 +411,5 @@
339
411
  return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
340
412
  }
341
413
  function failure(code, reason) { return { ok: false, code, reason, changedDocument: false }; }
342
- return { chooseOverleafFileInput, create, formatUploadHttpError, joinChunks, normalizePath };
414
+ return { assignFilesToInput, chooseOverleafFileInput, create, formatUploadHttpError, joinChunks, normalizePath };
343
415
  });
@@ -73,6 +73,9 @@
73
73
 
74
74
  running = true;
75
75
  refreshActiveBaseline();
76
+ // The editor may mount after the page bridge. Keep the read-only
77
+ // observer alive until its first file identity is available.
78
+ if (statusName === 'unavailable' && lastErrorCode === 'missing_active_path') statusName = 'starting';
76
79
  if (running) attachDocumentListeners();
77
80
  return getStatus();
78
81
  }
@@ -103,7 +106,7 @@
103
106
  lastErrorCode,
104
107
  channelCandidates: cloneChannelCandidates(channelCandidates)
105
108
  };
106
- if (statusName === 'unavailable' && statusReason) {
109
+ if ((statusName === 'unavailable' || statusName === 'starting') && statusReason) {
107
110
  status.reason = statusReason;
108
111
  }
109
112
  return status;