codex-overleaf-link 2.3.5 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -31
- package/README.zh-CN.md +543 -0
- package/extension/bootstrap/manifest.template.json +1 -1
- package/extension/src/background.js +7 -0
- package/extension/src/backgroundUpdateCoordinator.js +88 -4
- package/extension/src/content/activeTurnControl.js +1 -0
- package/extension/src/content/assetTransferBroker.js +2 -0
- package/extension/src/content/contentRuntime.js +77 -57
- package/extension/src/content/contextTray.js +4 -0
- package/extension/src/content/diagnosticsController.js +17 -3
- package/extension/src/content/diagnosticsPanel.js +129 -5
- package/extension/src/content/generated/content.bundle.js +1422 -348
- package/extension/src/content/generated/content.bundle.meta.json +7 -3
- package/extension/src/content/legacyGlobalRegistry.js +3 -0
- package/extension/src/content/modelPicker.js +21 -9
- package/extension/src/content/modelPickerSupport.js +38 -1
- package/extension/src/content/nativeCompatibilityController.js +4 -1
- package/extension/src/content/otWarmMirror.js +126 -92
- package/extension/src/content/otWarmMirrorController.js +122 -1
- package/extension/src/content/pageBridgeClient.js +2 -0
- package/extension/src/content/panelMaintenance.js +26 -15
- package/extension/src/content/providerSettingsCoordinator.js +1 -0
- package/extension/src/content/runFailureNotice.js +104 -0
- package/extension/src/content/runResultActions.js +16 -1
- package/extension/src/content/runScrollLayout.js +124 -0
- package/extension/src/content/runTimelineView.js +37 -11
- package/extension/src/content/sessionForkController.js +20 -11
- package/extension/src/content/settingsPanel.js +28 -11
- package/extension/src/content/trackedChangeCaptureController.js +153 -0
- package/extension/src/content/updateNotice.js +24 -4
- package/extension/src/content/writebackOrchestrator.js +31 -0
- package/extension/src/page/binaryAssetUploader.js +101 -29
- package/extension/src/page/overleafRealtimeObserver.js +120 -25
- package/extension/src/page/textFileCreator.js +493 -0
- package/extension/src/page/trackedChangeCapture.js +342 -0
- package/extension/src/page/trackedChangesLifecycle.js +145 -149
- package/extension/src/page/treeOperations.js +30 -2
- package/extension/src/page/writebackRouter.js +94 -48
- package/extension/src/pageBridge.js +62 -25
- package/extension/src/shared/compatibility.js +1 -1
- package/extension/src/shared/i18n.js +77 -37
- package/extension/src/shared/managedUpdateProjection.js +1 -1
- package/extension/src/shared/otText.js +0 -1
- package/extension/src/shared/pageRpcContract.js +2 -1
- package/extension/src/shared/sessionState.js +23 -23
- package/extension/src/shared/sessionTitle.js +120 -0
- package/extension/src/shared/storageDb.js +10 -12
- package/extension/src/shared/storageRunActions.js +29 -1
- package/extension/src/shared/updateRuntimeIdentity.js +32 -0
- package/extension/src/shared/writebackSettlement.js +19 -5
- package/extension/styles/panel.css +128 -24
- package/native-host/src/mirrorWorkspace.js +20 -1
- package/native-host/src/releaseProxy.js +177 -0
- package/native-host/src/releaseTransport.js +154 -0
- package/native-host/src/taskRunnerRuntime.js +25 -0
- package/native-host/src/updateManager.js +40 -39
- package/package.json +5 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory();
|
|
3
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
4
|
+
if (root) root.CodexOverleafTrackedChangeCaptureController = api;
|
|
5
|
+
})(typeof window !== 'undefined' ? window : globalThis, function () {
|
|
6
|
+
'use strict';
|
|
7
|
+
function create(deps = {}) {
|
|
8
|
+
const now = deps.now || (() => Date.now());
|
|
9
|
+
const defer = deps.setTimeout || setTimeout;
|
|
10
|
+
const cancelTimer = deps.clearTimeout || clearTimeout;
|
|
11
|
+
let timer = null, busy = false, disposed = false;
|
|
12
|
+
const runs = () => deps.getRuns() || [];
|
|
13
|
+
const pending = run => Array.isArray(run.trackedChangeCaptures)
|
|
14
|
+
&& run.trackedChangeCaptures.some(capture => capture.state === 'pending');
|
|
15
|
+
const terminal = run => ['accepted', 'rejected'].includes(run.trackedChangeStatus)
|
|
16
|
+
|| run.undoStatus === 'applied' || run.forkSnapshot === true;
|
|
17
|
+
function buildPostFiles(run) {
|
|
18
|
+
const expectedFiles = Array.isArray(run?.undoExpectedFiles) ? run.undoExpectedFiles : [];
|
|
19
|
+
const appliedOperations = Array.isArray(run?.appliedOperations) ? run.appliedOperations : [];
|
|
20
|
+
if (!expectedFiles.length || !appliedOperations.length) return [];
|
|
21
|
+
const postFilesByPath = deps.buildExpectedFilesAfterOperations({ files: expectedFiles }, appliedOperations);
|
|
22
|
+
const expectedPaths = new Set(expectedFiles.map(file => file?.path).filter(Boolean));
|
|
23
|
+
return Array.from(postFilesByPath.entries())
|
|
24
|
+
.filter(([path, content]) => expectedPaths.has(path) && typeof content === 'string')
|
|
25
|
+
.map(([path, content]) => ({ path, content }));
|
|
26
|
+
}
|
|
27
|
+
function hasLegacyUntrackedCheckpoint(run) {
|
|
28
|
+
if (!run || run.executionSnapshot || run.trackedChangeStatus || run.undoStatus === 'applied'
|
|
29
|
+
|| run.forkSnapshot || run.undoTrackedChanges?.length || run.trackedChangeCaptures?.length) return false;
|
|
30
|
+
const operations = Array.isArray(run.undoOperations) ? run.undoOperations : [];
|
|
31
|
+
const before = Array.isArray(run.undoExpectedFiles) ? run.undoExpectedFiles : [];
|
|
32
|
+
if (!operations.length || !before.length || before.some(file => !file?.path || typeof file.content !== 'string')) return false;
|
|
33
|
+
const paths = new Set(before.map(file => file.path));
|
|
34
|
+
if (paths.size !== before.length || operations.some(op => op?.type !== 'edit' || !paths.has(op.path))) return false;
|
|
35
|
+
const complete = files => Array.isArray(files) && files.length === paths.size
|
|
36
|
+
&& new Set(files.map(file => file?.path)).size === paths.size
|
|
37
|
+
&& files.every(file => paths.has(file?.path) && typeof file.content === 'string');
|
|
38
|
+
const storedPost = run.undoBaseFiles;
|
|
39
|
+
try {
|
|
40
|
+
const post = buildPostFiles(run);
|
|
41
|
+
if (!complete(storedPost) || !complete(post)) return false;
|
|
42
|
+
const postByPath = new Map(post.map(file => [file.path, file.content]));
|
|
43
|
+
if (!storedPost.every(file => postByPath.get(file.path) === file.content)) return false;
|
|
44
|
+
const restored = deps.buildExpectedFilesAfterOperations({ files: storedPost }, operations);
|
|
45
|
+
// A legacy checkpoint must prove both directions; empty refs alone grant nothing.
|
|
46
|
+
return before.every(file => restored.get(file.path) === file.content);
|
|
47
|
+
} catch (_error) { return false; }
|
|
48
|
+
}
|
|
49
|
+
function schedule() {
|
|
50
|
+
if (disposed || timer !== null || busy || !runs().some(pending)) return;
|
|
51
|
+
timer = defer(() => {
|
|
52
|
+
timer = null;
|
|
53
|
+
flush().catch(error => deps.onError?.(error));
|
|
54
|
+
}, 500);
|
|
55
|
+
}
|
|
56
|
+
function record(run, captures) {
|
|
57
|
+
if (!Array.isArray(captures) || !captures.length) return;
|
|
58
|
+
const next = [...(run.trackedChangeCaptures || []), ...captures.filter(Boolean)];
|
|
59
|
+
if (next.every(capture => capture.state === 'observed')) return;
|
|
60
|
+
run.trackedChangeCaptures = next;
|
|
61
|
+
deps.notify?.(run, next.some(capture => capture.state === 'pending') ? 'pending' : 'needs_review', {
|
|
62
|
+
captures: next.map(capture => ({ path: capture.path, reason: capture.reason, ...capture.diagnostics }))
|
|
63
|
+
});
|
|
64
|
+
deps.persist().then(schedule).catch(error => deps.onError?.(error));
|
|
65
|
+
}
|
|
66
|
+
async function flush() {
|
|
67
|
+
if (disposed || busy) return;
|
|
68
|
+
busy = true;
|
|
69
|
+
try {
|
|
70
|
+
for (const run of runs().filter(pending)) {
|
|
71
|
+
if (terminal(run)) { run.trackedChangeCaptures = []; await deps.persist(); continue; }
|
|
72
|
+
if (run.runProjectId !== deps.getProjectId()) continue;
|
|
73
|
+
const newer = runs().some(other => other.id !== run.id
|
|
74
|
+
&& other.runProjectId === run.runProjectId
|
|
75
|
+
&& Date.parse(other.startedAt) > Date.parse(run.startedAt));
|
|
76
|
+
const original = run.trackedChangeCaptures;
|
|
77
|
+
const captures = original.map(capture => ({ ...capture }));
|
|
78
|
+
const postFiles = buildPostFiles(run);
|
|
79
|
+
for (let i = 0; i < captures.length; i++) {
|
|
80
|
+
const capture = captures[i];
|
|
81
|
+
if (capture.state !== 'pending') continue;
|
|
82
|
+
const expected = postFiles.find(file => file.path === capture.path);
|
|
83
|
+
if (newer || !expected || now() >= capture.expiresAt || run.status === 'cancelled') {
|
|
84
|
+
capture.state = 'needs_review';
|
|
85
|
+
capture.reason = newer ? 'capture_superseded' : !expected ? 'capture_baseline_missing'
|
|
86
|
+
: run.status === 'cancelled' ? 'capture_cancelled' : 'capture_expired';
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let response;
|
|
90
|
+
try {
|
|
91
|
+
response = await deps.callPageBridge('reconcileTrackedChangeCapture', {
|
|
92
|
+
runProjectId: run.runProjectId, capture, expectedContent: expected.content
|
|
93
|
+
});
|
|
94
|
+
} catch (_error) {
|
|
95
|
+
response = null;
|
|
96
|
+
}
|
|
97
|
+
// Never patch a superseded hydrated record or another project's state.
|
|
98
|
+
if (disposed || deps.getProjectId() !== run.runProjectId
|
|
99
|
+
|| !runs().includes(run) || terminal(run) || run.trackedChangeCaptures !== original) return;
|
|
100
|
+
const superseded = runs().some(other => other.id !== run.id
|
|
101
|
+
&& other.runProjectId === run.runProjectId
|
|
102
|
+
&& Date.parse(other.startedAt) > Date.parse(run.startedAt));
|
|
103
|
+
if (superseded || run.status === 'cancelled') {
|
|
104
|
+
captures[i] = { ...capture, state: 'needs_review', reason: 'capture_superseded' };
|
|
105
|
+
} else if (response?.capture?.path === capture.path
|
|
106
|
+
&& response.capture.runProjectId === run.runProjectId
|
|
107
|
+
&& ['observed', 'pending', 'needs_review'].includes(response.capture.state)) {
|
|
108
|
+
captures[i] = response.capture;
|
|
109
|
+
} else {
|
|
110
|
+
captures[i] = { ...capture, state: 'needs_review', reason: 'capture_bridge_unavailable' };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!runs().includes(run) || terminal(run) || deps.getProjectId() !== run.runProjectId) continue;
|
|
114
|
+
if (captures.length && captures.every(capture => capture.state === 'observed')) {
|
|
115
|
+
const refs = deps.normalizeTrackedChanges([
|
|
116
|
+
...(run.undoTrackedChanges || []), ...captures.flatMap(capture => capture.refs || [])
|
|
117
|
+
]);
|
|
118
|
+
if (refs.length) {
|
|
119
|
+
run.undoTrackedChanges = refs;
|
|
120
|
+
run.trackedChangeStatus = 'pending';
|
|
121
|
+
run.trackedChangeCaptures = [];
|
|
122
|
+
await deps.persist();
|
|
123
|
+
deps.refresh(run);
|
|
124
|
+
deps.notify?.(run, 'observed', { count: refs.length });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const changed = JSON.stringify(original.map(summary)) !== JSON.stringify(captures.map(summary));
|
|
129
|
+
if (changed) {
|
|
130
|
+
run.trackedChangeCaptures = captures;
|
|
131
|
+
await deps.persist();
|
|
132
|
+
if (!pending(run)) deps.notify?.(run, 'needs_review', {
|
|
133
|
+
reasons: captures.filter(capture => capture.state !== 'observed').map(capture => capture.reason)
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
busy = false;
|
|
139
|
+
if (runs().some(run => pending(run) && run.runProjectId === deps.getProjectId())) schedule();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function summary(capture) {
|
|
143
|
+
return { state: capture.state, reason: capture.reason, refs: (capture.refs || []).map(ref => ref.key) };
|
|
144
|
+
}
|
|
145
|
+
function dispose() {
|
|
146
|
+
disposed = true;
|
|
147
|
+
if (timer !== null) cancelTimer(timer);
|
|
148
|
+
timer = null;
|
|
149
|
+
}
|
|
150
|
+
return { record, schedule, flush, dispose, buildPostFiles, hasLegacyUntrackedCheckpoint };
|
|
151
|
+
}
|
|
152
|
+
return { create };
|
|
153
|
+
});
|
|
@@ -90,7 +90,8 @@
|
|
|
90
90
|
install: 'codex-overleaf/consent-update-install',
|
|
91
91
|
retry: 'codex-overleaf/consent-update-check',
|
|
92
92
|
later: 'codex-overleaf/consent-update-later',
|
|
93
|
-
dismiss: 'codex-overleaf/consent-update-dismiss'
|
|
93
|
+
dismiss: 'codex-overleaf/consent-update-dismiss',
|
|
94
|
+
reload: 'codex-overleaf/consent-update-reload'
|
|
94
95
|
}[action]);
|
|
95
96
|
} catch (error) {
|
|
96
97
|
currentView = await reconcileAfterActionError(error, {
|
|
@@ -212,7 +213,10 @@
|
|
|
212
213
|
const stateName = currentView?.state?.state || 'idle';
|
|
213
214
|
let requestedAction = 'check';
|
|
214
215
|
try {
|
|
215
|
-
if (
|
|
216
|
+
if (['reload_required', 'reload_tabs_required'].includes(stateName)) {
|
|
217
|
+
requestedAction = 'reload';
|
|
218
|
+
currentView = await request('codex-overleaf/consent-update-reload');
|
|
219
|
+
} else if (stateName === 'update_available') {
|
|
216
220
|
requestedAction = 'install';
|
|
217
221
|
currentView = await request('codex-overleaf/consent-update-install');
|
|
218
222
|
} else if (['failed', 'rolled_back'].includes(stateName)) {
|
|
@@ -276,7 +280,7 @@
|
|
|
276
280
|
body.append(recovery, command);
|
|
277
281
|
}
|
|
278
282
|
|
|
279
|
-
if (!['update_available', 'failed', 'rolled_back'].includes(state.state)) {
|
|
283
|
+
if (!['update_available', 'failed', 'rolled_back', 'reload_required', 'reload_tabs_required'].includes(state.state)) {
|
|
280
284
|
const bar = document.createElement('div');
|
|
281
285
|
bar.className = 'codex-update-notice-progress' + (progress.determinate ? '' : ' is-indeterminate');
|
|
282
286
|
bar.setAttribute('role', 'progressbar');
|
|
@@ -328,7 +332,11 @@
|
|
|
328
332
|
let statusText = '';
|
|
329
333
|
let buttonText = tx('Check for updates', '检查更新');
|
|
330
334
|
|
|
331
|
-
if (
|
|
335
|
+
if (['reload_required', 'reload_tabs_required'].includes(stateName)) {
|
|
336
|
+
summaryText = tx(`Installed v${latestVersion}; running extension v${currentVersion}.`, `已安装 v${latestVersion};扩展当前运行 v${currentVersion}。`);
|
|
337
|
+
statusText = getCopy(state).detail;
|
|
338
|
+
buttonText = stateName === 'reload_required' ? tx('Reload extension', '重新加载扩展') : tx('Refresh Overleaf tabs', '刷新 Overleaf 标签页');
|
|
339
|
+
} else if (stateName === 'checking') {
|
|
332
340
|
statusText = tx('Checking the latest stable release…', '正在检查最新稳定版本…');
|
|
333
341
|
buttonText = tx('Checking…', '检查中…');
|
|
334
342
|
} else if (stateName === 'update_available') {
|
|
@@ -355,6 +363,10 @@
|
|
|
355
363
|
|
|
356
364
|
function visibleActions(state) {
|
|
357
365
|
if (actionInFlight) return [];
|
|
366
|
+
if (['reload_required', 'reload_tabs_required'].includes(state)) {
|
|
367
|
+
return [{ id: 'reload', label: state === 'reload_required'
|
|
368
|
+
? tx('Reload extension', '重新加载扩展') : tx('Refresh Overleaf tabs', '刷新 Overleaf 标签页'), primary: true }];
|
|
369
|
+
}
|
|
358
370
|
if (state === 'update_available') {
|
|
359
371
|
return [
|
|
360
372
|
{ id: 'later', label: tx('Later', '稍后'), primary: false },
|
|
@@ -380,6 +392,14 @@
|
|
|
380
392
|
const target = state.latestVersion ? 'v' + state.latestVersion : '';
|
|
381
393
|
const blocker = blockersCopy(state.blockers?.length ? state.blockers : [state.blocker]);
|
|
382
394
|
return {
|
|
395
|
+
reload_required: {
|
|
396
|
+
eyebrow: tx('Installed, awaiting reload', '已安装,等待加载'), title: target,
|
|
397
|
+
detail: state.message || tx('The new files are installed. Reload the extension when Overleaf is saved and idle.', '新版文件已安装,请在 Overleaf 保存并空闲后重新加载扩展。')
|
|
398
|
+
},
|
|
399
|
+
reload_tabs_required: {
|
|
400
|
+
eyebrow: tx('Updated runtime is ready', '新版运行组件已就绪'), title: target,
|
|
401
|
+
detail: state.message || tx('Refresh the saved, idle Overleaf tabs to load the updated panel.', '刷新已保存且空闲的 Overleaf 标签页以加载新版面板。')
|
|
402
|
+
},
|
|
383
403
|
update_available: {
|
|
384
404
|
eyebrow: tx('Update available', '发现新版本'),
|
|
385
405
|
title: target,
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
let tx;
|
|
19
19
|
let getLocale;
|
|
20
20
|
let appendRunEvent;
|
|
21
|
+
let appendRunRecordEvent;
|
|
21
22
|
// In-flight background mirror refresh (v1.7.5) — see the writeback path.
|
|
22
23
|
let pendingMirrorRefresh = null;
|
|
23
24
|
let appendChangeSummary;
|
|
@@ -69,6 +70,34 @@
|
|
|
69
70
|
: null;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
function invalidateMirrorAfterUndo(runId, projectId, result = {}) {
|
|
74
|
+
if (!projectId || (result?.changedDocument !== true && !result?.applied?.length)) return Promise.resolve('not-attempted');
|
|
75
|
+
const pending = Promise.resolve(pendingMirrorRefresh).catch(() => {}).then(async () => {
|
|
76
|
+
const response = await sendBackgroundNative({ method: 'mirror.invalidate', params: { projectId } });
|
|
77
|
+
if (!response?.ok || response.result?.invalidated !== true) {
|
|
78
|
+
throw new Error(response?.error?.message || 'Native host did not confirm mirror invalidation.');
|
|
79
|
+
}
|
|
80
|
+
if (getCurrentProjectId() === projectId) {
|
|
81
|
+
resetContextProject();
|
|
82
|
+
await callPageBridge('invalidateProjectSnapshot', { invalidateFileList: true });
|
|
83
|
+
}
|
|
84
|
+
return 'invalidated';
|
|
85
|
+
}).catch(error => {
|
|
86
|
+
appendRunRecordEvent?.(runId, {
|
|
87
|
+
status: 'warning',
|
|
88
|
+
title: tx(
|
|
89
|
+
`Overleaf undo finished, but local mirror invalidation failed: ${error.message}. Refresh this project or reconnect the native host before running again.`,
|
|
90
|
+
`Overleaf 撤销已完成,但本地镜像失效处理失败:${error.message}。请刷新项目或重新连接 Native Host 后再运行。`
|
|
91
|
+
)
|
|
92
|
+
});
|
|
93
|
+
return 'failed';
|
|
94
|
+
}).finally(() => {
|
|
95
|
+
if (pendingMirrorRefresh === pending) pendingMirrorRefresh = null;
|
|
96
|
+
});
|
|
97
|
+
pendingMirrorRefresh = pending;
|
|
98
|
+
return pending;
|
|
99
|
+
}
|
|
100
|
+
|
|
72
101
|
async function applySyncChangesToOverleaf(syncChanges = [], project = {}, options = {}) {
|
|
73
102
|
const runMode = options.mode || getState().mode;
|
|
74
103
|
const runRequireReviewing = typeof options.requireReviewing === 'boolean'
|
|
@@ -822,6 +851,7 @@
|
|
|
822
851
|
tx,
|
|
823
852
|
getLocale,
|
|
824
853
|
appendRunEvent,
|
|
854
|
+
appendRunRecordEvent,
|
|
825
855
|
appendChangeSummary,
|
|
826
856
|
appendCompletionReport,
|
|
827
857
|
appendOperationsPreview,
|
|
@@ -867,6 +897,7 @@
|
|
|
867
897
|
return {
|
|
868
898
|
applySyncChangesToOverleaf,
|
|
869
899
|
resolveCompileLogContext,
|
|
900
|
+
invalidateMirrorAfterUndo,
|
|
870
901
|
getPendingMirrorRefresh: () => pendingMirrorRefresh
|
|
871
902
|
};
|
|
872
903
|
}
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
size,
|
|
37
37
|
sha256: String(params.sha256).toLowerCase(),
|
|
38
38
|
mimeType: String(params.mimeType || 'application/octet-stream'),
|
|
39
|
+
projectId: treeOperations.getProjectId(),
|
|
39
40
|
overwrite: params.overwrite === true,
|
|
40
41
|
chunks: [],
|
|
41
42
|
received: 0
|
|
@@ -66,6 +67,7 @@
|
|
|
66
67
|
const transfer = transfers.get(transferId);
|
|
67
68
|
if (!transfer) return failure('binary_upload_transfer_missing', 'Asset upload transfer was not found.');
|
|
68
69
|
try {
|
|
70
|
+
assertTransferProject(transfer);
|
|
69
71
|
if (transfer.received !== transfer.size) return failure('binary_upload_incomplete', 'Asset upload is incomplete.');
|
|
70
72
|
const bytes = joinChunks(transfer.chunks, transfer.size);
|
|
71
73
|
const digest = await sha256Hex(bytes, windowRef.crypto);
|
|
@@ -84,9 +86,12 @@
|
|
|
84
86
|
const errors = [];
|
|
85
87
|
for (const attempt of attempts) {
|
|
86
88
|
try {
|
|
89
|
+
assertTransferProject(transfer);
|
|
87
90
|
const result = await attempt();
|
|
88
|
-
const
|
|
89
|
-
const
|
|
91
|
+
const evidence = result?.ok ? await waitForPath(transfer, result, 12000) : null;
|
|
92
|
+
const observed = evidence?.observed;
|
|
93
|
+
const hashVerified = evidence?.hashVerified === true;
|
|
94
|
+
assertTransferProject(transfer);
|
|
90
95
|
const overwriteVerified = !transfer.overwrite || hashVerified || result?.confirmedByTransport === true;
|
|
91
96
|
if (result?.ok && observed && overwriteVerified) {
|
|
92
97
|
return { ok: true, written: true, path: transfer.path, method: result.method, changedDocument: true };
|
|
@@ -97,7 +102,8 @@
|
|
|
97
102
|
errors.push(error.message);
|
|
98
103
|
}
|
|
99
104
|
}
|
|
100
|
-
return failure('binary_upload_unavailable', errors.filter(Boolean).join('; ') || 'No supported Overleaf asset upload path succeeded.')
|
|
105
|
+
return { ...failure('binary_upload_unavailable', errors.filter(Boolean).join('; ') || 'No supported Overleaf asset upload path succeeded.'),
|
|
106
|
+
changedDocument: transfer.mutationAttempted === true };
|
|
101
107
|
} finally {
|
|
102
108
|
transfers.delete(transferId);
|
|
103
109
|
}
|
|
@@ -107,6 +113,13 @@
|
|
|
107
113
|
return { ok: true, aborted: transfers.delete(String(params.transferId || '')) };
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
function assertTransferProject(transfer) {
|
|
117
|
+
if (transfers.get(transfer.id) !== transfer) throw new Error('Asset upload was cancelled or replaced.');
|
|
118
|
+
if (!transfer.projectId || treeOperations.getProjectId() !== transfer.projectId) {
|
|
119
|
+
throw new Error('Project changed during asset upload.');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
110
123
|
async function uploadWithMultipart(file, transfer) {
|
|
111
124
|
const projectId = treeOperations.getProjectId();
|
|
112
125
|
if (!projectId || typeof windowRef.fetch !== 'function') return { ok: false, reason: 'Project upload endpoint is unavailable.' };
|
|
@@ -120,6 +133,7 @@
|
|
|
120
133
|
const csrf = findCsrfToken();
|
|
121
134
|
const headers = csrf ? { 'X-CSRF-Token': csrf } : {};
|
|
122
135
|
const query = folderId ? `?folder_id=${encodeURIComponent(folderId)}` : '';
|
|
136
|
+
assertTransferProject(transfer);
|
|
123
137
|
const response = await windowRef.fetch(`/project/${encodeURIComponent(projectId)}/upload${query}`, {
|
|
124
138
|
method: 'POST', body: form, credentials: 'same-origin', headers
|
|
125
139
|
});
|
|
@@ -143,11 +157,16 @@
|
|
|
143
157
|
async function uploadWithDom(file, transfer) {
|
|
144
158
|
const parentPath = transfer.path.split('/').slice(0, -1).join('/');
|
|
145
159
|
if (parentPath) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
await
|
|
160
|
+
if (typeof deps.prepareUploadParent !== 'function') {
|
|
161
|
+
return { ok: false, reason: 'Verified folder selection is unavailable for nested asset upload.' };
|
|
162
|
+
}
|
|
163
|
+
await deps.prepareUploadParent(parentPath, {
|
|
164
|
+
createMissing: true,
|
|
165
|
+
onMutation: () => { transfer.mutationAttempted = true; },
|
|
166
|
+
isCurrent: () => treeOperations.getProjectId() === transfer.projectId && transfers.get(transfer.id) === transfer
|
|
167
|
+
});
|
|
150
168
|
}
|
|
169
|
+
assertTransferProject(transfer);
|
|
151
170
|
let input = findFileInput();
|
|
152
171
|
if (!input) {
|
|
153
172
|
const button = findUploadButton();
|
|
@@ -158,10 +177,12 @@
|
|
|
158
177
|
if (!input) return { ok: false, reason: 'Overleaf upload file input was not found.' };
|
|
159
178
|
const data = new windowRef.DataTransfer();
|
|
160
179
|
data.items.add(file);
|
|
180
|
+
assertTransferProject(transfer);
|
|
161
181
|
assignFilesToInput(input, data.files, windowRef);
|
|
182
|
+
transfer.mutationAttempted = true;
|
|
162
183
|
input.dispatchEvent(new windowRef.Event('input', { bubbles: true }));
|
|
163
184
|
input.dispatchEvent(new windowRef.Event('change', { bubbles: true }));
|
|
164
|
-
await clickReplaceIfShown();
|
|
185
|
+
await clickReplaceIfShown(transfer);
|
|
165
186
|
return { ok: true, method: 'overleaf.file-input' };
|
|
166
187
|
}
|
|
167
188
|
|
|
@@ -179,9 +200,9 @@
|
|
|
179
200
|
}
|
|
180
201
|
|
|
181
202
|
async function findFolderId(parentPath) {
|
|
182
|
-
const node = parentPath ?
|
|
183
|
-
|
|
184
|
-
const domId =
|
|
203
|
+
const node = parentPath ? (deps.findFolderNode?.(parentPath)
|
|
204
|
+
|| findNativeTreeNode(parentPath, 'folder') || treeOperations.findFileTreeNode(parentPath)) : null;
|
|
205
|
+
const domId = readTreeEntityId(node);
|
|
185
206
|
if (domId) return domId;
|
|
186
207
|
for (const root of [windowRef._ide, windowRef.Overleaf, windowRef.overleaf, windowRef.OL]) {
|
|
187
208
|
const folder = root?.project?.rootFolder?.[0] || root?.project?.rootFolder || root?.rootFolder?.[0] || root?.rootFolder;
|
|
@@ -223,31 +244,82 @@
|
|
|
223
244
|
}
|
|
224
245
|
return null;
|
|
225
246
|
}
|
|
226
|
-
async function clickReplaceIfShown() {
|
|
247
|
+
async function clickReplaceIfShown(transfer) {
|
|
227
248
|
await delay(180);
|
|
228
|
-
|
|
249
|
+
assertTransferProject(transfer);
|
|
250
|
+
if (!transfer.overwrite) return;
|
|
251
|
+
const button = Array.from(documentRef.querySelectorAll('[role="dialog"] button')).find(node => !isCodexOwnedNode(node)
|
|
252
|
+
&& /replace|overwrite|替换|覆盖/i.test(node.textContent || ''));
|
|
229
253
|
if (button) button.click();
|
|
230
254
|
}
|
|
231
|
-
|
|
255
|
+
function readTreeEntityId(node) {
|
|
256
|
+
const owner = node?.closest?.('[data-entity-id], [data-file-id], [data-folder-id], [data-id]');
|
|
257
|
+
const row = node?.closest?.('[role="treeitem"]') || node;
|
|
258
|
+
const entity = row?.querySelector?.(':scope > .entity[data-file-id]');
|
|
259
|
+
for (const candidate of [node, entity, owner]) {
|
|
260
|
+
const id = candidate?.dataset?.entityId || candidate?.dataset?.fileId
|
|
261
|
+
|| candidate?.dataset?.folderId || candidate?.dataset?.id;
|
|
262
|
+
if (id) return id;
|
|
263
|
+
}
|
|
264
|
+
return '';
|
|
265
|
+
}
|
|
266
|
+
function findNativeTreeNode(projectPath, type) {
|
|
267
|
+
const root = documentRef.querySelector?.('[data-testid="file-tree-list-root"]');
|
|
268
|
+
for (const row of root?.querySelectorAll?.('[role="treeitem"]') || []) {
|
|
269
|
+
const entity = row.querySelector?.(':scope > .entity[data-file-id]');
|
|
270
|
+
if (entity?.dataset?.fileType === type && readNativeTreePath(row, root) === projectPath) return row;
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
function readNativeTreePath(row, root) {
|
|
275
|
+
const parts = [row.getAttribute('aria-label')];
|
|
276
|
+
let group = row.closest('[role="tree"]');
|
|
277
|
+
const seen = new Set();
|
|
278
|
+
while (group && group !== root) {
|
|
279
|
+
if (seen.has(group) || seen.size >= 64) return '';
|
|
280
|
+
seen.add(group);
|
|
281
|
+
const folder = group.previousElementSibling;
|
|
282
|
+
const entity = folder?.querySelector?.(':scope > .entity[data-file-id]');
|
|
283
|
+
if (folder?.getAttribute?.('role') !== 'treeitem' || entity?.dataset?.fileType !== 'folder') return '';
|
|
284
|
+
parts.unshift(folder.getAttribute('aria-label'));
|
|
285
|
+
group = group.parentElement?.closest?.('[role="tree"]');
|
|
286
|
+
}
|
|
287
|
+
if (group !== root || parts.some(part => !part || /[/\\]/.test(part))) return '';
|
|
288
|
+
return parts.join('/');
|
|
289
|
+
}
|
|
290
|
+
async function waitForPath(transfer, result, timeoutMs) {
|
|
291
|
+
const projectPath = transfer.path;
|
|
232
292
|
const deadline = Date.now() + timeoutMs;
|
|
293
|
+
let lastObserved = null;
|
|
233
294
|
while (Date.now() < deadline) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
295
|
+
assertTransferProject(transfer);
|
|
296
|
+
const node = findNativeTreeNode(projectPath, 'file')
|
|
297
|
+
|| treeOperations.findFileTreeNode(projectPath, { invalidateCache: true });
|
|
298
|
+
let observed = node || treeOperations.projectPathExists(projectPath)
|
|
299
|
+
? { path: projectPath, id: readTreeEntityId(node) } : null;
|
|
300
|
+
if (!observed?.id) {
|
|
301
|
+
try {
|
|
302
|
+
const list = await snapshotRouter?.buildProjectFileList({
|
|
303
|
+
force: true, maxAgeMs: 0, preferLightweight: true, allowZipFallback: false
|
|
304
|
+
});
|
|
305
|
+
const file = (list?.files || []).find(item => item.path === projectPath);
|
|
306
|
+
if (file) observed = file;
|
|
307
|
+
} catch (_error) { /* continue until the deadline */ }
|
|
308
|
+
}
|
|
309
|
+
assertTransferProject(transfer);
|
|
310
|
+
if (observed) {
|
|
311
|
+
lastObserved = observed;
|
|
312
|
+
const hashVerified = await verifyRemoteHash(observed, transfer).catch(() => false);
|
|
313
|
+
assertTransferProject(transfer);
|
|
314
|
+
// An existing path is not replacement evidence. Re-read identity and
|
|
315
|
+
// bytes while the native uploader and file-tree events settle.
|
|
316
|
+
if (!transfer.overwrite || hashVerified || result.confirmedByTransport === true) {
|
|
317
|
+
return { observed, hashVerified };
|
|
318
|
+
}
|
|
240
319
|
}
|
|
241
320
|
await delay(250);
|
|
242
|
-
try {
|
|
243
|
-
const list = await snapshotRouter?.buildProjectFileList({
|
|
244
|
-
force: true, maxAgeMs: 0, preferLightweight: true, allowZipFallback: false
|
|
245
|
-
});
|
|
246
|
-
const file = (list?.files || []).find(item => item.path === projectPath);
|
|
247
|
-
if (file) return file;
|
|
248
|
-
} catch (_error) { /* continue until the deadline */ }
|
|
249
321
|
}
|
|
250
|
-
return
|
|
322
|
+
return { observed: lastObserved, hashVerified: false };
|
|
251
323
|
}
|
|
252
324
|
async function verifyRemoteHash(observed, transfer) {
|
|
253
325
|
const fileId = observed?.id || observed?._id || observed?.entityId || observed?.fileId || '';
|
|
@@ -339,5 +411,5 @@
|
|
|
339
411
|
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
|
|
340
412
|
}
|
|
341
413
|
function failure(code, reason) { return { ok: false, code, reason, changedDocument: false }; }
|
|
342
|
-
return { chooseOverleafFileInput, create, formatUploadHttpError, joinChunks, normalizePath };
|
|
414
|
+
return { assignFilesToInput, chooseOverleafFileInput, create, formatUploadHttpError, joinChunks, normalizePath };
|
|
343
415
|
});
|