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.
- package/README.md +38 -26
- package/README.zh-CN.md +39 -27
- package/extension/bootstrap/background.js +52 -9
- package/extension/bootstrap/manifest.template.json +1 -1
- package/extension/bootstrap/popup.html +1 -0
- package/extension/bootstrap/popupFallback.js +55 -0
- package/extension/bootstrap/updateStatus.js +1 -0
- package/extension/src/background.js +206 -53
- package/extension/src/backgroundBootstrapMigration.js +181 -0
- package/extension/src/backgroundSharedSessions.js +74 -14
- package/extension/src/backgroundUpdateCoordinator.js +118 -21
- package/extension/src/backgroundUpdateSafety.js +173 -0
- package/extension/src/content/activeTurnControl.js +8 -2
- package/extension/src/content/applyResultFormatters.js +46 -73
- package/extension/src/content/assetTransferBroker.js +72 -14
- package/extension/src/content/composerAttachments.js +93 -45
- package/extension/src/content/composerPanel.js +18 -0
- package/extension/src/content/contentRuntime.js +661 -676
- package/extension/src/content/diagnosticsController.js +122 -96
- package/extension/src/content/diagnosticsExport.js +19 -5
- package/extension/src/content/diagnosticsPanel.js +30 -0
- package/extension/src/content/directoryCleanupRecovery.js +54 -0
- package/extension/src/content/generated/content.bundle.js +6879 -2786
- package/extension/src/content/generated/content.bundle.meta.json +11 -3
- package/extension/src/content/globalPreferencesController.js +1 -1
- package/extension/src/content/legacyGlobalRegistry.js +6 -0
- package/extension/src/content/localSkillsPanel.js +102 -12
- package/extension/src/content/markdownText.js +5 -6
- package/extension/src/content/modelPicker.js +7 -4
- package/extension/src/content/nativeCompatibilityController.js +45 -20
- package/extension/src/content/otWarmMirror.js +4 -5
- package/extension/src/content/otWarmMirrorController.js +17 -20
- package/extension/src/content/pageBridgeClient.js +15 -2
- package/extension/src/content/panelMaintenance.js +40 -12
- package/extension/src/content/panelRenderer.js +6 -0
- package/extension/src/content/projectProviderSelection.js +13 -4
- package/extension/src/content/projectSessionCleanup.js +109 -62
- package/extension/src/content/projectSettingsCoordinator.js +39 -2
- package/extension/src/content/providerSettingsCoordinator.js +46 -8
- package/extension/src/content/providerSettingsDialog.js +71 -13
- package/extension/src/content/recentProjects.js +11 -5
- package/extension/src/content/runActivitySummary.js +40 -6
- package/extension/src/content/runCompletionMeta.js +344 -0
- package/extension/src/content/runController.js +37 -14
- package/extension/src/content/runFailureNotice.js +21 -12
- package/extension/src/content/runGuidanceController.js +72 -27
- package/extension/src/content/runGuidanceView.js +59 -10
- package/extension/src/content/runQueueScheduler.js +148 -42
- package/extension/src/content/runRecoveryActions.js +146 -0
- package/extension/src/content/runResultActions.js +8 -125
- package/extension/src/content/runReviewController.js +574 -0
- package/extension/src/content/runScrollLayout.js +21 -2
- package/extension/src/content/runTimelineView.js +146 -153
- package/extension/src/content/scopedPersistencePanelState.js +51 -6
- package/extension/src/content/selectionContextView.js +1 -1
- package/extension/src/content/sensitivePreflight.js +140 -0
- package/extension/src/content/sessionForkController.js +80 -17
- package/extension/src/content/sessionManager.js +1 -1
- package/extension/src/content/settingsPanel.js +7 -3
- package/extension/src/content/settingsWorkbench.js +5 -0
- package/extension/src/content/sharedSessionViewSync.js +45 -8
- package/extension/src/content/updateIdle.js +4 -1
- package/extension/src/content/updateNotice.js +60 -8
- package/extension/src/content/updateSafetyReceiver.js +58 -0
- package/extension/src/content/writebackController.js +53 -4
- package/extension/src/content/writebackOrchestrator.js +82 -73
- package/extension/src/content/writingStyleSettings.js +35 -11
- package/extension/src/page/binaryAssetUploader.js +136 -14
- package/extension/src/page/fileMover.js +256 -0
- package/extension/src/page/ownedDirectoryCleanup.js +94 -0
- package/extension/src/page/referenceProjects.js +1 -0
- package/extension/src/page/reviewContext.js +113 -0
- package/extension/src/page/saveState.js +6 -4
- package/extension/src/page/snapshotRouter.js +84 -39
- package/extension/src/page/textFileCreator.js +105 -25
- package/extension/src/page/trackedChangeCapture.js +12 -2
- package/extension/src/page/trackedChangeOwnership.js +19 -1
- package/extension/src/page/trackedChangeReplay.js +41 -21
- package/extension/src/page/trackedChangesLifecycle.js +217 -199
- package/extension/src/page/treeOperations.js +7 -10
- package/extension/src/page/updateSafetyProbe.js +41 -0
- package/extension/src/page/writebackReceiptJournal.js +2 -0
- package/extension/src/page/writebackRouter.js +80 -79
- package/extension/src/pageBridge.js +19 -3
- package/extension/src/popup.js +62 -10
- package/extension/src/shared/agentTranscript.js +23 -0
- package/extension/src/shared/auditRecords.js +2 -0
- package/extension/src/shared/compatibility.js +22 -6
- package/extension/src/shared/compileAdapter.js +79 -57
- package/extension/src/shared/composerAttachmentDraft.js +151 -0
- package/extension/src/shared/directoryCleanupProof.js +43 -0
- package/extension/src/shared/failureReasons.js +309 -3
- package/extension/src/shared/globalPreferences.js +30 -13
- package/extension/src/shared/governanceRules.js +4 -37
- package/extension/src/shared/i18n.js +298 -2
- package/extension/src/shared/managedUpdateProjection.js +2 -2
- package/extension/src/shared/nativeRequestIdentity.js +1 -0
- package/extension/src/shared/pageRpcContract.js +5 -4
- package/extension/src/shared/projectFiles.js +21 -44
- package/extension/src/shared/providerProfiles.js +5 -3
- package/extension/src/shared/runInputQueue.js +48 -5
- package/extension/src/shared/selectionContext.js +12 -3
- package/extension/src/shared/sessionState.js +72 -27
- package/extension/src/shared/settlementFacts.js +10 -2
- package/extension/src/shared/sharedSessionBridge.js +37 -14
- package/extension/src/shared/sharedSessionTransfer.js +140 -0
- package/extension/src/shared/staleGuard.js +118 -13
- package/extension/src/shared/storageDb.js +58 -26
- package/extension/src/shared/storageMigration.js +71 -111
- package/extension/src/shared/storageRunActions.js +209 -26
- package/extension/src/shared/subagentActivityModel.js +1 -1
- package/extension/src/shared/undoOperations.js +136 -13
- package/extension/src/shared/updateConsent.js +6 -4
- package/extension/src/shared/updateRuntimeIdentity.js +1 -1
- package/extension/src/shared/writebackIntent.js +16 -0
- package/extension/src/shared/writebackOperationEvidence.js +4 -15
- package/extension/src/shared/writebackSettlement.js +38 -38
- package/extension/styles/panel.css +19 -5
- package/native-host/src/anthropicBridgeRequest.js +4 -2
- package/native-host/src/anthropicBridgeResponse.js +6 -2
- package/native-host/src/anthropicMessagesBridge.js +24 -16
- package/native-host/src/bootstrapMigration.js +296 -0
- package/native-host/src/bootstrapMigrationContract.js +101 -0
- package/native-host/src/bootstrapUpdate.js +233 -0
- package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
- package/native-host/src/chatBridgeResponse.js +10 -0
- package/native-host/src/chatCompletionsBridge.js +21 -15
- package/native-host/src/codexConfigPaths.js +106 -0
- package/native-host/src/codexHome.js +66 -73
- package/native-host/src/codexHomeScope.js +124 -0
- package/native-host/src/codexInheritedAuth.js +90 -0
- package/native-host/src/codexLocalSkills.js +301 -0
- package/native-host/src/codexPromptAssembly.js +31 -12
- package/native-host/src/codexProviderTest.js +24 -26
- package/native-host/src/codexSessionRunner.js +110 -183
- package/native-host/src/codexSessionTiming.js +18 -8
- package/native-host/src/codexSkillIsolation.js +170 -0
- package/native-host/src/codexThreadFork.js +19 -3
- package/native-host/src/index.js +19 -7
- package/native-host/src/managedInstall.js +110 -35
- package/native-host/src/managedInstallRecovery.js +140 -0
- package/native-host/src/managedRuntimeEvidence.js +57 -0
- package/native-host/src/mirrorWorkspace.js +92 -22
- package/native-host/src/nativeDoctor.js +15 -0
- package/native-host/src/nativeHelperLifecycle.js +21 -0
- package/native-host/src/nativeHostRegistration.js +33 -0
- package/native-host/src/nativeRunLifecycle.js +71 -0
- package/native-host/src/processStopBarrier.js +42 -7
- package/native-host/src/projectPathPlatform.js +30 -0
- package/native-host/src/providerHttp.js +80 -0
- package/native-host/src/providerProfile.js +4 -2
- package/native-host/src/providerReasoning.js +2 -7
- package/native-host/src/providerRuntime.js +1 -0
- package/native-host/src/providerStore.js +72 -14
- package/native-host/src/responsesPassthroughBridge.js +266 -52
- package/native-host/src/runtimeInstaller.js +21 -1
- package/native-host/src/selectionScope.js +36 -8
- package/native-host/src/subagentAdmission.js +26 -0
- package/native-host/src/subagentBroker.js +79 -66
- package/native-host/src/subagentTelemetry.js +19 -10
- package/native-host/src/subagentWorkspacePath.js +5 -2
- package/native-host/src/taskRunnerRuntime.js +77 -54
- package/native-host/src/tomlStatements.js +140 -0
- package/native-host/src/updateManager.js +297 -39
- package/native-host/src/writingStyleRuntime.js +57 -29
- package/native-host/src/writingStyleStore.js +73 -0
- package/package.json +1 -1
- 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.
|
|
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.
|
|
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 =
|
|
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
|
|
326
|
-
&&
|
|
327
|
-
|
|
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')) {
|