codex-overleaf-link 2.5.0 → 2.5.1

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 (168) hide show
  1. package/README.md +38 -26
  2. package/README.zh-CN.md +39 -27
  3. package/extension/bootstrap/background.js +52 -9
  4. package/extension/bootstrap/manifest.template.json +1 -1
  5. package/extension/bootstrap/popup.html +1 -0
  6. package/extension/bootstrap/popupFallback.js +55 -0
  7. package/extension/bootstrap/updateStatus.js +1 -0
  8. package/extension/src/background.js +206 -53
  9. package/extension/src/backgroundBootstrapMigration.js +181 -0
  10. package/extension/src/backgroundSharedSessions.js +74 -14
  11. package/extension/src/backgroundUpdateCoordinator.js +118 -21
  12. package/extension/src/backgroundUpdateSafety.js +173 -0
  13. package/extension/src/content/activeTurnControl.js +8 -2
  14. package/extension/src/content/applyResultFormatters.js +46 -73
  15. package/extension/src/content/assetTransferBroker.js +72 -14
  16. package/extension/src/content/composerAttachments.js +93 -45
  17. package/extension/src/content/composerPanel.js +18 -0
  18. package/extension/src/content/contentRuntime.js +661 -676
  19. package/extension/src/content/diagnosticsController.js +122 -96
  20. package/extension/src/content/diagnosticsExport.js +19 -5
  21. package/extension/src/content/diagnosticsPanel.js +30 -0
  22. package/extension/src/content/directoryCleanupRecovery.js +54 -0
  23. package/extension/src/content/generated/content.bundle.js +6879 -2786
  24. package/extension/src/content/generated/content.bundle.meta.json +11 -3
  25. package/extension/src/content/globalPreferencesController.js +1 -1
  26. package/extension/src/content/legacyGlobalRegistry.js +6 -0
  27. package/extension/src/content/localSkillsPanel.js +102 -12
  28. package/extension/src/content/markdownText.js +5 -6
  29. package/extension/src/content/modelPicker.js +7 -4
  30. package/extension/src/content/nativeCompatibilityController.js +45 -20
  31. package/extension/src/content/otWarmMirror.js +4 -5
  32. package/extension/src/content/otWarmMirrorController.js +17 -20
  33. package/extension/src/content/pageBridgeClient.js +15 -2
  34. package/extension/src/content/panelMaintenance.js +40 -12
  35. package/extension/src/content/panelRenderer.js +6 -0
  36. package/extension/src/content/projectProviderSelection.js +13 -4
  37. package/extension/src/content/projectSessionCleanup.js +109 -62
  38. package/extension/src/content/projectSettingsCoordinator.js +39 -2
  39. package/extension/src/content/providerSettingsCoordinator.js +46 -8
  40. package/extension/src/content/providerSettingsDialog.js +71 -13
  41. package/extension/src/content/recentProjects.js +11 -5
  42. package/extension/src/content/runActivitySummary.js +40 -6
  43. package/extension/src/content/runCompletionMeta.js +344 -0
  44. package/extension/src/content/runController.js +37 -14
  45. package/extension/src/content/runFailureNotice.js +21 -12
  46. package/extension/src/content/runGuidanceController.js +72 -27
  47. package/extension/src/content/runGuidanceView.js +59 -10
  48. package/extension/src/content/runQueueScheduler.js +148 -42
  49. package/extension/src/content/runRecoveryActions.js +146 -0
  50. package/extension/src/content/runResultActions.js +8 -125
  51. package/extension/src/content/runReviewController.js +574 -0
  52. package/extension/src/content/runScrollLayout.js +21 -2
  53. package/extension/src/content/runTimelineView.js +146 -153
  54. package/extension/src/content/scopedPersistencePanelState.js +51 -6
  55. package/extension/src/content/selectionContextView.js +1 -1
  56. package/extension/src/content/sensitivePreflight.js +140 -0
  57. package/extension/src/content/sessionForkController.js +80 -17
  58. package/extension/src/content/sessionManager.js +1 -1
  59. package/extension/src/content/settingsPanel.js +7 -3
  60. package/extension/src/content/settingsWorkbench.js +5 -0
  61. package/extension/src/content/sharedSessionViewSync.js +45 -8
  62. package/extension/src/content/updateIdle.js +4 -1
  63. package/extension/src/content/updateNotice.js +60 -8
  64. package/extension/src/content/updateSafetyReceiver.js +58 -0
  65. package/extension/src/content/writebackController.js +53 -4
  66. package/extension/src/content/writebackOrchestrator.js +82 -73
  67. package/extension/src/content/writingStyleSettings.js +35 -11
  68. package/extension/src/page/binaryAssetUploader.js +136 -14
  69. package/extension/src/page/fileMover.js +256 -0
  70. package/extension/src/page/ownedDirectoryCleanup.js +94 -0
  71. package/extension/src/page/referenceProjects.js +1 -0
  72. package/extension/src/page/reviewContext.js +113 -0
  73. package/extension/src/page/saveState.js +6 -4
  74. package/extension/src/page/snapshotRouter.js +84 -39
  75. package/extension/src/page/textFileCreator.js +105 -25
  76. package/extension/src/page/trackedChangeCapture.js +12 -2
  77. package/extension/src/page/trackedChangeOwnership.js +19 -1
  78. package/extension/src/page/trackedChangeReplay.js +41 -21
  79. package/extension/src/page/trackedChangesLifecycle.js +217 -199
  80. package/extension/src/page/treeOperations.js +7 -10
  81. package/extension/src/page/updateSafetyProbe.js +41 -0
  82. package/extension/src/page/writebackReceiptJournal.js +2 -0
  83. package/extension/src/page/writebackRouter.js +80 -79
  84. package/extension/src/pageBridge.js +19 -3
  85. package/extension/src/popup.js +62 -10
  86. package/extension/src/shared/agentTranscript.js +23 -0
  87. package/extension/src/shared/auditRecords.js +2 -0
  88. package/extension/src/shared/compatibility.js +22 -6
  89. package/extension/src/shared/compileAdapter.js +79 -57
  90. package/extension/src/shared/composerAttachmentDraft.js +151 -0
  91. package/extension/src/shared/directoryCleanupProof.js +43 -0
  92. package/extension/src/shared/failureReasons.js +309 -3
  93. package/extension/src/shared/globalPreferences.js +30 -13
  94. package/extension/src/shared/governanceRules.js +4 -37
  95. package/extension/src/shared/i18n.js +298 -2
  96. package/extension/src/shared/managedUpdateProjection.js +2 -2
  97. package/extension/src/shared/nativeRequestIdentity.js +1 -0
  98. package/extension/src/shared/pageRpcContract.js +5 -4
  99. package/extension/src/shared/projectFiles.js +21 -44
  100. package/extension/src/shared/providerProfiles.js +5 -3
  101. package/extension/src/shared/runInputQueue.js +48 -5
  102. package/extension/src/shared/selectionContext.js +12 -3
  103. package/extension/src/shared/sessionState.js +72 -27
  104. package/extension/src/shared/settlementFacts.js +10 -2
  105. package/extension/src/shared/sharedSessionBridge.js +37 -14
  106. package/extension/src/shared/sharedSessionTransfer.js +140 -0
  107. package/extension/src/shared/staleGuard.js +118 -13
  108. package/extension/src/shared/storageDb.js +58 -26
  109. package/extension/src/shared/storageMigration.js +71 -111
  110. package/extension/src/shared/storageRunActions.js +209 -26
  111. package/extension/src/shared/subagentActivityModel.js +1 -1
  112. package/extension/src/shared/undoOperations.js +136 -13
  113. package/extension/src/shared/updateConsent.js +6 -4
  114. package/extension/src/shared/updateRuntimeIdentity.js +1 -1
  115. package/extension/src/shared/writebackIntent.js +16 -0
  116. package/extension/src/shared/writebackOperationEvidence.js +4 -15
  117. package/extension/src/shared/writebackSettlement.js +38 -38
  118. package/extension/styles/panel.css +19 -5
  119. package/native-host/src/anthropicBridgeRequest.js +4 -2
  120. package/native-host/src/anthropicBridgeResponse.js +6 -2
  121. package/native-host/src/anthropicMessagesBridge.js +24 -16
  122. package/native-host/src/bootstrapMigration.js +296 -0
  123. package/native-host/src/bootstrapMigrationContract.js +101 -0
  124. package/native-host/src/bootstrapUpdate.js +233 -0
  125. package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
  126. package/native-host/src/chatBridgeResponse.js +10 -0
  127. package/native-host/src/chatCompletionsBridge.js +21 -15
  128. package/native-host/src/codexConfigPaths.js +106 -0
  129. package/native-host/src/codexHome.js +66 -73
  130. package/native-host/src/codexHomeScope.js +124 -0
  131. package/native-host/src/codexInheritedAuth.js +90 -0
  132. package/native-host/src/codexLocalSkills.js +301 -0
  133. package/native-host/src/codexPromptAssembly.js +31 -12
  134. package/native-host/src/codexProviderTest.js +24 -26
  135. package/native-host/src/codexSessionRunner.js +110 -183
  136. package/native-host/src/codexSessionTiming.js +18 -8
  137. package/native-host/src/codexSkillIsolation.js +170 -0
  138. package/native-host/src/codexThreadFork.js +19 -3
  139. package/native-host/src/index.js +19 -7
  140. package/native-host/src/managedInstall.js +110 -35
  141. package/native-host/src/managedInstallRecovery.js +140 -0
  142. package/native-host/src/managedRuntimeEvidence.js +57 -0
  143. package/native-host/src/mirrorWorkspace.js +92 -22
  144. package/native-host/src/nativeDoctor.js +15 -0
  145. package/native-host/src/nativeHelperLifecycle.js +21 -0
  146. package/native-host/src/nativeHostRegistration.js +33 -0
  147. package/native-host/src/nativeRunLifecycle.js +71 -0
  148. package/native-host/src/processStopBarrier.js +42 -7
  149. package/native-host/src/projectPathPlatform.js +30 -0
  150. package/native-host/src/providerHttp.js +80 -0
  151. package/native-host/src/providerProfile.js +4 -2
  152. package/native-host/src/providerReasoning.js +2 -7
  153. package/native-host/src/providerRuntime.js +1 -0
  154. package/native-host/src/providerStore.js +72 -14
  155. package/native-host/src/responsesPassthroughBridge.js +266 -52
  156. package/native-host/src/runtimeInstaller.js +21 -1
  157. package/native-host/src/selectionScope.js +36 -8
  158. package/native-host/src/subagentAdmission.js +26 -0
  159. package/native-host/src/subagentBroker.js +79 -66
  160. package/native-host/src/subagentTelemetry.js +19 -10
  161. package/native-host/src/subagentWorkspacePath.js +5 -2
  162. package/native-host/src/taskRunnerRuntime.js +77 -54
  163. package/native-host/src/tomlStatements.js +140 -0
  164. package/native-host/src/updateManager.js +297 -39
  165. package/native-host/src/writingStyleRuntime.js +57 -29
  166. package/native-host/src/writingStyleStore.js +73 -0
  167. package/package.json +1 -1
  168. package/scripts/install-managed.mjs +1 -0
