leafrelay 0.2.3 → 0.2.4

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.
@@ -18,10 +18,12 @@ import { createHash } from "crypto";
18
18
  import { posix } from "path";
19
19
  import DiffMatchPatch from "diff-match-patch";
20
20
  import { posix as posix2 } from "path";
21
+ import DiffMatchPatch2 from "diff-match-patch";
21
22
  import { createHash as createHash2 } from "crypto";
22
23
  import { diff3Merge } from "node-diff3";
23
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
24
- import { dirname as dirname3, resolve as resolve4 } from "path";
24
+ import { randomUUID } from "crypto";
25
+ import { mkdir as mkdir3, readFile as readFile4, readdir as readdir2, rename, rm as rm2, writeFile as writeFile3 } from "fs/promises";
26
+ import { dirname as dirname3, join as join2, resolve as resolve4 } from "path";
25
27
  var PACKET_TYPES = [
26
28
  "disconnect",
27
29
  "connect",
@@ -1828,6 +1830,7 @@ var RemoteProject = class {
1828
1830
  project;
1829
1831
  entries = /* @__PURE__ */ new Map();
1830
1832
  idToPath = /* @__PURE__ */ new Map();
1833
+ collaboratorNames = /* @__PURE__ */ new Map();
1831
1834
  changeHandler;
1832
1835
  reconnectHandler;
1833
1836
  externalTransport;
@@ -1844,6 +1847,7 @@ var RemoteProject = class {
1844
1847
  this.socket = new SocketIOAPI(this.url, this.api, this.identity, this.projectId);
1845
1848
  }
1846
1849
  this.project = await this.socket.joinProject(this.projectId);
1850
+ this.indexCollaborators();
1847
1851
  this.reindex();
1848
1852
  this.registerEvents();
1849
1853
  }
@@ -1854,9 +1858,19 @@ var RemoteProject = class {
1854
1858
  }
1855
1859
  onChange(handler) {
1856
1860
  this.changeHandler = handler;
1861
+ return () => {
1862
+ if (this.changeHandler === handler) {
1863
+ this.changeHandler = void 0;
1864
+ }
1865
+ };
1857
1866
  }
1858
1867
  onReconnect(handler) {
1859
1868
  this.reconnectHandler = handler;
1869
+ return () => {
1870
+ if (this.reconnectHandler === handler) {
1871
+ this.reconnectHandler = void 0;
1872
+ }
1873
+ };
1860
1874
  }
1861
1875
  listEntries() {
1862
1876
  return [...this.entries.values()];
@@ -2071,16 +2085,40 @@ var RemoteProject = class {
2071
2085
  collection.splice(index, 1);
2072
2086
  }
2073
2087
  }
2074
- emitChange(path, previousPath) {
2075
- this.changeHandler?.({ path: normalizePath(path), previousPath });
2088
+ emitChange(path, previousPath, actor) {
2089
+ this.changeHandler?.({ path: normalizePath(path), previousPath, actor });
2090
+ }
2091
+ indexCollaborators() {
2092
+ for (const member of [this.project.owner, ...this.project.members]) {
2093
+ if (!member?._id) {
2094
+ continue;
2095
+ }
2096
+ const name = [member.first_name, member.last_name].filter(Boolean).join(" ").trim();
2097
+ if (name) {
2098
+ this.collaboratorNames.set(member._id, name);
2099
+ }
2100
+ }
2101
+ }
2102
+ actor(userId) {
2103
+ if (!userId) {
2104
+ return void 0;
2105
+ }
2106
+ const name = this.collaboratorNames.get(userId);
2107
+ return name ? `${name} (Overleaf)` : `Overleaf user ${userId}`;
2076
2108
  }
2077
2109
  registerEvents() {
2078
2110
  this.socket.updateEventHandlers({
2079
2111
  onProjectJoined: (project) => {
2080
2112
  this.project = project;
2113
+ this.indexCollaborators();
2081
2114
  this.reindex();
2082
2115
  this.reconnectHandler?.();
2083
2116
  },
2117
+ onClientUpdated: (user) => {
2118
+ if (user.user_id && user.name) {
2119
+ this.collaboratorNames.set(user.user_id, user.name);
2120
+ }
2121
+ },
2084
2122
  onFileCreated: (parentId, type, entity) => {
2085
2123
  const parent = this.folderById(parentId);
2086
2124
  if (!parent || type === "outputs") {
@@ -2145,12 +2183,121 @@ var RemoteProject = class {
2145
2183
  onFileChanged: (update) => {
2146
2184
  const path = this.idToPath.get(update.doc);
2147
2185
  if (path) {
2148
- this.emitChange(path);
2186
+ this.emitChange(path, void 0, this.actor(update.meta?.user_id));
2149
2187
  }
2150
2188
  }
2151
2189
  });
2152
2190
  }
2153
2191
  };
2192
+ var decoder = new TextDecoder("utf-8", { fatal: true });
2193
+ function decode(content) {
2194
+ if (content === void 0) {
2195
+ return "";
2196
+ }
2197
+ if (content.includes(0)) {
2198
+ return void 0;
2199
+ }
2200
+ try {
2201
+ return decoder.decode(content);
2202
+ } catch {
2203
+ return void 0;
2204
+ }
2205
+ }
2206
+ function lineStarts(content) {
2207
+ const starts = [0];
2208
+ for (let index = 0; index < content.length; index++) {
2209
+ if (content[index] === "\n" && index + 1 < content.length) {
2210
+ starts.push(index + 1);
2211
+ }
2212
+ }
2213
+ return starts;
2214
+ }
2215
+ function lineAt(starts, offset) {
2216
+ let low = 0;
2217
+ let high = starts.length;
2218
+ while (low < high) {
2219
+ const middle = Math.floor((low + high) / 2);
2220
+ if (starts[middle] <= offset) {
2221
+ low = middle + 1;
2222
+ } else {
2223
+ high = middle;
2224
+ }
2225
+ }
2226
+ return Math.max(1, low);
2227
+ }
2228
+ function toLineRange(content, start, end) {
2229
+ const starts = lineStarts(content);
2230
+ const safeStart = Math.min(start, Math.max(0, content.length - 1));
2231
+ const safeEnd = Math.min(Math.max(start, end - 1), Math.max(0, content.length - 1));
2232
+ return { start: lineAt(starts, safeStart), end: lineAt(starts, safeEnd) };
2233
+ }
2234
+ function mergeRanges(ranges) {
2235
+ const merged = [];
2236
+ for (const range of ranges.sort((a, b) => a.start - b.start)) {
2237
+ const previous = merged.at(-1);
2238
+ if (previous && range.start <= previous.end + 1) {
2239
+ previous.end = Math.max(previous.end, range.end);
2240
+ } else {
2241
+ merged.push({ ...range });
2242
+ }
2243
+ }
2244
+ return merged;
2245
+ }
2246
+ function formatRanges(ranges) {
2247
+ const shown = ranges.slice(0, 3).map((range) => range.start === range.end ? String(range.start) : `${range.start}-${range.end}`);
2248
+ return `${shown.join(", ")}${ranges.length > shown.length ? ` (+${ranges.length - shown.length} more)` : ""}`;
2249
+ }
2250
+ function describeContentChange(before, after) {
2251
+ if (before && after && Buffer.from(before).equals(Buffer.from(after))) {
2252
+ return void 0;
2253
+ }
2254
+ const oldText = decode(before);
2255
+ const newText = decode(after);
2256
+ if (oldText === void 0 || newText === void 0) {
2257
+ return `binary bytes ${before?.byteLength ?? 0} -> ${after?.byteLength ?? 0}`;
2258
+ }
2259
+ if (oldText.length === 0 && newText.length === 0) {
2260
+ return void 0;
2261
+ }
2262
+ if (oldText.length === 0) {
2263
+ const count = lineStarts(newText).length;
2264
+ return `added lines ${count === 1 ? "1" : `1-${count}`}`;
2265
+ }
2266
+ if (newText.length === 0) {
2267
+ const count = lineStarts(oldText).length;
2268
+ return `removed lines ${count === 1 ? "1" : `1-${count}`}`;
2269
+ }
2270
+ const changes = [];
2271
+ let oldOffset = 0;
2272
+ let newOffset = 0;
2273
+ let current;
2274
+ const finish = () => {
2275
+ if (current) {
2276
+ changes.push(current);
2277
+ current = void 0;
2278
+ }
2279
+ };
2280
+ for (const [operation, value] of new DiffMatchPatch2().diff_main(oldText, newText)) {
2281
+ if (operation === 0) {
2282
+ finish();
2283
+ oldOffset += value.length;
2284
+ newOffset += value.length;
2285
+ continue;
2286
+ }
2287
+ current ??= { oldStart: oldOffset, oldEnd: oldOffset, newStart: newOffset, newEnd: newOffset };
2288
+ if (operation === -1) {
2289
+ oldOffset += value.length;
2290
+ }
2291
+ if (operation === 1) {
2292
+ newOffset += value.length;
2293
+ }
2294
+ current.oldEnd = oldOffset;
2295
+ current.newEnd = newOffset;
2296
+ }
2297
+ finish();
2298
+ const ranges = mergeRanges(changes.map((change) => change.newEnd > change.newStart ? toLineRange(newText, change.newStart, change.newEnd) : toLineRange(oldText, change.oldStart, change.oldEnd)));
2299
+ return ranges.length === 0 ? void 0 : `lines ${formatRanges(ranges)}`;
2300
+ }
2154
2301
  var textDecoder = new TextDecoder("utf-8", { fatal: true });
2155
2302
  var textEncoder = new TextEncoder();
2156
2303
  function decodeText(content) {
@@ -2190,32 +2337,19 @@ function mergeText(baseContent, localContent, remoteContent) {
2190
2337
  content: textEncoder.encode(regions.flatMap((region) => region.ok ?? []).join(""))
2191
2338
  };
2192
2339
  }
2193
- var SYNC_STATE_SCHEMA_VERSION = 2;
2340
+ var SYNC_STATE_SCHEMA_VERSION = 1;
2194
2341
  function sha256(content) {
2195
2342
  return createHash2("sha256").update(content).digest("hex");
2196
2343
  }
2197
- function parseSyncState(serialized, projectUri) {
2198
- try {
2199
- const state = JSON.parse(serialized);
2200
- if (state.schemaVersion !== 1 && state.schemaVersion !== SYNC_STATE_SCHEMA_VERSION || state.projectUri !== projectUri || !Number.isInteger(state.remoteVersion) || typeof state.files !== "object" || state.files === null) {
2201
- return void 0;
2202
- }
2203
- return {
2204
- ...state,
2205
- schemaVersion: SYNC_STATE_SCHEMA_VERSION,
2206
- textBases: typeof state.textBases === "object" && state.textBases !== null ? state.textBases : {}
2207
- };
2208
- } catch {
2209
- return void 0;
2210
- }
2211
- }
2212
- function createSyncState(projectUri, remoteVersion) {
2344
+ function createSyncState(projectUri, remoteVersion, initialized = true) {
2213
2345
  return {
2214
2346
  schemaVersion: SYNC_STATE_SCHEMA_VERSION,
2215
2347
  projectUri,
2216
2348
  remoteVersion,
2349
+ initialized,
2217
2350
  files: {},
2218
- textBases: {}
2351
+ textBases: {},
2352
+ conflicts: {}
2219
2353
  };
2220
2354
  }
2221
2355
  function updateSyncCheckpoint(state, path, content) {
@@ -2228,10 +2362,12 @@ function updateSyncCheckpoint(state, path, content) {
2228
2362
  }
2229
2363
  }
2230
2364
  function removeSyncCheckpoint(state, path) {
2231
- for (const checkpointPath of Object.keys(state.files)) {
2365
+ const checkpointPaths = /* @__PURE__ */ new Set([...Object.keys(state.files), ...Object.keys(state.conflicts)]);
2366
+ for (const checkpointPath of checkpointPaths) {
2232
2367
  if (checkpointPath === path || checkpointPath.startsWith(`${path}/`)) {
2233
2368
  delete state.files[checkpointPath];
2234
2369
  delete state.textBases[checkpointPath];
2370
+ delete state.conflicts[checkpointPath];
2235
2371
  }
2236
2372
  }
2237
2373
  }
@@ -2254,34 +2390,36 @@ var SyncEngine = class {
2254
2390
  queue = Promise.resolve();
2255
2391
  conflicts = /* @__PURE__ */ new Set();
2256
2392
  stopLocalWatch;
2393
+ stopRemoteChange;
2394
+ stopRemoteReconnect;
2257
2395
  eventTimers = /* @__PURE__ */ new Map();
2396
+ stopped = false;
2258
2397
  async start() {
2398
+ this.stopped = false;
2259
2399
  this.state = await this.options.stateStore.load();
2260
2400
  const currentVersion = await this.options.remote.getCurrentVersion();
2261
2401
  const localFiles = await this.options.local.listFiles();
2262
2402
  const remoteFiles = this.remoteFilePaths();
2263
- if (!this.state) {
2264
- this.state = createSyncState(this.options.projectUri, currentVersion);
2265
- if (localFiles.length !== 0 && remoteFiles.length !== 0) {
2266
- throw new Error("Synchronization paused: both the local and Overleaf projects contain files, but no common checkpoint exists.");
2267
- }
2268
- const source = localFiles.length === 0 ? "remote" : "local";
2269
- for (const path of source === "remote" ? remoteFiles : localFiles) {
2270
- await this.reconcilePath(path, source);
2271
- }
2272
- this.state.remoteVersion = currentVersion;
2273
- await this.options.stateStore.save(this.state);
2403
+ if (!this.state || !this.state.initialized) {
2404
+ await this.initializeState(currentVersion, localFiles, remoteFiles);
2274
2405
  } else {
2406
+ this.restoreConflicts();
2275
2407
  await this.startupReconcile(currentVersion, localFiles, remoteFiles);
2276
2408
  }
2277
2409
  this.stopLocalWatch = await this.options.local.watch((path) => this.schedule(path, "local"));
2278
- this.options.remote.onChange((change) => {
2410
+ this.stopRemoteChange = this.options.remote.onChange((change) => {
2411
+ if (this.stopped) {
2412
+ return;
2413
+ }
2279
2414
  if (change.previousPath) {
2280
- this.schedule(change.previousPath, "remote");
2415
+ this.schedule(change.previousPath, "remote", change.actor);
2281
2416
  }
2282
- this.schedule(change.path, "remote");
2417
+ this.schedule(change.path, "remote", change.actor);
2283
2418
  });
2284
- this.options.remote.onReconnect?.(() => {
2419
+ this.stopRemoteReconnect = this.options.remote.onReconnect?.(() => {
2420
+ if (this.stopped) {
2421
+ return;
2422
+ }
2285
2423
  this.queue = this.queue.then(() => this.reconcileAfterReconnect()).catch((error2) => {
2286
2424
  this.options.log(`Failed to reconcile after reconnecting: ${error2 instanceof Error ? error2.message : String(error2)}`);
2287
2425
  });
@@ -2289,11 +2427,17 @@ var SyncEngine = class {
2289
2427
  this.options.log(`Synchronization active at Overleaf version ${this.state.remoteVersion}.`);
2290
2428
  }
2291
2429
  async stop() {
2292
- for (const timer of this.eventTimers.values()) {
2293
- clearTimeout(timer);
2430
+ this.stopped = true;
2431
+ this.stopRemoteChange?.();
2432
+ this.stopRemoteChange = void 0;
2433
+ this.stopRemoteReconnect?.();
2434
+ this.stopRemoteReconnect = void 0;
2435
+ for (const pending of this.eventTimers.values()) {
2436
+ clearTimeout(pending.timer);
2294
2437
  }
2295
2438
  this.eventTimers.clear();
2296
2439
  await this.stopLocalWatch?.();
2440
+ this.stopLocalWatch = void 0;
2297
2441
  await this.queue;
2298
2442
  }
2299
2443
  getConflicts() {
@@ -2329,15 +2473,95 @@ var SyncEngine = class {
2329
2473
  await this.options.local.remove(normalized);
2330
2474
  removeSyncCheckpoint(this.state, normalized);
2331
2475
  }
2332
- await this.options.stateStore.save(this.state);
2333
2476
  this.conflicts.delete(normalized);
2477
+ delete this.state.conflicts[normalized];
2478
+ await this.options.stateStore.save(this.state);
2334
2479
  this.options.log(`Resolved ${normalized} using ${winner === "local" ? "local" : "Overleaf"} content.`);
2335
2480
  });
2336
2481
  }
2337
2482
  async retry(path) {
2338
2483
  const normalized = normalizePath2(path);
2484
+ const reason = this.state.conflicts[normalized];
2339
2485
  this.conflicts.delete(normalized);
2340
- await this.enqueue(() => this.reconcilePath(normalized, "local"));
2486
+ delete this.state.conflicts[normalized];
2487
+ try {
2488
+ await this.enqueue(() => this.reconcilePath(normalized, "local"));
2489
+ } catch (error2) {
2490
+ if (reason !== void 0) {
2491
+ this.conflicts.add(normalized);
2492
+ this.state.conflicts[normalized] = reason;
2493
+ await this.options.stateStore.save(this.state);
2494
+ }
2495
+ throw error2;
2496
+ }
2497
+ }
2498
+ async initializeState(currentVersion, localFiles, remoteFiles) {
2499
+ if (!this.state) {
2500
+ this.state = createSyncState(this.options.projectUri, currentVersion, false);
2501
+ await this.options.stateStore.save(this.state);
2502
+ }
2503
+ this.restoreConflicts();
2504
+ const paths = /* @__PURE__ */ new Set([...localFiles, ...remoteFiles, ...Object.keys(this.state.files)]);
2505
+ for (const path of [...paths].sort()) {
2506
+ const normalized = normalizePath2(path);
2507
+ if (this.conflicts.has(normalized)) {
2508
+ continue;
2509
+ }
2510
+ if (this.state.files[normalized] !== void 0) {
2511
+ await this.reconcilePath(normalized, "local");
2512
+ continue;
2513
+ }
2514
+ await this.initializePath(normalized);
2515
+ }
2516
+ this.state.remoteVersion = currentVersion;
2517
+ this.state.initialized = true;
2518
+ await this.options.stateStore.save(this.state);
2519
+ if (this.conflicts.size !== 0) {
2520
+ this.options.log(`Synchronization remains active; ${this.conflicts.size} conflicting path(s) are paused.`);
2521
+ }
2522
+ }
2523
+ async initializePath(path) {
2524
+ const remoteEntry = this.options.remote.entry(path);
2525
+ if (remoteEntry?.type === "folder") {
2526
+ return;
2527
+ }
2528
+ const localContent = await this.options.local.read(path);
2529
+ const remoteContent = isRemoteFile(remoteEntry) ? await this.options.remote.read(path) : void 0;
2530
+ const localHash = localContent && sha256(localContent);
2531
+ const remoteHash = remoteContent && sha256(remoteContent);
2532
+ if (localHash === remoteHash) {
2533
+ if (localContent) {
2534
+ updateSyncCheckpoint(this.state, path, localContent);
2535
+ await this.options.stateStore.save(this.state);
2536
+ }
2537
+ return;
2538
+ }
2539
+ if (!localContent && remoteContent) {
2540
+ await this.options.local.write(path, remoteContent);
2541
+ updateSyncCheckpoint(this.state, path, remoteContent);
2542
+ await this.options.stateStore.save(this.state);
2543
+ this.logChange("pull", "add", path, void 0, remoteContent);
2544
+ return;
2545
+ }
2546
+ await this.recordConflict(
2547
+ path,
2548
+ remoteContent ? "Local and Overleaf content differ, but no common checkpoint exists." : "The local path has no matching Overleaf file and no common checkpoint exists."
2549
+ );
2550
+ }
2551
+ restoreConflicts() {
2552
+ for (const [path, reason] of Object.entries(this.state.conflicts)) {
2553
+ if (this.conflicts.has(path)) {
2554
+ continue;
2555
+ }
2556
+ this.conflicts.add(path);
2557
+ this.options.onConflict(path, reason);
2558
+ }
2559
+ }
2560
+ async recordConflict(path, reason) {
2561
+ this.conflicts.add(path);
2562
+ this.state.conflicts[path] = reason;
2563
+ await this.options.stateStore.save(this.state);
2564
+ this.options.onConflict(path, reason);
2341
2565
  }
2342
2566
  async startupReconcile(currentVersion, localFiles, remoteFiles) {
2343
2567
  const state = this.state;
@@ -2366,6 +2590,11 @@ var SyncEngine = class {
2366
2590
  }
2367
2591
  const candidates = new Set(localChanged);
2368
2592
  const knownFiles = /* @__PURE__ */ new Set([...Object.keys(state.files), ...localFiles, ...remoteFiles]);
2593
+ for (const path of [...localFiles, ...remoteFiles]) {
2594
+ if (state.files[path] === void 0) {
2595
+ candidates.add(path);
2596
+ }
2597
+ }
2369
2598
  for (const change of remoteDiff.diff) {
2370
2599
  const paths = [change.pathname, change.newPathname].filter((path) => Boolean(path));
2371
2600
  for (const changedPath of paths.map(normalizePath2)) {
@@ -2400,6 +2629,9 @@ var SyncEngine = class {
2400
2629
  }
2401
2630
  }
2402
2631
  async reconcileAfterReconnect() {
2632
+ if (this.stopped) {
2633
+ return;
2634
+ }
2403
2635
  const currentVersion = await this.options.remote.getCurrentVersion();
2404
2636
  await this.startupReconcile(
2405
2637
  currentVersion,
@@ -2408,30 +2640,49 @@ var SyncEngine = class {
2408
2640
  );
2409
2641
  this.options.log(`Realtime connection restored at Overleaf version ${currentVersion}.`);
2410
2642
  }
2411
- schedule(path, source) {
2643
+ schedule(path, source, actor) {
2412
2644
  const normalized = normalizePath2(path);
2413
- if (this.shouldIgnore(normalized) || this.conflicts.has(normalized)) {
2645
+ if (this.stopped || this.shouldIgnore(normalized) || this.conflicts.has(normalized)) {
2414
2646
  return;
2415
2647
  }
2416
2648
  const previous = this.eventTimers.get(normalized);
2417
2649
  if (previous) {
2418
- clearTimeout(previous);
2650
+ clearTimeout(previous.timer);
2419
2651
  }
2420
- this.eventTimers.set(normalized, setTimeout(() => {
2421
- this.eventTimers.delete(normalized);
2422
- this.queue = this.queue.then(() => this.reconcilePath(normalized, source)).catch((error2) => {
2423
- this.options.log(`Failed to synchronize ${normalized}: ${error2 instanceof Error ? error2.message : String(error2)}`);
2424
- });
2425
- }, 250));
2652
+ const actors = previous?.actors ?? /* @__PURE__ */ new Set();
2653
+ let unknownRemoteActor = previous?.unknownRemoteActor ?? false;
2654
+ if (source === "remote") {
2655
+ if (actor) {
2656
+ actors.add(actor);
2657
+ } else {
2658
+ unknownRemoteActor = true;
2659
+ }
2660
+ }
2661
+ const pending = {
2662
+ source: previous?.source === "local" || source === "local" ? "local" : "remote",
2663
+ actors,
2664
+ unknownRemoteActor,
2665
+ timer: setTimeout(() => {
2666
+ this.eventTimers.delete(normalized);
2667
+ if (this.stopped) {
2668
+ return;
2669
+ }
2670
+ const remoteActor = pending.unknownRemoteActor ? void 0 : pending.actors.size === 1 ? [...pending.actors][0] : pending.actors.size > 1 ? "multiple Overleaf collaborators" : void 0;
2671
+ this.queue = this.queue.then(() => this.reconcilePath(normalized, pending.source, remoteActor)).catch((error2) => {
2672
+ this.options.log(`Failed to synchronize ${normalized}: ${error2 instanceof Error ? error2.message : String(error2)}`);
2673
+ });
2674
+ }, 250)
2675
+ };
2676
+ this.eventTimers.set(normalized, pending);
2426
2677
  }
2427
2678
  enqueue(operation) {
2428
2679
  const result = this.queue.then(operation);
2429
2680
  this.queue = result.then(() => void 0, () => void 0);
2430
2681
  return result;
2431
2682
  }
2432
- async reconcilePath(path, source) {
2683
+ async reconcilePath(path, source, actor) {
2433
2684
  const normalized = normalizePath2(path);
2434
- if (this.shouldIgnore(normalized) || this.conflicts.has(normalized)) {
2685
+ if (this.stopped || this.shouldIgnore(normalized) || this.conflicts.has(normalized)) {
2435
2686
  return;
2436
2687
  }
2437
2688
  const remoteEntry = this.options.remote.entry(normalized);
@@ -2441,38 +2692,43 @@ var SyncEngine = class {
2441
2692
  const localContent = await this.options.local.read(normalized);
2442
2693
  const remoteContent = isRemoteFile(remoteEntry) ? await this.options.remote.read(normalized) : void 0;
2443
2694
  const baseHash = this.state.files[normalized];
2695
+ const baseContent = readTextBase(this.state, normalized);
2444
2696
  const localHash = localContent && sha256(localContent);
2445
2697
  const remoteHash = remoteContent && sha256(remoteContent);
2446
2698
  if (localHash === remoteHash) {
2447
- if (localContent) {
2448
- updateSyncCheckpoint(this.state, normalized, localContent);
2449
- } else {
2450
- removeSyncCheckpoint(this.state, normalized);
2699
+ if (localHash !== baseHash) {
2700
+ if (localContent) {
2701
+ updateSyncCheckpoint(this.state, normalized, localContent);
2702
+ } else {
2703
+ removeSyncCheckpoint(this.state, normalized);
2704
+ }
2705
+ await this.options.stateStore.save(this.state);
2451
2706
  }
2452
- await this.options.stateStore.save(this.state);
2453
2707
  return;
2454
2708
  }
2455
2709
  const localChanged = localHash !== baseHash;
2456
2710
  const remoteChanged = remoteHash !== baseHash;
2457
2711
  if (localChanged && remoteChanged) {
2458
- const base = readTextBase(this.state, normalized);
2459
- if (base && localContent && remoteContent) {
2460
- const merged = mergeText(base, localContent, remoteContent);
2712
+ if (baseContent && localContent && remoteContent) {
2713
+ const merged = mergeText(baseContent, localContent, remoteContent);
2461
2714
  if (merged.status === "merged" && merged.content) {
2462
2715
  await this.options.local.write(normalized, merged.content);
2463
2716
  await this.options.remote.write(normalized, merged.content);
2464
2717
  updateSyncCheckpoint(this.state, normalized, merged.content);
2465
2718
  await this.options.stateStore.save(this.state);
2466
- this.logChange("merge", "update", normalized);
2719
+ this.logChange("merge", "update", normalized, baseContent, merged.content, actor);
2467
2720
  return;
2468
2721
  }
2469
2722
  }
2470
- this.conflicts.add(normalized);
2471
2723
  const reason = "Both local and Overleaf content changed since the last synchronized checkpoint.";
2472
- this.options.onConflict(normalized, reason);
2724
+ await this.recordConflict(normalized, reason);
2725
+ return;
2726
+ }
2727
+ if (source === "remote" && localChanged && !remoteChanged) {
2473
2728
  return;
2474
2729
  }
2475
2730
  if (localChanged || !remoteChanged && source === "local") {
2731
+ let pushedContent = localContent;
2476
2732
  if (!localContent) {
2477
2733
  if (remoteEntry) {
2478
2734
  await this.options.remote.remove(normalized);
@@ -2484,13 +2740,21 @@ var SyncEngine = class {
2484
2740
  if (sha256(synchronized) !== sha256(localContent)) {
2485
2741
  await this.options.local.write(normalized, synchronized);
2486
2742
  }
2743
+ pushedContent = synchronized;
2487
2744
  updateSyncCheckpoint(this.state, normalized, synchronized);
2488
2745
  }
2489
2746
  await this.options.stateStore.save(this.state);
2490
2747
  if (!localContent && remoteEntry) {
2491
- this.logChange("push", "delete", normalized);
2748
+ this.logChange("push", "delete", normalized, baseContent ?? remoteContent, void 0, actor);
2492
2749
  } else if (localContent) {
2493
- this.logChange("push", remoteEntry ? "update" : "add", normalized);
2750
+ this.logChange(
2751
+ "push",
2752
+ remoteEntry ? "update" : "add",
2753
+ normalized,
2754
+ baseContent ?? remoteContent,
2755
+ pushedContent,
2756
+ actor
2757
+ );
2494
2758
  }
2495
2759
  return;
2496
2760
  }
@@ -2505,13 +2769,26 @@ var SyncEngine = class {
2505
2769
  }
2506
2770
  await this.options.stateStore.save(this.state);
2507
2771
  if (!remoteContent && localContent) {
2508
- this.logChange("pull", "delete", normalized);
2772
+ this.logChange("pull", "delete", normalized, baseContent ?? localContent, void 0, actor);
2509
2773
  } else if (remoteContent) {
2510
- this.logChange("pull", localContent ? "update" : "add", normalized);
2511
- }
2512
- }
2513
- logChange(direction, operation, path) {
2514
- this.options.log(`[${direction}] ${operation} ${JSON.stringify(path)}`);
2774
+ this.logChange(
2775
+ "pull",
2776
+ localContent ? "update" : "add",
2777
+ normalized,
2778
+ baseContent ?? localContent,
2779
+ remoteContent,
2780
+ actor
2781
+ );
2782
+ }
2783
+ }
2784
+ logChange(direction, operation, path, before, after, actor) {
2785
+ const summary = describeContentChange(before, after);
2786
+ const source = direction === "push" ? "local filesystem" : direction === "pull" ? actor ?? "Overleaf" : `local filesystem + ${actor ?? "Overleaf"}`;
2787
+ this.options.log([
2788
+ `[${direction}] ${operation} ${JSON.stringify(path)}`,
2789
+ summary,
2790
+ `source=${source}`
2791
+ ].filter(Boolean).join("; "));
2515
2792
  }
2516
2793
  remoteFilePaths() {
2517
2794
  return this.options.remote.listEntries().filter((entry) => isRemoteFile(entry) && !this.shouldIgnore(entry.path)).map((entry) => entry.path);
@@ -2520,39 +2797,325 @@ var SyncEngine = class {
2520
2797
  return this.options.ignore(path) || path.split("/").some((part) => part.startsWith("."));
2521
2798
  }
2522
2799
  };
2800
+ var HASH_PATTERN = /^[a-f0-9]{64}$/;
2801
+ var encoder = new TextEncoder();
2802
+ function json(value) {
2803
+ return `${JSON.stringify(value, null, 2)}
2804
+ `;
2805
+ }
2806
+ function pathKey(path) {
2807
+ return sha256(encoder.encode(path));
2808
+ }
2809
+ async function readJson(path) {
2810
+ return JSON.parse(await readFile4(path, "utf8"));
2811
+ }
2812
+ async function listFiles(directory) {
2813
+ const result = [];
2814
+ const visit = async (current) => {
2815
+ let entries;
2816
+ try {
2817
+ entries = await readdir2(current, { withFileTypes: true });
2818
+ } catch (error2) {
2819
+ if (error2.code === "ENOENT") {
2820
+ return;
2821
+ }
2822
+ throw error2;
2823
+ }
2824
+ for (const entry of entries) {
2825
+ const path = join2(current, entry.name);
2826
+ if (entry.isDirectory()) {
2827
+ await visit(path);
2828
+ } else if (entry.isFile()) {
2829
+ result.push(path);
2830
+ }
2831
+ }
2832
+ };
2833
+ await visit(directory);
2834
+ return result;
2835
+ }
2836
+ async function atomicWrite(path, content) {
2837
+ await mkdir3(dirname3(path), { recursive: true });
2838
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
2839
+ try {
2840
+ await writeFile3(temporary, content);
2841
+ await rename(temporary, path);
2842
+ } finally {
2843
+ await rm2(temporary, { force: true });
2844
+ }
2845
+ }
2846
+ function parseFormat(value, projectUri) {
2847
+ if (typeof value !== "object" || value === null) {
2848
+ return false;
2849
+ }
2850
+ const format = value;
2851
+ return format.schemaVersion === SYNC_STATE_SCHEMA_VERSION && format.projectUri === projectUri;
2852
+ }
2853
+ function parseCursor(value) {
2854
+ if (typeof value !== "object" || value === null) {
2855
+ return false;
2856
+ }
2857
+ const cursor = value;
2858
+ return Number.isInteger(cursor.remoteVersion) && cursor.remoteVersion >= 0 && typeof cursor.initialized === "boolean";
2859
+ }
2860
+ function parsePathRecord(value) {
2861
+ if (typeof value !== "object" || value === null) {
2862
+ return false;
2863
+ }
2864
+ const record = value;
2865
+ if (typeof record.path !== "string" || !record.path.startsWith("/")) {
2866
+ return false;
2867
+ }
2868
+ if (record.hash !== void 0 && (typeof record.hash !== "string" || !HASH_PATTERN.test(record.hash))) {
2869
+ return false;
2870
+ }
2871
+ if (record.baseHash !== void 0 && (typeof record.baseHash !== "string" || !HASH_PATTERN.test(record.baseHash) || record.hash !== record.baseHash)) {
2872
+ return false;
2873
+ }
2874
+ if (record.conflict !== void 0 && typeof record.conflict !== "string") {
2875
+ return false;
2876
+ }
2877
+ return record.hash !== void 0 || record.conflict !== void 0;
2878
+ }
2879
+ function createRecord(state, path) {
2880
+ const hash = state.files[path];
2881
+ const textBase = state.textBases[path];
2882
+ const conflict = state.conflicts[path];
2883
+ if (hash === void 0 && conflict === void 0) {
2884
+ return void 0;
2885
+ }
2886
+ return {
2887
+ path,
2888
+ ...hash === void 0 ? {} : { hash },
2889
+ ...textBase === void 0 ? {} : { baseHash: hash },
2890
+ ...conflict === void 0 ? {} : { conflict }
2891
+ };
2892
+ }
2523
2893
  var FileSyncStateStore = class {
2524
2894
  constructor(directory, projectUri) {
2525
2895
  this.projectUri = projectUri;
2526
- this.path = resolve4(directory, ".overleaf", "sync-state.json");
2896
+ const overleafDirectory = resolve4(directory, ".overleaf");
2897
+ this.legacyPath = join2(overleafDirectory, "sync-state.json");
2898
+ this.syncDirectory = join2(overleafDirectory, "sync");
2899
+ this.formatPath = join2(this.syncDirectory, "format.json");
2900
+ this.cursorPath = join2(this.syncDirectory, "cursor.json");
2901
+ this.pathsDirectory = join2(this.syncDirectory, "paths");
2902
+ this.basesDirectory = join2(this.syncDirectory, "bases");
2527
2903
  }
2528
2904
  projectUri;
2529
- path;
2530
- serialized;
2905
+ legacyPath;
2906
+ syncDirectory;
2907
+ formatPath;
2908
+ cursorPath;
2909
+ pathsDirectory;
2910
+ basesDirectory;
2911
+ persistedRecords = /* @__PURE__ */ new Map();
2912
+ persistedCursor;
2913
+ invalidRecordPaths = /* @__PURE__ */ new Set();
2914
+ initialized = false;
2531
2915
  async load() {
2916
+ this.invalidRecordPaths.clear();
2532
2917
  try {
2533
- const content = await readFile4(this.path, "utf8");
2534
- const state = parseSyncState(content, this.projectUri);
2535
- if (state) {
2536
- this.serialized = `${JSON.stringify(state, null, 2)}
2537
- `;
2918
+ const format = await readJson(this.formatPath);
2919
+ const cursor = await readJson(this.cursorPath);
2920
+ if (!parseFormat(format, this.projectUri) || !parseCursor(cursor)) {
2921
+ return void 0;
2538
2922
  }
2923
+ const state = {
2924
+ schemaVersion: SYNC_STATE_SCHEMA_VERSION,
2925
+ projectUri: this.projectUri,
2926
+ remoteVersion: cursor.remoteVersion,
2927
+ initialized: cursor.initialized,
2928
+ files: {},
2929
+ textBases: {},
2930
+ conflicts: {}
2931
+ };
2932
+ const records = /* @__PURE__ */ new Map();
2933
+ const referencedBases = /* @__PURE__ */ new Set();
2934
+ let stateIncomplete = false;
2935
+ for (const recordPath of await listFiles(this.pathsDirectory)) {
2936
+ const record = await this.loadRecord(recordPath);
2937
+ if (!record) {
2938
+ if (recordPath.endsWith(".json")) {
2939
+ this.invalidRecordPaths.add(recordPath);
2940
+ stateIncomplete = true;
2941
+ } else {
2942
+ await rm2(recordPath, { force: true });
2943
+ }
2944
+ continue;
2945
+ }
2946
+ if (record.hash !== void 0) {
2947
+ state.files[record.path] = record.hash;
2948
+ }
2949
+ if (record.baseHash !== void 0) {
2950
+ let textBase;
2951
+ try {
2952
+ textBase = await readFile4(this.basePath(record.baseHash), "utf8");
2953
+ } catch (error2) {
2954
+ if (error2.code === "ENOENT") {
2955
+ this.invalidRecordPaths.add(recordPath);
2956
+ stateIncomplete = true;
2957
+ delete state.files[record.path];
2958
+ continue;
2959
+ }
2960
+ throw error2;
2961
+ }
2962
+ if (sha256(encoder.encode(textBase)) !== record.baseHash) {
2963
+ this.invalidRecordPaths.add(recordPath);
2964
+ stateIncomplete = true;
2965
+ delete state.files[record.path];
2966
+ continue;
2967
+ }
2968
+ state.textBases[record.path] = textBase;
2969
+ referencedBases.add(record.baseHash);
2970
+ }
2971
+ if (record.conflict !== void 0) {
2972
+ state.conflicts[record.path] = record.conflict;
2973
+ }
2974
+ records.set(record.path, json(record));
2975
+ }
2976
+ if (stateIncomplete) {
2977
+ state.initialized = false;
2978
+ }
2979
+ await this.removeOrphanBases(referencedBases);
2980
+ this.persistedRecords = records;
2981
+ this.persistedCursor = json(cursor);
2982
+ this.initialized = true;
2539
2983
  return state;
2540
2984
  } catch (error2) {
2541
- if (error2.code === "ENOENT") {
2985
+ if (error2.code === "ENOENT" || error2 instanceof SyntaxError) {
2542
2986
  return void 0;
2543
2987
  }
2544
2988
  throw error2;
2545
2989
  }
2546
2990
  }
2547
2991
  async save(state) {
2548
- const serialized = `${JSON.stringify(state, null, 2)}
2549
- `;
2550
- if (serialized === this.serialized) {
2992
+ if (state.projectUri !== this.projectUri || state.schemaVersion !== SYNC_STATE_SCHEMA_VERSION) {
2993
+ throw new Error("Cannot persist synchronization state for a different project or schema.");
2994
+ }
2995
+ if (!this.initialized) {
2996
+ await this.initializeStorage();
2997
+ }
2998
+ const paths = /* @__PURE__ */ new Set([
2999
+ ...this.persistedRecords.keys(),
3000
+ ...Object.keys(state.files),
3001
+ ...Object.keys(state.conflicts)
3002
+ ]);
3003
+ const obsoleteBases = /* @__PURE__ */ new Set();
3004
+ for (const path of [...paths].sort()) {
3005
+ const record = createRecord(state, path);
3006
+ const serialized = record && json(record);
3007
+ const previous = this.persistedRecords.get(path);
3008
+ if (serialized === previous) {
3009
+ continue;
3010
+ }
3011
+ if (previous) {
3012
+ const previousRecord = JSON.parse(previous);
3013
+ if (previousRecord.baseHash) {
3014
+ obsoleteBases.add(previousRecord.baseHash);
3015
+ }
3016
+ }
3017
+ if (!record || !serialized) {
3018
+ await rm2(this.recordPath(path), { force: true });
3019
+ this.persistedRecords.delete(path);
3020
+ continue;
3021
+ }
3022
+ if (record.baseHash) {
3023
+ const textBase = state.textBases[path];
3024
+ if (textBase === void 0 || sha256(encoder.encode(textBase)) !== record.baseHash) {
3025
+ throw new Error(`Text checkpoint does not match its content hash: ${path}`);
3026
+ }
3027
+ await this.writeBase(record.baseHash, textBase);
3028
+ }
3029
+ const destination = this.recordPath(path);
3030
+ await atomicWrite(destination, serialized);
3031
+ this.invalidRecordPaths.delete(destination);
3032
+ this.persistedRecords.set(path, serialized);
3033
+ }
3034
+ const cursor = json({ remoteVersion: state.remoteVersion, initialized: state.initialized });
3035
+ if (cursor !== this.persistedCursor) {
3036
+ await atomicWrite(this.cursorPath, cursor);
3037
+ this.persistedCursor = cursor;
3038
+ }
3039
+ if (state.initialized) {
3040
+ await Promise.all([...this.invalidRecordPaths].map((path) => rm2(path, { force: true })));
3041
+ this.invalidRecordPaths.clear();
3042
+ }
3043
+ await rm2(this.legacyPath, { force: true });
3044
+ await this.removeObsoleteBases(state, obsoleteBases);
3045
+ }
3046
+ async initializeStorage() {
3047
+ await rm2(this.syncDirectory, { recursive: true, force: true });
3048
+ await mkdir3(this.pathsDirectory, { recursive: true });
3049
+ await mkdir3(this.basesDirectory, { recursive: true });
3050
+ await atomicWrite(this.formatPath, json({
3051
+ schemaVersion: SYNC_STATE_SCHEMA_VERSION,
3052
+ projectUri: this.projectUri
3053
+ }));
3054
+ this.persistedRecords.clear();
3055
+ this.persistedCursor = void 0;
3056
+ this.invalidRecordPaths.clear();
3057
+ this.initialized = true;
3058
+ }
3059
+ recordPath(path) {
3060
+ const key = pathKey(path);
3061
+ return join2(this.pathsDirectory, key.slice(0, 2), `${key}.json`);
3062
+ }
3063
+ async loadRecord(recordPath) {
3064
+ if (!recordPath.endsWith(".json")) {
3065
+ return void 0;
3066
+ }
3067
+ try {
3068
+ const record = await readJson(recordPath);
3069
+ if (!parsePathRecord(record) || recordPath !== this.recordPath(record.path)) {
3070
+ return void 0;
3071
+ }
3072
+ return record;
3073
+ } catch (error2) {
3074
+ if (error2.code === "ENOENT" || error2 instanceof SyntaxError) {
3075
+ return void 0;
3076
+ }
3077
+ throw error2;
3078
+ }
3079
+ }
3080
+ basePath(hash) {
3081
+ return join2(this.basesDirectory, hash.slice(0, 2), `${hash}.txt`);
3082
+ }
3083
+ async writeBase(hash, content) {
3084
+ const path = this.basePath(hash);
3085
+ try {
3086
+ if (sha256(encoder.encode(await readFile4(path, "utf8"))) === hash) {
3087
+ return;
3088
+ }
3089
+ } catch (error2) {
3090
+ if (error2.code !== "ENOENT") {
3091
+ throw error2;
3092
+ }
3093
+ }
3094
+ await atomicWrite(path, content);
3095
+ }
3096
+ async removeObsoleteBases(state, candidates) {
3097
+ if (candidates.size === 0) {
2551
3098
  return;
2552
3099
  }
2553
- await mkdir3(dirname3(this.path), { recursive: true });
2554
- await writeFile3(this.path, serialized, "utf8");
2555
- this.serialized = serialized;
3100
+ const referenced = /* @__PURE__ */ new Set();
3101
+ for (const [path, textBase] of Object.entries(state.textBases)) {
3102
+ if (textBase !== void 0 && state.files[path] !== void 0) {
3103
+ referenced.add(state.files[path]);
3104
+ }
3105
+ }
3106
+ for (const hash of candidates) {
3107
+ if (!referenced.has(hash)) {
3108
+ await rm2(this.basePath(hash), { force: true });
3109
+ }
3110
+ }
3111
+ }
3112
+ async removeOrphanBases(referencedHashes) {
3113
+ const referencedPaths = new Set([...referencedHashes].map((hash) => this.basePath(hash)));
3114
+ for (const path of await listFiles(this.basesDirectory)) {
3115
+ if (!referencedPaths.has(path)) {
3116
+ await rm2(path, { force: true });
3117
+ }
3118
+ }
2556
3119
  }
2557
3120
  };
2558
3121
  async function startServe(directory = process.cwd(), options = {}) {
@@ -2618,4 +3181,4 @@ export {
2618
3181
  RemoteProject,
2619
3182
  startServe
2620
3183
  };
2621
- //# sourceMappingURL=chunk-3RL67IG5.js.map
3184
+ //# sourceMappingURL=chunk-OZ4FLSVP.js.map