codex-overleaf-link 2.5.0 → 2.6.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 (189) hide show
  1. package/README.md +155 -392
  2. package/README.zh-CN.md +154 -393
  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 +283 -55
  9. package/extension/src/backgroundBootstrapMigration.js +181 -0
  10. package/extension/src/backgroundSharedSessions.js +129 -16
  11. package/extension/src/backgroundUpdateCoordinator.js +242 -29
  12. package/extension/src/backgroundUpdateSafety.js +173 -0
  13. package/extension/src/content/activeTurnControl.js +171 -8
  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 +104 -47
  17. package/extension/src/content/composerPanel.js +110 -12
  18. package/extension/src/content/contentRuntime.js +1555 -926
  19. package/extension/src/content/contextTray.js +8 -2
  20. package/extension/src/content/contextUsageView.js +267 -0
  21. package/extension/src/content/diagnosticsController.js +122 -96
  22. package/extension/src/content/diagnosticsExport.js +19 -5
  23. package/extension/src/content/diagnosticsPanel.js +48 -0
  24. package/extension/src/content/directoryCleanupRecovery.js +54 -0
  25. package/extension/src/content/generated/content.bundle.js +11077 -3412
  26. package/extension/src/content/generated/content.bundle.meta.json +14 -3
  27. package/extension/src/content/globalPreferencesController.js +1 -1
  28. package/extension/src/content/legacyGlobalRegistry.js +10 -0
  29. package/extension/src/content/localSkillsPanel.js +104 -14
  30. package/extension/src/content/markdownDomRenderer.js +5 -3
  31. package/extension/src/content/markdownText.js +47 -11
  32. package/extension/src/content/modelPicker.js +8 -4
  33. package/extension/src/content/nativeCompatibilityController.js +74 -30
  34. package/extension/src/content/otWarmMirror.js +5 -6
  35. package/extension/src/content/otWarmMirrorController.js +17 -20
  36. package/extension/src/content/pageBridgeClient.js +17 -4
  37. package/extension/src/content/panelMaintenance.js +46 -14
  38. package/extension/src/content/panelRenderer.js +57 -27
  39. package/extension/src/content/pendingInputView.js +1 -1
  40. package/extension/src/content/projectProviderSelection.js +13 -4
  41. package/extension/src/content/projectSessionCleanup.js +109 -62
  42. package/extension/src/content/projectSettingsCoordinator.js +67 -6
  43. package/extension/src/content/providerSettingsCoordinator.js +46 -8
  44. package/extension/src/content/providerSettingsDialog.js +73 -14
  45. package/extension/src/content/recentProjects.js +15 -8
  46. package/extension/src/content/runActivitySummary.js +160 -18
  47. package/extension/src/content/runCompletionMeta.js +532 -0
  48. package/extension/src/content/runController.js +39 -15
  49. package/extension/src/content/runFailureNotice.js +29 -13
  50. package/extension/src/content/runGuidanceController.js +72 -27
  51. package/extension/src/content/runGuidanceView.js +59 -10
  52. package/extension/src/content/runPresence.js +18 -8
  53. package/extension/src/content/runQueueScheduler.js +157 -47
  54. package/extension/src/content/runRecoveryActions.js +147 -0
  55. package/extension/src/content/runResultActions.js +28 -143
  56. package/extension/src/content/runReviewController.js +633 -0
  57. package/extension/src/content/runScrollLayout.js +21 -2
  58. package/extension/src/content/runTimelineView.js +367 -175
  59. package/extension/src/content/scopedPersistencePanelState.js +134 -20
  60. package/extension/src/content/selectionContextView.js +1 -1
  61. package/extension/src/content/sensitivePreflight.js +153 -0
  62. package/extension/src/content/sessionForkController.js +80 -17
  63. package/extension/src/content/sessionManager.js +50 -4
  64. package/extension/src/content/sessionMenuView.js +54 -18
  65. package/extension/src/content/sessionPersistence.js +25 -5
  66. package/extension/src/content/settingsPanel.js +22 -5
  67. package/extension/src/content/settingsWorkbench.js +132 -89
  68. package/extension/src/content/sharedSessionViewSync.js +49 -11
  69. package/extension/src/content/subagentActivityView.js +23 -7
  70. package/extension/src/content/updateIdle.js +4 -1
  71. package/extension/src/content/updateNotice.js +118 -12
  72. package/extension/src/content/updateSafetyReceiver.js +58 -0
  73. package/extension/src/content/writebackController.js +53 -4
  74. package/extension/src/content/writebackOrchestrator.js +116 -94
  75. package/extension/src/content/writingStyleSettings.js +35 -11
  76. package/extension/src/page/binaryAssetUploader.js +136 -14
  77. package/extension/src/page/compileBridge.js +193 -180
  78. package/extension/src/page/fileMover.js +256 -0
  79. package/extension/src/page/overleafCapabilities.js +4 -1
  80. package/extension/src/page/ownedDirectoryCleanup.js +94 -0
  81. package/extension/src/page/referenceProjects.js +1 -0
  82. package/extension/src/page/reviewContext.js +113 -0
  83. package/extension/src/page/saveState.js +6 -4
  84. package/extension/src/page/snapshotRouter.js +84 -39
  85. package/extension/src/page/textCoordinates.js +17 -1
  86. package/extension/src/page/textFileCreator.js +114 -26
  87. package/extension/src/page/trackedChangeCapture.js +12 -2
  88. package/extension/src/page/trackedChangeOwnership.js +19 -1
  89. package/extension/src/page/trackedChangeReplay.js +41 -21
  90. package/extension/src/page/trackedChangesLifecycle.js +217 -199
  91. package/extension/src/page/treeOperations.js +7 -10
  92. package/extension/src/page/updateSafetyProbe.js +41 -0
  93. package/extension/src/page/writebackReceiptJournal.js +2 -0
  94. package/extension/src/page/writebackRouter.js +103 -155
  95. package/extension/src/pageBridge.js +126 -69
  96. package/extension/src/popup.js +62 -10
  97. package/extension/src/shared/agentTranscript.js +373 -17
  98. package/extension/src/shared/auditRecords.js +4 -0
  99. package/extension/src/shared/compatibility.js +22 -6
  100. package/extension/src/shared/compileAdapter.js +129 -66
  101. package/extension/src/shared/composerAttachmentDraft.js +156 -0
  102. package/extension/src/shared/contextUsage.js +125 -0
  103. package/extension/src/shared/directoryCleanupProof.js +43 -0
  104. package/extension/src/shared/failureReasons.js +349 -4
  105. package/extension/src/shared/globalPreferences.js +30 -13
  106. package/extension/src/shared/governanceRules.js +4 -37
  107. package/extension/src/shared/i18n.js +590 -24
  108. package/extension/src/shared/lineReferences.js +38 -31
  109. package/extension/src/shared/managedUpdateProjection.js +2 -2
  110. package/extension/src/shared/nativeRequestIdentity.js +1 -0
  111. package/extension/src/shared/pageRpcContract.js +5 -4
  112. package/extension/src/shared/panelIcons.js +39 -0
  113. package/extension/src/shared/projectFiles.js +21 -44
  114. package/extension/src/shared/providerProfiles.js +5 -3
  115. package/extension/src/shared/reviewing.js +130 -1
  116. package/extension/src/shared/runActivityModel.js +70 -4
  117. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  118. package/extension/src/shared/runInputQueue.js +48 -5
  119. package/extension/src/shared/selectionContext.js +12 -3
  120. package/extension/src/shared/sessionState.js +132 -28
  121. package/extension/src/shared/sessionTitle.js +14 -1
  122. package/extension/src/shared/settlementFacts.js +10 -2
  123. package/extension/src/shared/sharedSessionBridge.js +118 -27
  124. package/extension/src/shared/sharedSessionTransfer.js +140 -0
  125. package/extension/src/shared/staleGuard.js +166 -52
  126. package/extension/src/shared/storageDb.js +90 -26
  127. package/extension/src/shared/storageMigration.js +78 -112
  128. package/extension/src/shared/storageRunActions.js +267 -30
  129. package/extension/src/shared/subagentActivityModel.js +1 -1
  130. package/extension/src/shared/undoOperations.js +253 -32
  131. package/extension/src/shared/updateConsent.js +6 -4
  132. package/extension/src/shared/updateRuntimeIdentity.js +17 -3
  133. package/extension/src/shared/writebackIntent.js +16 -0
  134. package/extension/src/shared/writebackOperationEvidence.js +4 -15
  135. package/extension/src/shared/writebackSettlement.js +58 -40
  136. package/extension/styles/panel.css +1123 -493
  137. package/native-host/src/anthropicBridgeRequest.js +4 -2
  138. package/native-host/src/anthropicBridgeResponse.js +6 -2
  139. package/native-host/src/anthropicMessagesBridge.js +24 -16
  140. package/native-host/src/bootstrapMigration.js +296 -0
  141. package/native-host/src/bootstrapMigrationContract.js +101 -0
  142. package/native-host/src/bootstrapUpdate.js +269 -0
  143. package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
  144. package/native-host/src/chatBridgeRequest.js +60 -6
  145. package/native-host/src/chatBridgeResponse.js +20 -1
  146. package/native-host/src/chatCompletionsBridge.js +48 -15
  147. package/native-host/src/codexConfigPaths.js +106 -0
  148. package/native-host/src/codexHome.js +66 -73
  149. package/native-host/src/codexHomeScope.js +124 -0
  150. package/native-host/src/codexInheritedAuth.js +90 -0
  151. package/native-host/src/codexLocalSkills.js +301 -0
  152. package/native-host/src/codexPromptAssembly.js +114 -127
  153. package/native-host/src/codexProviderTest.js +24 -26
  154. package/native-host/src/codexSessionRunner.js +234 -198
  155. package/native-host/src/codexSessionTiming.js +18 -8
  156. package/native-host/src/codexSkillIsolation.js +170 -0
  157. package/native-host/src/codexThreadFork.js +19 -3
  158. package/native-host/src/index.js +19 -7
  159. package/native-host/src/managedInstall.js +110 -35
  160. package/native-host/src/managedInstallRecovery.js +140 -0
  161. package/native-host/src/managedRuntimeEvidence.js +57 -0
  162. package/native-host/src/mirrorWorkspace.js +92 -22
  163. package/native-host/src/nativeDoctor.js +15 -0
  164. package/native-host/src/nativeHelperLifecycle.js +21 -0
  165. package/native-host/src/nativeHostRegistration.js +33 -0
  166. package/native-host/src/nativeRunLifecycle.js +71 -0
  167. package/native-host/src/processStopBarrier.js +42 -7
  168. package/native-host/src/projectPathPlatform.js +30 -0
  169. package/native-host/src/providerContextError.js +20 -0
  170. package/native-host/src/providerHttp.js +80 -0
  171. package/native-host/src/providerProfile.js +4 -2
  172. package/native-host/src/providerReasoning.js +2 -7
  173. package/native-host/src/providerRuntime.js +1 -0
  174. package/native-host/src/providerStore.js +72 -14
  175. package/native-host/src/responsesPassthroughBridge.js +266 -52
  176. package/native-host/src/runtimeInstaller.js +21 -1
  177. package/native-host/src/selectionScope.js +36 -8
  178. package/native-host/src/subagentAdmission.js +26 -0
  179. package/native-host/src/subagentBroker.js +79 -66
  180. package/native-host/src/subagentTelemetry.js +19 -10
  181. package/native-host/src/subagentWorkspacePath.js +5 -2
  182. package/native-host/src/taskRunnerRuntime.js +92 -57
  183. package/native-host/src/textPatch.js +473 -377
  184. package/native-host/src/tomlStatements.js +140 -0
  185. package/native-host/src/updateManager.js +409 -46
  186. package/native-host/src/writingStyleRuntime.js +57 -29
  187. package/native-host/src/writingStyleStore.js +73 -0
  188. package/package.json +5 -8
  189. package/scripts/install-managed.mjs +1 -0