@@ -42,6 +42,9 @@
42
42
  if (!/^[a-f0-9]{64}$/i.test(String(params.sha256 || ''))) {
43
43
  return failure('binary_asset_hash_invalid', 'Asset SHA-256 is missing or invalid.');
44
44
  }
45
+ if (params.overwrite === true && !/^[a-f0-9]{64}$/i.test(String(params.previousSha256 || ''))) {
46
+ return failure('binary_asset_baseline_missing', 'The original binary version is unavailable. Read the project again before replacing this file.');
47
+ }
45
48
  pruneReceipts();
46
49
  if (transfers.has(transferId) || receipts.has(transferId)) {
47
50
  return failure('binary_upload_transfer_conflict', 'Asset transfer ID is already in use.');
@@ -54,9 +57,11 @@
54
57
  path: projectPath,
55
58
  size,
56
59
  sha256: String(params.sha256).toLowerCase(),
60
+ previousSha256: params.overwrite === true ? String(params.previousSha256).toLowerCase() : '',
57
61
  mimeType: String(params.mimeType || 'application/octet-stream'),
58
62
  projectId: treeOperations.getProjectId(),
59
63
  overwrite: params.overwrite === true,
64
+ createdDirectories: [], folderChanged: false, fileUploadAttempted: false,
60
65
  chunks: [],
61
66
  received: 0
62
67
  });
@@ -96,11 +101,19 @@
96
101
  transfer.promise = commitOnce(params).catch(error => ({
97
102
  ...failure(error.code || 'binary_upload_failed', error.message),
98
103
  changedDocument: transfer.mutationAttempted === true
99
- })).then(result => {
100
- transfer.result = result;
104
+ })).then(async result => {
105
+ if (result.ok !== true && transfer.createdDirectories.length && typeof deps.cleanupCreatedDirectories === 'function') {
106
+ try {
107
+ const cleanup = await deps.cleanupCreatedDirectories(transfer.createdDirectories, {
108
+ projectId: transfer.projectId, isCurrent: () => treeOperations.getProjectId() === transfer.projectId && receipts.get(id) === transfer
109
+ });
110
+ transfer.createdDirectories = cleanup.remaining || transfer.createdDirectories;
111
+ } catch (_error) { /* Retain ownership proofs when cleanup cannot be confirmed. */ }
112
+ }
113
+ transfer.result = { ...result, ...directoryFacts(transfer, result.ok !== true) };
101
114
  transfer.finishedAt = Date.now();
102
115
  transfer.chunks = [];
103
- return result;
116
+ return transfer.result;
104
117
  });
105
118
  return transfer.promise;
106
119
  }
@@ -112,11 +125,12 @@
112
125
  || treeOperations.getProjectId() !== transfer.projectId) {
113
126
  return failure('binary_upload_receipt_missing', 'Asset upload receipt is unavailable for this project.');
114
127
  }
115
- if (transfer.result?.ok !== true && transfer.mutationAttempted === true) {
128
+ if (transfer.result?.ok !== true && transfer.fileUploadAttempted === true) {
116
129
  const evidence = await waitForPath(transfer, {}, 2500).catch(() => null);
117
130
  if (evidence?.hashVerified === true) {
118
131
  transfer.result = { ok: true, written: true, path: transfer.path,
119
- changedDocument: true, method: 'remote-hash-reconciliation' };
132
+ changedDocument: true, method: 'remote-hash-reconciliation', ...directoryFacts(transfer),
133
+ entityId: evidence.observed?.id || readTreeEntityId(evidence.observed), sha256: transfer.sha256 };
120
134
  }
121
135
  }
122
136
  return { ok: true, transferId: transfer.id, runProjectId: transfer.projectId,
@@ -156,17 +170,22 @@
156
170
  assertTransferProject(transfer);
157
171
  const overwriteVerified = hashVerified || result?.confirmedByTransport === true;
158
172
  if (result?.ok && observed && overwriteVerified) {
159
- return { ok: true, written: true, path: transfer.path, method: result.method, changedDocument: true };
173
+ return { ok: true, written: true, path: transfer.path, method: result.method, changedDocument: true,
174
+ entityId: observed.id || observed._id || observed.entityId || observed.fileId || '', sha256: transfer.sha256 };
160
175
  }
161
176
  if (result?.ok && observed && !overwriteVerified) errors.push('Overleaf did not expose enough evidence to verify the binary replacement.');
162
177
  if (result?.reason) errors.push(result.reason);
163
178
  } catch (error) {
179
+ if (error?.code?.startsWith('binary_asset_baseline_')) return {
180
+ ...failure(error.code, error.message), changedDocument: transfer.mutationAttempted === true
181
+ };
164
182
  errors.push(error.message);
165
183
  }
166
184
  // Once submission may have happened, changing transports can duplicate
167
185
  // or overwrite a successful upload. Reconcile the same attempt instead.
168
186
  if (transfer.mutationAttempted === true) {
169
- const evidence = await waitForPath(transfer, {}, 12000).catch(() => null);
187
+ const evidence = transfer.fileUploadAttempted === true
188
+ ? await waitForPath(transfer, {}, 12000).catch(() => null) : null;
170
189
  if (evidence?.hashVerified === true) {
171
190
  return { ok: true, written: true, path: transfer.path,
172
191
  method: 'remote-hash-reconciliation', changedDocument: true };
@@ -194,6 +213,12 @@
194
213
  }
195
214
  }
196
215
 