@@ -97,9 +97,10 @@
97
97
  };
98
98
  }),
99
99
  capabilities: {
100
- fullProjectSnapshot: true,
100
+ fullProjectSnapshot: zipSnapshot.pathIdentityComplete !== false,
101
+ pathIdentityComplete: zipSnapshot.pathIdentityComplete,
101
102
  method: 'overleaf-zip-file-list',
102
- skipped: [],
103
+ skipped: zipSnapshot.skipped || [],
103
104
  diagnostics: {
104
105
  zipEndpoint: zipSnapshot.endpoint,
105
106
  zipTiming: zipSnapshot.diagnostics,
@@ -243,7 +244,7 @@
243
244
  if (zipSnapshot.ok && zipSnapshot.files.length) {
244
245
  const mergedFiles = mergeSnapshotFiles(
245
246
  zipSnapshot.files,
246
- filterZipSnapshotOverlayFiles(zipSnapshot.files, lightweightSnapshot?.files || [])
247
+ filterZipSnapshotOverlayFiles(zipSnapshot.files, lightweightSnapshot?.files || [], zipSnapshot.skipped)
247
248
  );
248
249
  const files = filterRequestedSnapshotFiles(mergedFiles, params, activePath);
249
250
  const requestedPathsComplete = params.restrictToRequestedPathsOnly === true
@@ -255,7 +256,9 @@
255
256
  activePath,
256
257
  files,
257
258
  capabilities: {
258
- fullProjectSnapshot: params.restrictToRequestedPathsOnly === true ? false : true,
259
+ fullProjectSnapshot: params.restrictToRequestedPathsOnly !== true && zipSnapshot.pathIdentityComplete !== false,
260
+ pathIdentityComplete: zipSnapshot.pathIdentityComplete,
261
+ contentComplete: !(zipSnapshot.skipped || []).length,
259
262
  requestedPathsComplete,
260
263
  method: 'overleaf-zip',
261
264
  skipped: zipSnapshot.skipped || [],
@@ -316,7 +319,8 @@
316
319
  80
317
320
  );
318
321
  const files = [];
319
- const skipped = [];
322
+ const skipped = [...(zipSnapshot?.skipped || [])];
323
+ const ambiguousPaths = new Set(skipped.filter(file => file.reason === 'zip_path_duplicate').map(file => file.path));
320
324
  if (zipSnapshot && !zipSnapshot.ok) {
321
325
  skipped.push({
322
326
  path: 'project.zip',
@@ -326,6 +330,7 @@
326
330
  let previousSignature = '';
327
331
 
328
332
  for (const filePath of projectPaths) {
333
+ if (ambiguousPaths.has(filePath)) continue;
329
334
  if (filePath === snapshotActivePath) {
330
335
  const ready = await waitForActiveEditorText(filePath, 5000);
331
336
  if (!ready.ok) {
@@ -404,7 +409,7 @@
404
409
  await waitForActiveEditorText(activePath, 5000);
405
410
  }
406
411
 
407
- if (snapshotActivePath && !files.length && (!lightweightOnly || params.allowZipFallback === false)) {
412
+ if (snapshotActivePath && !ambiguousPaths.has(snapshotActivePath) && !files.length && (!lightweightOnly || params.allowZipFallback === false)) {
408
413
  files.push({
409
414
  path: snapshotActivePath,
410
415
  content: readActiveEditorText(),
@@ -419,7 +424,8 @@
419
424
  activePath: snapshotActivePath,
420
425
  files,
421
426
  capabilities: {
422
- fullProjectSnapshot: params.restrictToRequestedPathsOnly === true ? false : requestedPathsComplete,
427
+ fullProjectSnapshot: params.restrictToRequestedPathsOnly !== true && requestedPathsComplete && zipSnapshot?.pathIdentityComplete !== false,
428
+ pathIdentityComplete: zipSnapshot?.pathIdentityComplete,
423
429
  requestedPathsComplete,
424
430
  method: resolvePageSnapshotMethod(files, lightweightOnly),
425
431
  skipped,
@@ -438,15 +444,14 @@
438
444
 
439
445
  async function buildZipOnlyProjectSnapshot(params = {}) {
440
446
  const activePath = getPassiveActiveFilePath();
441
- const activeText = params.serverOnly === true ? '' : activePath ? readActiveEditorText() : '';
447
+ // Capture read evidence together with the path/text before awaiting ZIP.
448
+ // A later editor identity cannot prove that an earlier empty read succeeded.
449
+ const activeOverlay = params.serverOnly === true ? [] : readActiveEditorOverlay(activePath);
442
450
 
443
451
  const zipSnapshot = await fetchProjectZipSnapshot(params);
444
452
  if (zipSnapshot.ok && zipSnapshot.files.length) {
445
- const activeOverlay = params.serverOnly === true ? [] : buildActiveEditorOverlay(activePath, activeText, {
446
- allowedPaths: zipSnapshot.files.map(file => file.path)
447
- });
448
453
  const files = filterRequestedSnapshotFiles(
449
- mergeSnapshotFiles(zipSnapshot.files, activeOverlay),
454
+ mergeSnapshotFiles(zipSnapshot.files, filterZipSnapshotOverlayFiles(zipSnapshot.files, activeOverlay, zipSnapshot.skipped)),
450
455
  params,
451
456
  activePath
452
457
  );
@@ -459,7 +464,9 @@
459
464
  activePath,
460
465
  files,
461
466
  capabilities: {
462
- fullProjectSnapshot: params.restrictToRequestedPathsOnly === true ? false : true,
467
+ fullProjectSnapshot: params.restrictToRequestedPathsOnly !== true && zipSnapshot.pathIdentityComplete !== false,
468
+ pathIdentityComplete: zipSnapshot.pathIdentityComplete,
469
+ contentComplete: !(zipSnapshot.skipped || []).length,
463
470
  requestedPathsComplete,
464
471
  method: 'overleaf-zip',
465
472
  skipped: zipSnapshot.skipped || [],
@@ -478,16 +485,18 @@
478
485
  id: getProjectId(),
479
486
  url: window.location.href,
480
487
  activePath,
481
- files: params.serverOnly === true ? [] : buildActiveEditorOverlay(activePath, activeText),
488
+ files: filterZipSnapshotOverlayFiles([], activeOverlay, zipSnapshot.skipped, true),
482
489
  capabilities: {
483
490
  fullProjectSnapshot: false,
491
+ pathIdentityComplete: zipSnapshot.pathIdentityComplete,
484
492
  method: params.serverOnly === true ? 'overleaf-zip-unavailable'
485
- : activePath && window.CodexOverleafProjectFiles.isUsableProjectFileContent(activeText)
493
+ : activeOverlay.length
486
494
  ? 'active-editor-zip-only-fallback'
487
495
  : 'zip-only-fallback-empty',
488
- skipped: [{
496
+ contentComplete: false,
497
+ skipped: [...(zipSnapshot.skipped || []), {
489
498
  path: 'project.zip',
490
- reason: zipSnapshot.reason
499
+ reason: zipSnapshot.reason || 'ZIP file identities were incomplete'
491
500
  }],
492
501
  diagnostics: {
493
502
  docRecordCount: 0,
@@ -500,9 +509,11 @@
500
509
  };
501
510
  }
502
511
 
503
- function filterZipSnapshotOverlayFiles(zipFiles = [], overlayFiles = []) {
512
+ function filterZipSnapshotOverlayFiles(zipFiles = [], overlayFiles = [], skipped = [], allowMissing = false) {
504
513
  const zipPaths = new Set((zipFiles || []).map(file => file.path).filter(Boolean));
505
- return (overlayFiles || []).filter(file => file?.source !== 'active-editor' || zipPaths.has(file.path));
514
+ const ambiguousPaths = new Set((skipped || []).filter(file => file.reason === 'zip_path_duplicate').map(file => file.path));
515
+ return (overlayFiles || []).filter(file => !ambiguousPaths.has(file?.path)
516
+ && (allowMissing || file?.source !== 'active-editor' || zipPaths.has(file.path)));
506
517
  }
507
518
 
508
519
  function getRequestedSnapshotPaths(params = {}, activePath = '') {
@@ -520,19 +531,32 @@
520
531
  return (files || []).filter(file => requestedSet.has(file?.path));
521
532
  }
522
533
 
523
- function buildActiveEditorOverlay(activePath, activeText, options = {}) {
524
- if (!activePath || !window.CodexOverleafProjectFiles.isUsableProjectFileContent(activeText)) {
525
- return [];
526
- }
527
- if (Array.isArray(options.allowedPaths) && !options.allowedPaths.includes(activePath)) {
534
+ function readActiveEditorOverlay(activePath) {
535
+ if (!activePath) return [];
536
+ try {
537
+ const content = readActiveEditorText();
538
+ if (typeof content !== 'string') return [];
539
+ if (!content.trim()) {
540
+ // The legacy reader also returns '' when no surface exists. A readable
541
+ // identity must independently confirm blank text before it can erase ZIP.
542
+ const identity = deps.getActiveEditorIdentity?.();
543
+ let observed;
544
+ if (identity?.type === 'codemirror-view' && typeof identity.doc?.toString === 'function') {
545
+ observed = identity.doc.toString();
546
+ } else if (identity?.type === 'textarea') {
547
+ observed = identity.node?.value;
548
+ } else if (identity?.type === 'contenteditable') {
549
+ const node = identity.node;
550
+ if (typeof node?.innerText === 'string' || typeof node?.textContent === 'string') {
551
+ observed = node.innerText || node.textContent || '';
552
+ }
553
+ }
554
+ if (typeof observed !== 'string' || observed !== content) return [];
555
+ } else if (!window.CodexOverleafProjectFiles.isUsableProjectFileContent(content)) return [];
556
+ return [{ path: activePath, content, source: 'active-editor', kind: 'text' }];
557
+ } catch (_error) {
528
558
  return [];
529
559
  }
530
- return [{
531
- path: activePath,
532
- content: activeText,
533
- source: 'active-editor',
534
- kind: 'text'
535
- }];
536
560
  }
537
561
 
538
562
  function mergeSnapshotFiles(primaryFiles = [], overrideFiles = []) {
@@ -541,7 +565,7 @@
541
565
  if (!file?.path || file.kind === 'binary' || typeof file.content !== 'string') {
542
566
  continue;
543
567
  }
544
- if (!window.CodexOverleafProjectFiles.isUsableProjectFileContent(file.content)) {
568
+ if (file.content.trim() && !window.CodexOverleafProjectFiles.isUsableProjectFileContent(file.content)) {
545
569
  continue;
546
570
  }
547
571
  filesByPath.set(file.path, {
@@ -691,7 +715,7 @@
691
715
  });
692
716
  const extractMs = Date.now() - extractStartedAt;
693
717
  const files = extracted.files || [];
694
- if (!files.length) {
718
+ if (!files.length && extracted.pathIdentityComplete !== false) {
695
719
  attempts.push({
696
720
  endpoint,
697
721
  status: response.status,
@@ -715,6 +739,7 @@
715
739
  return {
716
740
  ok: true,
717
741
  endpoint,
742
+ pathIdentityComplete: extracted.pathIdentityComplete,
718
743
  skipped: extracted.skipped || [],
719
744
  files: files.map(file => ({
720
745
  ...file,
@@ -841,6 +866,9 @@
841
866
  const entryCount = view.getUint16(eocdOffset + 10, true);
842
867
  const centralDirectoryOffset = view.getUint32(eocdOffset + 16, true);
843
868
  const decoder = new TextDecoder('utf-8');
869
+ const nameDecoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
870
+ const seenPaths = new Set();
871
+ let pathIdentityComplete = true;
844
872
  const files = [];
845
873
  const skipped = [];
846
874
  let binaryBytes = 0;
@@ -859,7 +887,25 @@
859
887
  const commentLength = view.getUint16(offset + 32, true);
860
888
  const localHeaderOffset = view.getUint32(offset + 42, true);
861
889
  const rawName = bytes.slice(offset + 46, offset + 46 + fileNameLength);
862
- const path = normalizeSafeProjectPath(decoder.decode(rawName));
890
+ let name = '';
891
+ try { name = nameDecoder.decode(rawName); } catch (_error) { /* unknown identity is incomplete */ }
892
+ const directory = name.endsWith('/');
893
+ const path = normalizeSafeProjectPath(directory ? name.slice(0, -1) : name);
894
+ if (!path || seenPaths.has(path)) {
895
+ if (path) {
896
+ const previousIndex = files.findIndex(file => file.path === path);
897
+ if (previousIndex >= 0) files.splice(previousIndex, 1);
898
+ }
899
+ pathIdentityComplete = false;
900
+ skipped.push({ path: name, reason: path ? 'zip_path_duplicate' : 'zip_path_invalid', unknownPath: !name });
901
+ offset += 46 + fileNameLength + extraLength + commentLength;
902
+ continue;
903
+ }
904
+ seenPaths.add(path);
905
+ if (directory) {
906
+ offset += 46 + fileNameLength + extraLength + commentLength;
907
+ continue;
908
+ }
863
909
  const isText = window.CodexOverleafProjectFiles.isTextProjectPath(path);
864
910
  const shouldInclude = path && !path.endsWith('/') && (isText || options.includeBinaryFiles);
865
911
 
@@ -904,7 +950,7 @@
904
950
  offset += 46 + fileNameLength + extraLength + commentLength;
905
951
  }
906
952
 
907
- return { files, skipped };
953
+ return { files, skipped, pathIdentityComplete };
908
954
  }
909
955
 
910
956
  function uint8ArrayToBase64(bytes) {
@@ -1035,11 +1081,10 @@
1035
1081
  }
1036
1082
 
1037
1083
  function fallbackNormalizeSafeProjectPath(value) {
1038
- const text = String(value || '').replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '').trim();
1039
- if (!text || text.split('/').some(part => !part || part === '.' || part === '..')) {
1040
- return '';
1041
- }
1042
- return text;
1084
+ if (typeof value !== 'string' || !value || value.length > 2048 || value.trim() !== value
1085
+ || /[\u0000-\u001f\u007f]/.test(value) || value.startsWith('/')
1086
+ || value.includes('\\') || /^[A-Za-z]:/.test(value)) return '';
1087
+ return value.split('/').some(part => !part || part === '.' || part === '..') ? '' : value;
1043
1088
  }
1044
1089
 
1045
1090
  return { create };
@@ -6,7 +6,7 @@
6
6
  'use strict';
7
7
 
8
8
  function resolveJumpToPositionRange(params, text, filePath) {
9
- if (params.line === undefined && params.column === undefined) {
9
+ if (params.line === undefined && params.column === undefined && params.endLine === undefined) {
10
10
  return {
11
11
  ok: true,
12
12
  from: params.from,
@@ -33,6 +33,22 @@
33
33
  });
34
34
  }
35
35
 
36
+ if (params.endLine !== undefined) {
37
+ const endLineNumber = Number(params.endLine);
38
+ if (!Number.isInteger(endLineNumber) || endLineNumber < lineNumber) {
39
+ return jumpPositionOutOfRange('line_out_of_range', filePath, {
40
+ line: lineNumber,
41
+ endLine: Number.isInteger(endLineNumber) ? endLineNumber : params.endLine,
42
+ lineCount
43
+ });
44
+ }
45
+ return {
46
+ ok: true,
47
+ from: line.start,
48
+ to: lines[Math.min(endLineNumber, lineCount) - 1].end
49
+ };
50
+ }
51
+
36
52
  const lineLength = line.end - line.start;
37
53
  if (params.selectLine === true) {
38
54
  return {
@@ -1,7 +1,7 @@
1
1
  (function (root, factory) {
2
- if (typeof module === 'object' && module.exports) module.exports = factory();
3
- else root.CodexOverleafTextFileCreator = factory();
4
- })(typeof window !== 'undefined' ? window : globalThis, function () {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory(require('./ownedDirectoryCleanup'));
3
+ else root.CodexOverleafTextFileCreator = factory(root.CodexOverleafOwnedDirectoryCleanup);
4
+ })(typeof window !== 'undefined' ? window : globalThis, function (OwnedDirectoryCleanup) {
5
5
  'use strict';
6
6
 
7
7
  const MAX_CONFIRMATION_OBSERVATIONS = 8;
@@ -18,6 +18,7 @@
18
18
  const delay = ms => new Promise(resolve => window.setTimeout(resolve, ms));
19
19
  const now = typeof deps.now === 'function' ? deps.now : Date.now;
20
20
  const verificationReads = new Set();
21
+ const uploadParentProofs = new WeakMap();
21
22
  const verificationBudget = () => ({ remainingMs: 90000 });
22
23
  async function pauseVerification(budget, durationMs) {
23
24
  const started = now();
@@ -81,7 +82,18 @@
81
82
  } : {}) };
82
83
  } finally { if (controller) verificationReads.delete(controller); }
83
84
  assertVerificationOwner(projectId, isCurrent);
84
- if (result?.ok === true && Array.isArray(result.files)) return result;
85
+ if (result?.ok === true && Array.isArray(result.files)) {
86
+ const skipped = [...(Array.isArray(result.skipped) ? result.skipped : []),
87
+ ...(Array.isArray(result.capabilities?.skipped) ? result.capabilities.skipped : [])];
88
+ if (result.pathIdentityComplete === false || result.capabilities?.pathIdentityComplete === false
89
+ || skipped.some(file => file?.unknownPath || ['zip_path_invalid', 'zip_path_duplicate'].includes(file?.reason))) {
90
+ throw Object.assign(new Error('The server snapshot contains unresolved file identities; absence cannot be confirmed.'), {
91
+ code: 'source_zip_unavailable', originalCode: 'zip_path_identity_incomplete', retryable: false,
92
+ diagnostics: { ...(result.diagnostics || {}), pathIdentityComplete: false }
93
+ });
94
+ }
95
+ return result;
96
+ }
85
97
  last = Object.assign(new Error('The server project snapshot could not be read.'), {
86
98
  code: 'source_zip_unavailable', diagnostics: result?.diagnostics || null,
87
99
  technicalMessage: result?.reason || 'No complete server snapshot was returned.',
@@ -96,9 +108,14 @@
96
108
  }
97
109
  function verificationFailure(error, changedDocument, phase) {
98
110
  const interrupted = ['codex_cancelled', 'aborted_project_changed'].includes(error?.code);
99
- const code = interrupted ? error.code : changedDocument ? 'file_upload_unconfirmed' : 'source_zip_unavailable';
111
+ // Only a failed server read means the project could not be checked; any
112
+ // other error before the upload came from the Overleaf file-tree controls.
113
+ const treeControls = !interrupted && !changedDocument && error?.code !== 'source_zip_unavailable';
114
+ const code = interrupted ? error.code : changedDocument ? 'file_upload_unconfirmed'
115
+ : treeControls ? 'file_tree_controls_unavailable' : 'source_zip_unavailable';
100
116
  const reason = interrupted ? error.message : changedDocument
101
117
  ? 'The file was submitted, but its saved content could not be confirmed.'
118
+ : treeControls ? 'Codex could not safely perform this file operation with the available Overleaf controls.'
102
119
  : 'The project could not be checked before uploading this file.';
103
120
  return { ok: false, code, reason, changedDocument, diagnostics: {
104
121
  verificationPhase: phase, ...(error?.diagnostics || {}), reason: error?.technicalMessage || error?.message || ''
@@ -142,11 +159,13 @@
142
159
  if (!lastError && observed.get(operation.path)?.content === operation.content) {
143
160
  results.push({ operation, result: { ok: true, verified: true, changedDocument: true,
144
161
  method: 'overleaf.native-text-upload', verification: 'overleaf-zip',
162
+ createdDirectories: entry.result?.createdDirectories || [], fileUploadAttempted: entry.result?.fileUploadAttempted === true,
145
163
  saveReceipt: await makeSaveReceipt(batch.projectId, operation.path, operation.content) } });
146
164
  } else {
147
- results.push({ operation, result: verificationFailure(lastError || new Error(
165
+ results.push({ operation, result: { ...verificationFailure(lastError || new Error(
148
166
  observed.has(operation.path) ? 'Server content differs from the submitted file.' : 'The file is not yet present in the server snapshot.'
149
- ), true, 'post-upload') });
167
+ ), true, 'post-upload'), createdDirectories: entry.result?.createdDirectories || [],
168
+ fileUploadAttempted: entry.result?.fileUploadAttempted === true } });
150
169
  }
151
170
  }
152
171
  // The final fresh snapshot can also prove unrelated edits in this batch.
@@ -191,6 +210,10 @@
191
210
  return folder;
192
211
  };
193
212
 
213
+ const directoryCleanup = OwnedDirectoryCleanup.create({ document, treeOperations, delay, now,
214
+ readServerSnapshot, assertVerificationOwner, normalizeDomText, normalizeSafeProjectPath });
215
+ const { folderIdentity, cleanupCreatedDirectories } = directoryCleanup;
216
+
194
217
  async function createFolderWithDom(entryPath, options = {}) {
195
218
  const target = normalizeSafeProjectPath(entryPath);
196
219
  const projectId = getProjectId();
@@ -230,7 +253,15 @@
230
253
  toggle.click();
231
254
  await waitFor(() => findFolder(parentPath)?.getAttribute('aria-expanded') === 'true');
232
255
  };
233
- let ownedDialog = null;
256
+ let ownedDialog = null, createdProof = null;
257
+ const publishProof = () => {
258
+ if (!createdProof) return;
259
+ if (getProjectId() === projectId) {
260
+ const row = findFolder(target);
261
+ if (row) { createdProof.nativeFolderId = folderIdentity(row); createdProof.observedAfterCreate = true; }
262
+ }
263
+ options.onCreatedDirectory?.({ ...createdProof });
264
+ };
234
265
  try {
235
266
  assertCurrent();
236
267
  if (findFolder(target)) return;
@@ -259,12 +290,18 @@
259
290
  await delay(0);
260
291
  assertCurrent();
261
292
  if (findFolder(target) || projectPathExists(target)) throw new Error('The target path appeared during creation; no replacement was attempted.');
293
+ const confirm = choose(ownedDialog, /^(?:Create|创建)$/i);
294
+ createdProof = { v: 1, projectId, path: target, nativeFolderId: '', ownershipConfirmed: false, observedAfterCreate: false };
295
+ options.onCreatedDirectory?.({ ...createdProof });
262
296
  options.onMutation?.();
263
- choose(ownedDialog, /^(?:Create|创建)$/i).click();
297
+ confirm.click();
298
+ publishProof();
264
299
  await waitFor(() => !dialogs().length);
265
300
  await expandParent();
266
301
  await waitFor(() => findFolder(target));
302
+ publishProof();
267
303
  } finally {
304
+ publishProof();
268
305
  if (ownedDialog && getProjectId() === projectId && dialogs().includes(ownedDialog)) {
269
306
  const cancel = Array.from(ownedDialog.querySelectorAll('button')).filter(node => visible(node) && /^(?:Cancel|取消)$/i.test(controlName(node)));
270
307
  if (cancel.length === 1) cancel[0].click();
@@ -281,7 +318,12 @@
281
318
  if (!target || !projectId || typeof operation.content !== 'string' || !window.CodexOverleafProjectFiles?.isTextProjectPath(target)) {
282
319
  return { ok: false, code: 'invalid_project_path', reason: 'A valid text-file path and project are required.' };
283
320
  }
284
- let mutationAttempted = false;
321
+ let mutationAttempted = false, fileUploadAttempted = false;
322
+ const createdDirectories = [];
323
+ const recordDirectory = proof => {
324
+ const index = createdDirectories.findIndex(item => item.path === proof.path);
325
+ if (index < 0) createdDirectories.push(proof); else createdDirectories[index] = proof;
326
+ };
285
327
  let verificationPhase = 'preflight', zipReadCount = 0, lastZipFailure = null;
286
328
  const readBudget = options.batch?.budget || verificationBudget();
287
329
  let uploadDialog = null;
@@ -346,7 +388,7 @@
346
388
  data.items.add(file);
347
389
  assertCurrent();
348
390
  uploadHelpers.assignFilesToInput(input, data.files, window);
349
- mutationAttempted = true;
391
+ mutationAttempted = true; fileUploadAttempted = true;
350
392
  options.batch?.pending.set(target, operation);
351
393
  input.dispatchEvent(new window.Event('change', { bubbles: true }));
352
394
  // Never click Replace/Overwrite. A concurrent name conflict must stop.
@@ -402,20 +444,28 @@
402
444
  const folderPath = parts.slice(0, depth).join('/');
403
445
  if (!findFolder(folderPath)) await createFolderWithDom(folderPath, {
404
446
  isCurrent: () => { assertCurrent(); return true; },
405
- onMutation: () => { mutationAttempted = true; }
447
+ onMutation: () => { mutationAttempted = true; }, onCreatedDirectory: recordDirectory
406
448
  });
407
449
  await ensureFolderExpanded(folderPath);
408
450
  }
409
451
  await uploadText();
410
- if (options.batch) return { ok: false, pendingVerification: true, changedDocument: true };
411
- return { ok: true, method: 'overleaf.native-text-upload', changedDocument: true,
452
+ if (options.batch) return { ok: false, pendingVerification: true, changedDocument: true, createdDirectories, fileUploadAttempted };
453
+ return { ok: true, method: 'overleaf.native-text-upload', changedDocument: true, createdDirectories, fileUploadAttempted,
412
454
  verified: true, verification: 'overleaf-zip',
413
455
  saveReceipt: await makeSaveReceipt(projectId, target, operation.content) };
414
456
  } catch (error) {
415
- return {
416
- ...verificationFailure(error, mutationAttempted, verificationPhase),
417
- diagnostics: { verificationPhase, zipReadCount, lastZipFailure }
418
- };
457
+ const directoryCleanup = createdDirectories.length
458
+ ? await cleanupCreatedDirectories(createdDirectories, { projectId, isCurrent: options.isCurrent }) : undefined;
459
+ const result = verificationFailure(error, mutationAttempted, verificationPhase);
460
+ if (!fileUploadAttempted && mutationAttempted) {
461
+ const reason = directoryCleanup?.ok ? 'File upload did not start. The new empty folders were removed.'
462
+ : 'File upload did not start. Some new folders remain and need review.';
463
+ result.reason = reason; result.code = 'file_tree_operation_unverified';
464
+ result.failure = { ...result.failure, code: result.code, stage: 'directory-preparation', userMessage: reason,
465
+ nextAction: directoryCleanup?.ok ? 'Retry sync to create the file.' : 'Review the remaining folders before retrying.' };
466
+ }
467
+ return { ...result, createdDirectories: directoryCleanup?.remaining || createdDirectories,
468
+ fileUploadAttempted, directoryCleanup, diagnostics: { verificationPhase, zipReadCount, lastZipFailure } };
419
469
  } finally {
420
470
  if (uploadDialog && getProjectId() === projectId && dialogs().includes(uploadDialog)) {
421
471
  const close = Array.from(uploadDialog.querySelectorAll('button')).find(node => visible(node)
@@ -424,6 +474,31 @@
424
474
  }
425
475
  }
426
476
  }
477
+ const rootUploadIdentity = row => row?.querySelector?.('[data-file-type="doc"][data-file-id], [data-file-type="file"][data-file-id]')
478
+ ?.getAttribute?.('data-file-id') || '';
479
+ function rememberUploadParent(path, node, projectId) {
480
+ if (node && typeof node === 'object') uploadParentProofs.set(node, Object.freeze({
481
+ path, projectId, nativeId: path ? folderIdentity(node) : rootUploadIdentity(node)
482
+ }));
483
+ return node;
484
+ }
485
+ // Synchronous last-moment guard after any uploader await. Snapshot the
486
+ // identity at preparation, since React can reuse a DOM row for a new ID.
487
+ function isUploadParentCurrent(folderPath, parentNode) {
488
+ const target = normalizeSafeProjectPath(folderPath);
489
+ const proof = parentNode && uploadParentProofs.get(parentNode);
490
+ if ((!target && folderPath !== '') || !proof?.projectId || !proof.nativeId
491
+ || proof.path !== target || getProjectId() !== proof.projectId) return false;
492
+ const root = document.querySelector('[data-testid="file-tree-list-root"], .file-tree-list[role="tree"]');
493
+ if (!root?.children) return false;
494
+ const selected = Array.from(root.querySelectorAll?.('[role="treeitem"]') || [])
495
+ .filter(row => row.getAttribute('aria-selected') === 'true');
496
+ if (selected.length !== 1 || selected[0] !== parentNode) return false;
497
+ if (!target) return parentNode.closest?.('[role="tree"]') === root
498
+ && !parentNode.querySelector('[data-file-type="folder"]') && rootUploadIdentity(parentNode) === proof.nativeId;
499
+ return findFolder(target) === parentNode && folderIdentity(parentNode) === proof.nativeId;
500
+ }
501
+
427
502
  // Binary uploads use the same folder hierarchy as verified text uploads.
428
503
  // A folder is not a text document and must never use basename fallback.
429
504
  async function prepareUploadParent(folderPath, options = {}) {
@@ -454,7 +529,7 @@
454
529
  if (selected.length !== 1 || selected[0] !== anchor || anchor.closest('[role="tree"]') !== currentRoot) {
455
530
  throw new Error('Overleaf did not confirm the selected root upload parent.');
456
531
  }
457
- return anchor;
532
+ return rememberUploadParent('', anchor, projectId);
458
533
  }
459
534
  const expand = async path => {
460
535
  assertCurrent();
@@ -479,7 +554,8 @@
479
554
  const path = parts.slice(0, depth).join('/');
480
555
  assertCurrent();
481
556
  if (!findFolder(path) && options.createMissing === true) await createFolderWithDom(path, {
482
- isCurrent: () => { assertCurrent(); return true; }, onMutation: options.onMutation
557
+ isCurrent: () => { assertCurrent(); return true; }, onMutation: options.onMutation,
558
+ onCreatedDirectory: options.onCreatedDirectory
483
559
  });
484
560
  await expand(path);
485
561
  }
@@ -493,7 +569,7 @@
493
569
  if (selected.getAttribute('aria-selected') !== 'true') {
494
570
  throw new Error('Overleaf did not confirm the selected upload parent.');
495
571
  }
496
- return selected;
572
+ return rememberUploadParent(target, selected, projectId);
497
573
  }
498
574
 
499
575
  async function prepareEditor(params = {}) {
@@ -539,6 +615,9 @@
539
615
  async function ensureWriteMode(reviewing, params = {}) {
540
616
  const ensureMode = reviewing ? deps.ensureReviewing : deps.ensureEditing;
541
617
  if (typeof ensureMode !== 'function') return { ok: false, reason: 'Editor mode controls are unavailable.' };
618
+ // An open paywall can make the underlying editor inert. Detect it before
619
+ // preparation attempts any file navigation behind that modal.
620
+ if (reviewing && deps.findUpgradeModal?.()) return ensureMode(params);
542
621
  const prepared = await prepareEditor(params);
543
622
  return prepared.ok ? ensureMode(params) : prepared;
544
623
  }
@@ -552,6 +631,15 @@
552
631
  const deadlineAt = Math.min(now() + 90000, Number.isFinite(options.deadlineAt) ? options.deadlineAt : Infinity);
553
632
  const preflightDeadlineAt = deadlineAt - 15000;
554
633
  const expected = options.expectedContent;
634
+ const finishCreatedDeletion = result => {
635
+ const proofs = operation.undoCreatedFile?.createdDirectories;
636
+ if (options.undoCreatedFile !== true || !Array.isArray(proofs) || !proofs.length) return result;
637
+ // The ZIP-confirmed file deletion completes its Undo checkpoint.
638
+ // Directory observations are retained separately and grant no deletion authority.
639
+ return { ...result, fileDeleted: true, directoryCleanup: {
640
+ ok: false, removed: [], remaining: proofs, changedDocument: false
641
+ } };
642
+ };
555
643
  const binary = operation.undoCreatedFile?.v === 1 && operation.undoCreatedFile.kind === 'binary'
556
644
  && options.undoCreatedFile === true && /^[a-f0-9]{64}$/i.test(options.expectedSha256 || '');
557
645
  const matchesExpected = async file => {
@@ -658,8 +746,8 @@
658
746
  if (!snapshotRouter?.fetchProjectZipSnapshot) throw new Error('Verified deletion support is unavailable.');
659
747
  const original = (await readServerFiles()).find(file => file.path === target);
660
748
  if (!original && options.undoCreatedFile === true && !resolveRow()) {
661
- return { ok: true, method: 'overleaf.native-created-file-delete', changedDocument: false,
662
- idempotent: true, verified: true, verification: 'overleaf-zip' };
749
+ return finishCreatedDeletion({ ok: true, method: 'overleaf.native-created-file-delete', changedDocument: false,
750
+ idempotent: true, verified: true, verification: 'overleaf-zip' });
663
751
  }
664
752
  const originalMatches = await matchesExpected(original);
665
753
  assertCurrent();
@@ -741,8 +829,8 @@
741
829
  for (let observation = 0; ; observation++) {
742
830
  const files = await readServerFiles();
743
831
  if (!files.some(file => file.path === target)) {
744
- return { ok: true, method: binary ? 'overleaf.native-created-file-delete' : 'overleaf.native-text-delete', changedDocument: true,
745
- verified: true, verification: 'overleaf-zip' };
832
+ return finishCreatedDeletion({ ok: true, method: binary ? 'overleaf.native-created-file-delete' : 'overleaf.native-text-delete', changedDocument: true,
833
+ verified: true, verification: 'overleaf-zip' });
746
834
  }
747
835
  await delay(Math.min(250 * (2 ** Math.min(observation, 3)), remainingMs()));
748
836
  }
@@ -776,7 +864,7 @@
776
864
  async function createFile(operation, options = {}) {
777
865
  return createTextFileWithDom(operation, options);
778
866
  }
779
- return { createFile, deleteFile, prepareEditor, ensureWriteMode, prepareUploadParent, findFolderNode: findFolder,
867
+ return { createFile, deleteFile, prepareEditor, ensureWriteMode, prepareUploadParent, isUploadParentCurrent, cleanupCreatedDirectories, findFolderNode: findFolder,
780
868
  beginCreateBatch, finishCreateBatch,
781
869
  cancelVerifications() { for (const controller of verificationReads) controller.abort(); } };
782
870
  }
@@ -129,12 +129,22 @@
129
129
  const mergedPrefix = 'native-merge:';
130
130
  const nativeKey = tuple => 'native:' + JSON.stringify(tuple);
131
131
  const ledgerKeys = refs => refs.map(ref => ref.key).sort();
132
- const { mergedInsertionScope, prepareMergedInsertionReview, nativeOwned, rangesFor, shiftedBaseline, scopeCovered } = Ownership.create({
132
+ const { mergedInsertionScope, prepareMergedInsertionReview, nativeOwned, rangesFor, shiftedBaseline, scopeCovered, reviewNodeMatches } = Ownership.create({
133
133
  fingerprint, readActiveEditorText, mergedPrefix, nativeKey, ledgerKeys
134
134
  });
135
135
 
136
136
 
137
137
 
138
+ function isTrackedChangeNodeInNativeScope(node, path) {
139
+ if (typeof deps.getCodeMirrorEditorView !== 'function') return true;
140
+ return reviewNodeMatches(node, path, readNativeSnapshot(path), explicitId(node));
141
+ }
142
+ function rememberTrackedChangeNodeContext(path) {
143
+ if (typeof deps.getCodeMirrorEditorView !== 'function') return;
144
+ const snapshot = readNativeSnapshot(path);
145
+ if (snapshot.ready) for (const node of collectTrackedChangeNodes()) reviewNodeMatches(node, path, snapshot, explicitId(node));
146
+ }
147
+
138
148
  function prepareTrackedChangeReview(path, refs = []) {
139
149
  const snapshot = readNativeSnapshot(path);
140
150
  const blocked = reason => ({ ...snapshot, ok: false, reason });
@@ -321,7 +331,7 @@
321
331
  }
322
332
  return observe(capture, params.expectedContent, 1200);
323
333
  }
324
- return { collectTrackedChangeNodes, trackedChangeRefFromNode, collectTrackedChangeRefsForPaths,
334
+ return { collectTrackedChangeNodes, trackedChangeRefFromNode, collectTrackedChangeRefsForPaths, isTrackedChangeNodeInNativeScope, rememberTrackedChangeNodeContext,
325
335
  waitForTrackedChangeDiff, prepareTrackedChangeCapture, prepareTrackedChangeReview, prepareUntrackedUndo, getTrackedChangeCaptureStatus, captureTrackedWrite, reconcileTrackedChangeCapture };
326
336
  }
327
337
  return { create };
@@ -191,7 +191,25 @@
191
191
  }));
192
192
  }
193
193
 
194
- return { mergedInsertionScope, prepareMergedInsertionReview, nativeOwned, rangesFor, shiftedBaseline, scopeCovered };
194
+ const reviewNodeOwners = new WeakMap();
195
+ function reviewNodeMatches(node, path, snapshot, id = '') {
196
+ if (!node || snapshot?.ready !== true || !snapshot.nativeDocId) return false;
197
+ const explicitPath = node.getAttribute?.('data-path') || node.getAttribute?.('data-file-path') || node.getAttribute?.('data-doc-path');
198
+ if (explicitPath && explicitPath !== path) return false;
199
+ const rawPosition = node.getAttribute?.('data-pos'), from = Number(rawPosition);
200
+ const parts = Array.from(node.querySelectorAll?.('.review-panel-expandable-content') || []);
201
+ if (parts.length !== 1 || (!id && (rawPosition == null || rawPosition === '' || !Number.isSafeInteger(from) || from < 0))) return false;
202
+ const kind = /(?:delete|change-d)(?:\s|$)/.test(node.className || '') ? 'delete' : 'insert';
203
+ const textHash = fingerprint(parts[0].textContent || '');
204
+ if (!snapshot.refs.some(ref => (id ? ref.id === id : ref.from === from) && ref.kind === kind && ref.textHash === textHash)) return false;
205
+ const signature = JSON.stringify([id, from, kind, textHash]);
206
+ const prior = reviewNodeOwners.get(node);
207
+ if (prior && prior.docId !== snapshot.nativeDocId && prior.signature === signature) return false;
208
+ reviewNodeOwners.set(node, { docId: snapshot.nativeDocId, signature });
209
+ return true;
210
+ }
211
+
212
+ return { mergedInsertionScope, prepareMergedInsertionReview, nativeOwned, rangesFor, shiftedBaseline, scopeCovered, reviewNodeMatches };
195
213
  }
196
214
 
197
215
  return { create };