216
+ function directoryFacts(transfer, requireCleanup = false) {
217
+ return { createdDirectories: transfer.createdDirectories.map(proof => ({ ...proof })),
218
+ folderChanged: transfer.folderChanged === true, fileUploadAttempted: transfer.fileUploadAttempted === true,
219
+ directoryCleanupRequired: requireCleanup && transfer.createdDirectories.length > 0 };
220
+ }
221
+
197
222
  async function uploadWithMultipart(file, transfer) {
198
223
  const projectId = treeOperations.getProjectId();
199
224
  if (!projectId || typeof windowRef.fetch !== 'function') return { ok: false, reason: 'Project upload endpoint is unavailable.' };
@@ -208,7 +233,11 @@
208
233
  const headers = csrf ? { 'X-CSRF-Token': csrf } : {};
209
234
  const query = folderId ? `?folder_id=${encodeURIComponent(folderId)}` : '';
210
235
  assertTransferProject(transfer);
236
+ await assertBinaryBaseline(transfer);
237
+ if (parentPath && await findFolderId(parentPath) !== folderId) throw baselineError('The target folder changed before replacement.');
238
+ assertTransferProject(transfer);
211
239
  transfer.mutationAttempted = true;
240
+ transfer.fileUploadAttempted = true;
212
241
  const response = await windowRef.fetch(`/project/${encodeURIComponent(projectId)}/upload${query}`, {
213
242
  method: 'POST', body: form, credentials: 'same-origin', headers
214
243
  });
@@ -231,15 +260,24 @@
231
260
 
232
261
  async function uploadWithDom(file, transfer) {
233
262
  const parentPath = transfer.path.split('/').slice(0, -1).join('/');
234
- if (typeof deps.prepareUploadParent !== 'function') {
263
+ if (typeof deps.prepareUploadParent !== 'function' || typeof deps.isUploadParentCurrent !== 'function') {
235
264
  return { ok: false, reason: 'Verified folder selection is unavailable for asset upload.' };
236
265
  }
237
- await deps.prepareUploadParent(parentPath, {
266
+ transfer.uploadParent = await deps.prepareUploadParent(parentPath, {
238
267
  createMissing: true,
239
- onMutation: () => { transfer.mutationAttempted = true; },
268
+ onMutation: () => { transfer.mutationAttempted = true; transfer.folderChanged = true; },
269
+ onCreatedDirectory: proof => {
270
+ if (proof?.v !== 1 || proof.projectId !== transfer.projectId || typeof proof.path !== 'string'
271
+ || !transfer.path.startsWith(proof.path + '/')) return;
272
+ const next = { v: 1, projectId: proof.projectId, path: proof.path, nativeFolderId: String(proof.nativeFolderId || ''),
273
+ ownershipConfirmed: proof.ownershipConfirmed === true, observedAfterCreate: proof.observedAfterCreate === true };
274
+ const index = transfer.createdDirectories.findIndex(value => value.path === proof.path);
275
+ if (index >= 0) transfer.createdDirectories[index] = next; else transfer.createdDirectories.push(next);
276
+ },
240
277
  isCurrent: () => treeOperations.getProjectId() === transfer.projectId && transfers.get(transfer.id) === transfer
241
278
  });
242
279
  assertTransferProject(transfer);
280
+ transfer.uploadParentPath = parentPath;
243
281
  let input = findFileInput();
244
282
  if (!input) {
245
283
  const button = findUploadButton();
@@ -251,8 +289,13 @@
251
289
  const data = new windowRef.DataTransfer();
252
290
  data.items.add(file);
253
291
  assertTransferProject(transfer);
292
+ await assertBinaryBaseline(transfer);
293
+ assertTransferProject(transfer);
294
+ if (deps.isUploadParentCurrent(parentPath, transfer.uploadParent) !== true) throw baselineError('The selected upload folder changed before submission.');
295
+ if (input.isConnected === false || findFileInput() !== input) throw baselineError('The upload input changed before submission.');
254
296
  assignFilesToInput(input, data.files, windowRef);
255
297
  transfer.mutationAttempted = true;
298
+ transfer.fileUploadAttempted = true;
256
299
  input.dispatchEvent(new windowRef.Event('input', { bubbles: true }));
257
300
  input.dispatchEvent(new windowRef.Event('change', { bubbles: true }));
258
301
  await clickReplaceIfShown(transfer);
@@ -265,10 +308,14 @@
265
308
  for (const name of ['uploadFile', 'uploadAsset', 'createBinaryFile', 'createFile', 'addFile']) {
266
309
  if (typeof manager[name] !== 'function') continue;
267
310
  try {
311
+ await assertBinaryBaseline(transfer);
312
+ assertTransferProject(transfer);
313
+ if (treeOperations.findFileTreeManager() !== manager) throw baselineError('The upload manager changed before submission.');
268
314
  transfer.mutationAttempted = true;
315
+ transfer.fileUploadAttempted = true;
269
316
  await manager[name](transfer.path, file);
270
317
  return { ok: true, method: `fileTreeManager.${name}`, confirmedByTransport: true };
271
- } catch (error) { return { ok: false, reason: error.message }; }
318
+ } catch (error) { if (error?.code?.startsWith('binary_asset_baseline_')) throw error; return { ok: false, reason: error.message }; }
272
319
  }
273
320
  return { ok: false, reason: 'Legacy file-tree manager has no compatible asset method.' };
274
321
  }
@@ -322,9 +369,84 @@
322
369
  await delay(180);
323
370
  assertTransferProject(transfer);
324
371
  if (!transfer.overwrite) return;
325
- const button = Array.from(documentRef.querySelectorAll('[role="dialog"] button')).find(node => !isCodexOwnedNode(node)
326
- && /replace|overwrite|替换|覆盖/i.test(node.textContent || ''));
327
- if (button) button.click();
372
+ const candidates = () => Array.from(documentRef.querySelectorAll('[role="dialog"] button')).filter(node => !isCodexOwnedNode(node)
373
+ && /^(replace|overwrite|替换|覆盖)(?:\b|\s|$)/i.test(String(node.textContent || '').trim()));
374
+ const buttons = candidates();
375
+ if (!buttons.length) return;
376
+ const matchesTarget = node => {
377
+ const dialog = node.closest?.('[role="dialog"]');
378
+ const text = String(dialog?.textContent || '');
379
+ return [transfer.path, transfer.path.split('/').pop()].some(path =>
380
+ new RegExp('(^|[^\\w./-])' + path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '($|[^\\w./-])').test(text));
381
+ };
382
+ const intended = buttons.filter(matchesTarget);
383
+ if (intended.length !== 1) throw baselineError('The replacement dialog could not be bound to the target file.');
384
+ const button = intended[0], dialog = button.closest('[role="dialog"]');
385
+ await assertBinaryBaseline(transfer);
386
+ assertTransferProject(transfer);
387
+ if (deps.isUploadParentCurrent(transfer.uploadParentPath, transfer.uploadParent) !== true) throw baselineError('The selected upload folder changed before replacement confirmation.');
388
+ const current = candidates().filter(matchesTarget);
389
+ if (current.length !== 1 || current[0] !== button || button.isConnected === false
390
+ || button.closest('[role="dialog"]') !== dialog) throw baselineError('The replacement dialog changed before confirmation.');
391
+ button.click();
392
+ }
393
+ async function binaryIdentity(transfer) {
394
+ assertTransferProject(transfer);
395
+ const nativeRoot = documentRef.querySelector?.('[data-testid="file-tree-list-root"]');
396
+ // The generic adapter may resolve a root basename to another folder.
397
+ // Only the native ancestry or an exact metadata path can bind old bytes.
398
+ const node = nativeRoot ? findNativeTreeNode(transfer.path, 'file') : null;
399
+ let id = readTreeEntityId(node);
400
+ if (!id) {
401
+ const list = await snapshotRouter?.buildProjectFileList({ force: true, maxAgeMs: 0, preferLightweight: true, allowZipFallback: false });
402
+ const file = list?.files?.find(file => file.path === transfer.path);
403
+ id = file?.id || file?._id || file?.entityId || file?.fileId || '';
404
+ }
405
+ assertTransferProject(transfer);
406
+ return id;
407
+ }
408
+ async function assertBinaryBaseline(transfer) {
409
+ if (!transfer.overwrite) return;
410
+ const { id, digest } = await readBaselineDigest(transfer);
411
+ assertTransferProject(transfer);
412
+ if (digest !== transfer.previousSha256) throw baselineError('The binary file changed since Codex read it. Read the project again before replacing it.');
413
+ transfer.previousEntityId = id;
414
+ }
415
+ function baselineError(message) { return Object.assign(new Error(message), { code: 'binary_asset_baseline_changed' }); }
416
+ async function readBaselineDigest(transfer) {
417
+ const controller = new AbortController();
418
+ let timer, reader;
419
+ const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => {
420
+ controller.abort(); reader?.cancel().catch(() => {});
421
+ reject(baselineError('Reading the original binary file timed out.'));
422
+ }, 8000); });
423
+ try {
424
+ return await Promise.race([timeout, (async () => {
425
+ const id = await binaryIdentity(transfer);
426
+ if (controller.signal.aborted) throw baselineError('Reading the original binary file timed out.');
427
+ if (!id || (transfer.previousEntityId && transfer.previousEntityId !== id)) throw baselineError('The binary file identity changed before replacement.');
428
+ const response = await windowRef.fetch(`/project/${encodeURIComponent(transfer.projectId)}/file/${encodeURIComponent(id)}`, {
429
+ credentials: 'same-origin', cache: 'no-store', signal: controller.signal
430
+ });
431
+ if (!response.ok || Number(response.headers?.get?.('content-length')) > MAX_ASSET_BYTES) throw baselineError('The original binary file is unavailable or exceeds the read limit.');
432
+ let bytes;
433
+ if (response.body?.getReader) {
434
+ reader = response.body.getReader(); const chunks = []; let size = 0;
435
+ for (;;) { const chunk = await reader.read(); if (chunk.done) break;
436
+ size += chunk.value.byteLength;
437
+ if (size > MAX_ASSET_BYTES) throw baselineError('The original binary file exceeds the read limit.');
438
+ chunks.push(chunk.value);
439
+ }
440
+ bytes = joinChunks(chunks, size);
441
+ } else bytes = new Uint8Array(await response.arrayBuffer());
442
+ if (bytes.byteLength > MAX_ASSET_BYTES) throw baselineError('The original binary file exceeds the read limit.');
443
+ const digest = await sha256Hex(bytes, windowRef.crypto);
444
+ if (await binaryIdentity(transfer) !== id) throw baselineError('The binary file identity changed while its original bytes were read.');
445
+ assertTransferProject(transfer);
446
+ if (controller.signal.aborted) throw baselineError('Reading the original binary file timed out.');
447
+ return { id, digest };
448
+ })()]);
449
+ } finally { clearTimeout(timer); controller.abort(); reader?.cancel().catch(() => {}); }
328
450
  }
329
451
  function readTreeEntityId(node) {
330
452
  const owner = node?.closest?.('[data-entity-id], [data-file-id], [data-folder-id], [data-id]');
@@ -0,0 +1,256 @@
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory();
3
+ else root.CodexOverleafFileMover = factory();
4
+ })(typeof window !== 'undefined' ? window : globalThis, function () {
5
+ 'use strict';
6
+
7
+ function create(deps = {}) {
8
+ const { window, document, treeOperations, snapshotRouter, textFileCreator } = deps;
9
+ const normalizePath = deps.normalizeSafeProjectPath || window.CodexOverleafProjectFiles?.normalizeSafeProjectPath;
10
+ const delay = deps.delay || (ms => new Promise(resolve => window.setTimeout(resolve, ms)));
11
+ const error = (code, message) => Object.assign(new Error(message), { code });
12
+ const parent = path => path.split('/').slice(0, -1).join('/');
13
+ const basename = path => path.split('/').pop();
14
+ const join = (folder, name) => [folder, name].filter(Boolean).join('/');
15
+ function entities() {
16
+ treeOperations.invalidateDomProjectPathCache?.();
17
+ const root = document.querySelector('[data-testid="file-tree-list-root"], .file-tree-list[role="tree"]');
18
+ const unique = new Map();
19
+ for (const row of root?.querySelectorAll?.('[role="treeitem"]') || []) {
20
+ const entity = row.querySelector?.(':scope > .entity[data-file-id]')
21
+ || row.querySelector?.('[data-file-type][data-file-id]');
22
+ const id = entity?.dataset?.fileId || entity?.getAttribute?.('data-file-id');
23
+ const type = entity?.dataset?.fileType || entity?.getAttribute?.('data-file-type');
24
+ const path = normalizePath?.(treeOperations.readProjectPathFromNode?.(row));
25
+ if (id && path && ['doc', 'file', 'folder'].includes(type)) unique.set(type + ':' + id + ':' + path, { id, path, type });
26
+ }
27
+ return [...unique.values()];
28
+ }
29
+ function at(path, type) {
30
+ const found = entities().filter(entry => entry.path === path && (!type || entry.type === type));
31
+ return found.length === 1 ? found[0] : null;
32
+ }
33
+ function folderId(path, projectId) {
34
+ if (path) return at(path, 'folder')?.id || '';
35
+ for (const root of [window._ide, window.Overleaf, window.overleaf, window.OL]) {
36
+ const project = root?.project;
37
+ if ((project?._id || project?.id) !== projectId) continue;
38
+ const folder = Array.isArray(project.rootFolder) ? project.rootFolder[0] : project.rootFolder;
39
+ if (folder?._id || folder?.id) return folder._id || folder.id;
40
+ }
41
+ return '';
42
+ }
43
+ async function digest(file, kind) {
44
+ let bytes;
45
+ if (kind === 'text' && typeof file?.content === 'string') bytes = new TextEncoder().encode(file.content);
46
+ else if (kind === 'binary' && typeof file?.contentBase64 === 'string') {
47
+ bytes = Uint8Array.from((window.atob || globalThis.atob)(file.contentBase64), char => char.charCodeAt(0));
48
+ } else throw error('file_move_source_unavailable', 'The source contents could not be verified.');
49
+ const hash = await (window.crypto || globalThis.crypto).subtle.digest('SHA-256', bytes);
50
+ return [...new Uint8Array(hash)].map(byte => byte.toString(16).padStart(2, '0')).join('');
51
+ }
52
+ function csrfToken() {
53
+ const meta = document.querySelector('meta[name="ol-csrfToken"], meta[name="csrf-token"], meta[name="_csrf"]');
54
+ let value = meta ? meta.content : window.csrfToken || window._csrf;
55
+ if (['json', 'number'].includes(meta?.dataset?.type)) {
56
+ try { value = JSON.parse(value); } catch (_error) { return ''; }
57
+ }
58
+ if (meta?.dataset?.type === 'boolean') return '';
59
+ return typeof value === 'string' && value.length > 0 && value.length <= 4096 && !/[\0\r\n]/.test(value) ? value : '';
60
+ }
61
+
62
+ // Compatibility consumer for identity-bound moves recorded by older builds.
63
+ // New mirror diffs never produce metadata moves.
64
+ async function moveFile(operation, options = {}) {
65
+ const source = normalizePath?.(operation?.path), target = normalizePath?.(operation?.to);
66
+ const proof = operation?.moveProof;
67
+ const projectId = options.runProjectId;
68
+ const entity = value => typeof value === 'string' && value.length > 0 && value.length <= 160 && !/[\0\r\n]/.test(value);
69
+ if (options.noTraceUndo !== true || !entity(proof?.sourceEntityId) || !entity(proof?.targetEntityId)
70
+ || proof.sourceEntityId !== proof.targetEntityId) return { ok: false, code: 'file_tree_controls_unavailable', changedDocument: false,
71
+ reason: 'A recorded same-entity move is required before its location can be restored.' };
72
+ const deadline = Math.min(Date.now() + 90000, Number.isFinite(options.deadlineAt) ? options.deadlineAt : Infinity);
73
+ const createdDirectories = [], completed = [];
74
+ let entityId = '', entityType = '', attempted = false, currentPath = source;
75
+ const assertCurrent = () => {
76
+ if (!projectId || treeOperations.getProjectId() !== projectId) throw error('aborted_project_changed', 'The project changed during the file move.');
77
+ if (options.isCurrent?.() === false) throw error('codex_cancelled', 'The file move was cancelled.');
78
+ if (Date.now() >= deadline) throw error('file_move_deadline_exceeded', 'The file move verification deadline was reached.');
79
+ };
80
+ const inspectRemainingDirectories = async () => {
81
+ if (!Array.isArray(proof.createdDirectories) || !proof.createdDirectories.length) return {};
82
+ try {
83
+ const directoryCleanup = await textFileCreator?.cleanupCreatedDirectories?.(proof.createdDirectories, {
84
+ projectId, isCurrent: () => { assertCurrent(); return true; }
85
+ });
86
+ if (directoryCleanup) return { directoryCleanup };
87
+ } catch (_error) { /* The verified file move remains complete; only its directory inspection is unresolved. */ }
88
+ return { directoryCleanup: { ok: false, removed: [], remaining: proof.createdDirectories, requiresExplicitCleanup: true } };
89
+ };
90
+ const observedPath = () => {
91
+ if (treeOperations.getProjectId() !== projectId) return '';
92
+ const found = entities().filter(entry => entry.id === entityId && entry.type === entityType);
93
+ return found.length === 1 ? found[0].path : '';
94
+ };
95
+ const snapshot = async () => {
96
+ assertCurrent(); snapshotRouter.invalidateCache?.();
97
+ const result = await snapshotRouter.fetchProjectZipSnapshot({ force: true, maxAgeMs: 0, serverOnly: true,
98
+ includeContent: true, includeBinaryFiles: true, writebackVerification: true,
99
+ zipTimeoutMs: Math.max(1, Math.min(30000, deadline - Date.now())) });
100
+ assertCurrent();
101
+ const skipped = [...(result?.skipped || []), ...(result?.capabilities?.skipped || [])];
102
+ if (!result?.ok || !Array.isArray(result.files) || result.partialSnapshot === true
103
+ || result.pathIdentityComplete === false || result.capabilities?.pathIdentityComplete === false
104
+ || skipped.some(file => file?.unknownPath || ['zip_path_invalid', 'zip_path_duplicate'].includes(file?.reason))
105
+ || result.capabilities?.fullProjectSnapshot === false || (result.id && result.id !== projectId)) {
106
+ throw error('file_move_source_unavailable', 'A complete server source snapshot is required to move a file.');
107
+ }
108
+ return result;
109
+ };
110
+ const contains = (data, path) => data.files.some(file => file.path === path)
111
+ || [...(data.skipped || []), ...(data.capabilities?.skipped || [])].some(file => file.path === path);
112
+ const verify = async (path, data) => {
113
+ assertCurrent();
114
+ const entity = at(path);
115
+ if (!entity || !['doc', 'file'].includes(entity.type) || (entityId && entity.id !== entityId)
116
+ || (entityType && entity.type !== entityType) || (proof.entityType && entity.type !== proof.entityType)
117
+ || (proof.sourceEntityId && entity.id !== proof.sourceEntityId)) {
118
+ throw error('file_move_identity_changed', 'The exact source file identity changed or is unavailable.');
119
+ }
120
+ const file = data.files.find(file => file.path === path);
121
+ if (await digest(file, proof.kind) !== proof.sha256.toLowerCase()) {
122
+ throw error('stale_source_changed', 'The source file changed after Codex read it.');
123
+ }
124
+ assertCurrent();
125
+ if (at(path)?.id !== entity.id || at(path)?.type !== entity.type) throw error('file_move_identity_changed', 'The source file identity changed while checking its contents.');
126
+ return entity;
127
+ };
128
+ const checkStep = async step => {
129
+ const data = await snapshot(); await verify(step.from, data);
130
+ if (contains(data, step.to) || at(step.to)) throw error('file_move_target_exists', 'The destination or an intermediate move path is occupied.');
131
+ if (step.action === 'move' && folderId(parent(step.to), projectId) !== step.folderId) {
132
+ throw error('file_move_folder_changed', 'The destination folder identity changed.');
133
+ }
134
+ assertCurrent();
135
+ };
136
+ const confirmStep = async step => {
137
+ for (let i = 0; i < 8; i++) {
138
+ const data = await snapshot();
139
+ if (!contains(data, step.from) && contains(data, step.to) && at(step.to)?.id === entityId) {
140
+ await verify(step.to, data); currentPath = step.to; return true;
141
+ }
142
+ if (i < 7) await delay(Math.min(250 * (i + 1), 1000));
143
+ }
144
+ return false;
145
+ };
146
+ const send = async step => {
147
+ await checkStep(step);
148
+ const csrf = csrfToken();
149
+ if (!csrf) throw error('file_move_identity_unavailable', 'The page request identity is unavailable. Reload Overleaf before moving files.');
150
+ const Controller = window.AbortController || globalThis.AbortController;
151
+ const controller = new Controller();
152
+ const timer = window.setTimeout(() => controller.abort(), Math.max(1, deadline - Date.now()));
153
+ let response, transportError;
154
+ try {
155
+ assertCurrent(); attempted = true;
156
+ response = await window.fetch(`/project/${encodeURIComponent(projectId)}/${entityType}/${encodeURIComponent(entityId)}/${step.action}`, {
157
+ method: 'POST', credentials: 'same-origin', signal: controller.signal,
158
+ headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrf, Accept: 'application/json' },
159
+ body: JSON.stringify(step.action === 'rename' ? { name: basename(step.to) } : { folder_id: step.folderId })
160
+ });
161
+ } catch (caught) { transportError = caught; }
162
+ finally { window.clearTimeout(timer); }
163
+ assertCurrent();
164
+ if (await confirmStep(step)) return;
165
+ if (response && !response.ok && response.status >= 400 && response.status < 500 && response.status !== 408) {
166
+ const rejected = error('file_move_rejected', 'Overleaf rejected the file move.');
167
+ rejected.definitive = true; throw rejected;
168
+ }
169
+ const unknown = error('file_move_unconfirmed', transportError
170
+ ? 'The move response was lost. Check the file location before retrying.'
171
+ : 'The move result could not be confirmed. Check the file location before retrying.');
172
+ unknown.receiptUnconfirmed = true; throw unknown;
173
+ };
174
+ try {
175
+ assertCurrent();
176
+ if (!source || !target || source === target || operation.type !== 'move' || proof?.v !== 1
177
+ || !['text', 'binary'].includes(proof.kind) || !/^[a-f0-9]{64}$/i.test(proof.sha256 || '')) {
178
+ throw error('invalid_project_path', 'A verified source, different destination and content hash are required for a move.');
179
+ }
180
+ if (parent(source)) await textFileCreator?.prepareUploadParent?.(parent(source), { createMissing: false, isCurrent: () => { assertCurrent(); return true; } });
181
+ let data = await snapshot();
182
+ if (!contains(data, source) && proof.sourceEntityId && at(target)?.id === proof.sourceEntityId) {
183
+ entityId = proof.sourceEntityId; entityType = at(target).type;
184
+ await verify(target, data);
185
+ return { ok: true, verified: true, verification: 'overleaf-zip', idempotent: true, changedDocument: false,
186
+ method: 'overleaf.entity-move', moveProof: { ...proof, targetEntityId: entityId }, ...await inspectRemainingDirectories() };
187
+ }
188
+ const sourceEntity = await verify(source, data); entityId = sourceEntity.id; entityType = sourceEntity.type;
189
+ if (contains(data, target) || at(target)) throw error('file_move_target_exists', 'The move destination already exists.');
190
+ const originalParent = parent(source), destinationParent = parent(target);
191
+ const originalFolderId = originalParent === destinationParent ? '' : folderId(originalParent, projectId);
192
+ if (originalParent !== destinationParent && !originalFolderId) throw error('file_move_folder_changed', 'The original folder identity is unavailable.');
193
+ if (destinationParent && originalParent !== destinationParent && !folderId(destinationParent, projectId)) {
194
+ await textFileCreator?.prepareUploadParent?.(destinationParent, { createMissing: true, isCurrent: () => { assertCurrent(); return true; },
195
+ onCreatedDirectory: proof => createdDirectories.push(proof) });
196
+ }
197
+ const destinationFolderId = originalParent === destinationParent ? '' : folderId(destinationParent, projectId);
198
+ if (originalParent !== destinationParent && !destinationFolderId) throw error('file_move_folder_changed', 'The destination folder identity is unavailable.');
199
+ data = await snapshot(); await verify(source, data);
200
+ const steps = [];
201
+ const rename = to => { steps.push({ action: 'rename', from: currentPath, to }); currentPath = to; };
202
+ const move = to => { steps.push({ action: 'move', from: currentPath, to, folderId: destinationFolderId, fromFolderId: originalFolderId }); currentPath = to; };
203
+ if (originalParent === destinationParent) rename(target);
204
+ else if (basename(source) === basename(target)) move(target);
205
+ else if (!contains(data, join(originalParent, basename(target))) && !at(join(originalParent, basename(target)))) {
206
+ rename(join(originalParent, basename(target))); move(target);
207
+ } else if (!contains(data, join(destinationParent, basename(source))) && !at(join(destinationParent, basename(source)))) {
208
+ move(join(destinationParent, basename(source))); rename(target);
209
+ } else {
210
+ const token = window.crypto?.randomUUID?.().replace(/-/g, '').slice(0, 16);
211
+ if (!token) throw error('file_move_target_exists', 'An unoccupied intermediate name could not be reserved safely.');
212
+ const extension = basename(source).match(/\.[^.]+$/)?.[0] || '';
213
+ const temporary = 'codex-move-' + token + extension;
214
+ if ([originalParent, destinationParent].some(folder => contains(data, join(folder, temporary)) || at(join(folder, temporary)))) {
215
+ throw error('file_move_target_exists', 'The temporary move path is occupied. Retry on a fresh project snapshot.');
216
+ }
217
+ rename(join(originalParent, temporary)); move(join(destinationParent, temporary)); rename(target);
218
+ }
219
+ currentPath = source;
220
+ for (const step of steps) { await send(step); completed.push(step); }
221
+ return { ok: true, verified: true, verification: 'overleaf-zip', changedDocument: true, method: 'overleaf.entity-move',
222
+ moveProof: { v: 1, kind: proof.kind, sha256: proof.sha256.toLowerCase(), sourceEntityId: entityId, targetEntityId: entityId, entityType },
223
+ ...await inspectRemainingDirectories(),
224
+ ...(createdDirectories.length ? { createdDirectories } : {}) };
225
+ } catch (caught) {
226
+ let rolledBack = false;
227
+ // Only a definite refusal permits compensation. A lost response may
228
+ // still be in flight; never replay or reverse that uncertain mutation.
229
+ if (caught.definitive && completed.length && treeOperations.getProjectId() === projectId && options.isCurrent?.() !== false) {
230
+ try {
231
+ for (const prior of completed.slice().reverse()) {
232
+ await send({ action: prior.action, from: prior.to, to: prior.from, folderId: prior.fromFolderId });
233
+ }
234
+ rolledBack = true;
235
+ } catch (_rollbackError) { /* Preserve the verified entity and report its location for recovery. */ }
236
+ }
237
+ const observed = entityId ? observedPath() : '';
238
+ const changedDocument = attempted || createdDirectories.length > 0;
239
+ const reason = rolledBack ? 'The move was not completed; the original file location was restored.' : caught.message;
240
+ const moveRecovery = attempted && !rolledBack ? { v: 1, projectId, kind: proof?.kind, sha256: proof?.sha256,
241
+ entityId, entityType, sourcePath: source, targetPath: target, observedPath: observed } : null;
242
+ return { ok: false, code: rolledBack ? 'file_move_rolled_back' : caught.code || 'file_move_failed', reason,
243
+ changedDocument, rolledBack, receiptUnconfirmed: caught.receiptUnconfirmed === true,
244
+ ...(createdDirectories.length ? { createdDirectories } : {}),
245
+ ...(moveRecovery ? { moveRecovery } : {}),
246
+ failure: { code: caught.code || 'file_move_failed', stage: attempted ? 'verify' : 'preflight',
247
+ severity: changedDocument ? 'warning' : 'blocked', terminalState: changedDocument ? 'needs_review' : 'blocked',
248
+ changedDocument, retryable: false, file: observed || source,
249
+ ...(moveRecovery ? { evidence: { moveRecovery } } : {}),
250
+ userMessage: reason, nextAction: changedDocument ? 'Check the file location before retrying.' : 'Read the project again before retrying.' } };
251
+ }
252
+ }
253
+ return { moveFile };
254
+ }
255
+ return { create };
256
+ });
@@ -0,0 +1,94 @@
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory();
3
+ else root.CodexOverleafOwnedDirectoryCleanup = factory();
4
+ })(typeof window !== 'undefined' ? window : globalThis, function () {
5
+ 'use strict';
6
+ function create(deps = {}) {
7
+ const { document, treeOperations, delay, now, readServerSnapshot,
8
+ assertVerificationOwner, normalizeDomText, normalizeSafeProjectPath } = deps;
9
+ const { invalidateDomProjectPathCache } = treeOperations;
10
+ function folderIdentity(row) {
11
+ const entity = row?.querySelector?.('[data-file-type="folder"]');
12
+ return ['data-file-id', 'data-folder-id', 'data-entity-id']
13
+ .map(name => entity?.getAttribute?.(name) || row?.getAttribute?.(name) || '').find(Boolean) || '';
14
+ }
15
+ // Resolve every path component within its direct native child group. A
16
+ // collapsed/ambiguous tree is unknown, never proof that a folder is absent.
17
+ function locateOwnedFolder(path) {
18
+ let group = document.querySelector('[data-testid="file-tree-list-root"], .file-tree-list[role="tree"]');
19
+ const parts = path.split('/');
20
+ for (let index = 0; index < parts.length; index++) {
21
+ if (!group?.children) throw new Error('The native folder tree is unavailable.');
22
+ const inner = Array.from(group.children).find(node => node.classList?.contains('file-tree-folder-list-inner')) || group;
23
+ const matches = Array.from(inner.children).filter(node => node.getAttribute?.('role') === 'treeitem'
24
+ && node.querySelector?.('[data-file-type="folder"]') && normalizeDomText(node.getAttribute('aria-label')) === parts[index]);
25
+ if (matches.length > 1) throw new Error('The folder path is ambiguous.');
26
+ if (!matches.length) return null;
27
+ const row = matches[0];
28
+ if (index === parts.length - 1) return row;
29
+ if (row.getAttribute('aria-expanded') !== 'true') throw new Error('The ancestor folder must be expanded before cleanup.');
30
+ const sibling = row.nextElementSibling;
31
+ group = sibling?.getAttribute('role') === 'tree' ? sibling : row.querySelector('[role="tree"]');
32
+ }
33
+ return null;
34
+ }
35
+ // Native folder Delete can remove a subtree. Read/empty checks cannot
36
+ // make that mutation conditional on the subtree staying empty. Retain the
37
+ // proof for explicit native cleanup; never click Delete automatically.
38
+ async function cleanupCreatedDirectories(proofs, options = {}) {
39
+ const items = Array.isArray(proofs) ? proofs : [];
40
+ const removed = [], completed = new Set(), failures = [], review = [];
41
+ for (const proof of items.slice().sort((a, b) => String(b?.path || '').split('/').length - String(a?.path || '').split('/').length)) {
42
+ try {
43
+ const path = normalizeSafeProjectPath(proof?.path);
44
+ if (proof?.v !== 1 || !path || path !== proof.path || !proof.projectId || !proof.nativeFolderId
45
+ || (options.projectId && proof.projectId !== options.projectId)) throw new Error('The created folder identity is not proven.');
46
+ const assertCurrent = () => assertVerificationOwner(proof.projectId, options.isCurrent);
47
+ const exactRow = () => {
48
+ const row = locateOwnedFolder(path);
49
+ if (row && folderIdentity(row) !== proof.nativeFolderId) throw new Error('The created folder identity changed.');
50
+ return row;
51
+ };
52
+ assertCurrent();
53
+ for (let depth = 1; depth <= path.split('/').length; depth++) {
54
+ const prefix = path.split('/').slice(0, depth).join('/'), row = locateOwnedFolder(prefix);
55
+ if (!row) break;
56
+ if (prefix === path && folderIdentity(row) !== proof.nativeFolderId) throw new Error('The created folder identity changed.');
57
+ if (row.getAttribute('aria-expanded') === 'true') continue;
58
+ const toggle = row.querySelector('.folder-expand-collapse-button');
59
+ if (!toggle) throw new Error('The folder cannot be expanded safely.');
60
+ assertCurrent(); toggle.click();
61
+ const deadline = now() + 5000;
62
+ while (locateOwnedFolder(prefix)?.getAttribute('aria-expanded') !== 'true') {
63
+ assertCurrent(); if (now() >= deadline) throw new Error('The folder expansion was not confirmed.');
64
+ await delay(100); invalidateDomProjectPathCache();
65
+ }
66
+ }
67
+ exactRow();
68
+ const snapshot = await readServerSnapshot({ projectId: proof.projectId, isCurrent: options.isCurrent, includeBinaryFiles: true });
69
+ assertCurrent();
70
+ const row = exactRow();
71
+ const entries = [...snapshot.files, ...(snapshot.skipped || []), ...(snapshot.capabilities?.skipped || [])];
72
+ const serverHasEntries = entries.some(entry => String(entry?.path || '').startsWith(path + '/'));
73
+ if (!row) {
74
+ if (serverHasEntries) throw new Error('Server entries remain under the folder path; absence is unconfirmed.');
75
+ completed.add(proof); removed.push({ ...proof, alreadyAbsent: true, verification: 'overleaf-native-tree-and-zip' }); continue;
76
+ }
77
+ const sibling = row.nextElementSibling;
78
+ const group = sibling?.getAttribute('role') === 'tree' ? sibling : row.querySelector('[role="tree"]');
79
+ if (row.getAttribute('aria-expanded') !== 'true' || !group?.children) throw new Error('The folder contents are unknown.');
80
+ const inner = Array.from(group.children).find(node => node.classList?.contains('file-tree-folder-list-inner')) || group;
81
+ const empty = inner.children.length === 0 && !serverHasEntries;
82
+ review.push({ ...proof, empty, requiresExplicitCleanup: true });
83
+ failures.push({ path, code: empty ? 'directory_requires_explicit_cleanup' : 'created_directory_not_empty',
84
+ reason: empty ? 'The new empty folder remains. Review and remove it through Overleaf if appropriate.'
85
+ : 'The created folder contains entries or unread files and was left untouched.' });
86
+ } catch (error) { failures.push({ path: proof?.path || '', code: error?.code || 'created_directory_cleanup_unconfirmed', reason: error?.message || '' }); }
87
+ }
88
+ return { ok: completed.size === items.length, removed, remaining: items.filter(item => !completed.has(item)),
89
+ failures, review, requiresExplicitCleanup: review.length > 0, changedDocument: false };
90
+ }
91
+ return { folderIdentity, cleanupCreatedDirectories };
92
+ }
93
+ return { create };
94
+ });
@@ -113,6 +113,7 @@
113
113
  }, { projectId, signal: controller.signal });
114
114
  if (controller.signal.aborted) throw failure('reference_read_cancelled', 'Reference reading was cancelled.');
115
115
  if (!snapshot?.ok) throw failure('reference_project_unavailable', snapshot?.reason || 'The reference project could not be read.');
116
+ if (snapshot.pathIdentityComplete === false) throw failure('reference_project_incomplete', 'Some ZIP file identities could not be read unambiguously.');
116
117
  const files = (snapshot.files || []).filter(file => /\.tex$/i.test(file.path || ''));
117
118
  if (!files.length) throw failure('reference_project_empty', 'This project contains no readable TeX files.');
118
119
  if (files.length > 1000 || files.some(file => typeof file.content !== 'string')) {