overleaf-sync 2.0.0a3__tar.gz → 2.0.0a4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overleaf-sync
3
- Version: 2.0.0a3
3
+ Version: 2.0.0a4
4
4
  Summary: Overleaf Two-Way Sync Tool
5
5
  Keywords: overleaf,sync,latex,tex
6
6
  Author-email: Moritz Glöckl <moritzgloeckl@users.noreply.github.com>
@@ -115,7 +115,9 @@ Use `ols download` to compile and download your project's PDF. Specify a downloa
115
115
  moritz@github:~/test$ ols [-l/--local-only -r/--remote-only --store-path -p/--path -i/--olignore]
116
116
  ```
117
117
 
118
- Just calling `ols` will two-way sync your project. When there are changes both locally, and remotely you will be asked which file to keep. Using the `-l` or `-r` option you can specify to either sync local project files to Overleaf only or Overleaf files to local ones only respectively. When using these options you can also sync deleted files. If a file has been deleted it can either be deleted on the target (remote when `-l`, local when `-r`) as well, restored on the source (local when `-l`, remote when `-r`) or ignored.
118
+ Just calling `ols` will two-way sync your project. overleaf-sync remembers what each file looked like after the last sync (in a local `.olsync-state.json` file, which is never uploaded), so it knows on which side a file changed: files changed locally are uploaded, files changed on Overleaf are downloaded. If a file was changed on both sides, or differs on the first sync, you are asked which version to keep (or to skip it). Nothing is overwritten silently.
119
+
120
+ Using the `-l` or `-r` option you can specify to either sync local project files to Overleaf only or Overleaf files to local ones only respectively. When using these options you can also sync deleted files. If a file has been deleted it can either be deleted on the target (remote when `-l`, local when `-r`) as well, restored on the source (local when `-l`, remote when `-r`) or ignored.
119
121
 
120
122
  The option `--store-path` specifies the path of the cookie file created by the `login` command. If you did not change its path, you do not need to specify this argument. The `-p/--path` option allows you to specify a different sync folder than the one you're calling `ols` from. The `-i/--olignore` option allows you to specify the path of an `.olignore` file. It uses `fnmatch` internally, so it may have some similarity to `.gitignore` but doesn't work exactly the same. For example, if you wish to exclude a specific folder named `out`, you need to specify it as `out/*`. See [here](https://docs.python.org/3/library/fnmatch.html) for more information.
121
123
 
@@ -86,7 +86,9 @@ Use `ols download` to compile and download your project's PDF. Specify a downloa
86
86
  moritz@github:~/test$ ols [-l/--local-only -r/--remote-only --store-path -p/--path -i/--olignore]
87
87
  ```
88
88
 
89
- Just calling `ols` will two-way sync your project. When there are changes both locally, and remotely you will be asked which file to keep. Using the `-l` or `-r` option you can specify to either sync local project files to Overleaf only or Overleaf files to local ones only respectively. When using these options you can also sync deleted files. If a file has been deleted it can either be deleted on the target (remote when `-l`, local when `-r`) as well, restored on the source (local when `-l`, remote when `-r`) or ignored.
89
+ Just calling `ols` will two-way sync your project. overleaf-sync remembers what each file looked like after the last sync (in a local `.olsync-state.json` file, which is never uploaded), so it knows on which side a file changed: files changed locally are uploaded, files changed on Overleaf are downloaded. If a file was changed on both sides, or differs on the first sync, you are asked which version to keep (or to skip it). Nothing is overwritten silently.
90
+
91
+ Using the `-l` or `-r` option you can specify to either sync local project files to Overleaf only or Overleaf files to local ones only respectively. When using these options you can also sync deleted files. If a file has been deleted it can either be deleted on the target (remote when `-l`, local when `-r`) as well, restored on the source (local when `-l`, remote when `-r`) or ignored.
90
92
 
91
93
  The option `--store-path` specifies the path of the cookie file created by the `login` command. If you did not change its path, you do not need to specify this argument. The `-p/--path` option allows you to specify a different sync folder than the one you're calling `ols` from. The `-i/--olignore` option allows you to specify the path of an `.olignore` file. It uses `fnmatch` internally, so it may have some similarity to `.gitignore` but doesn't work exactly the same. For example, if you wish to exclude a specific folder named `out`, you need to specify it as `out/*`. See [here](https://docs.python.org/3/library/fnmatch.html) for more information.
92
94
 
@@ -1,3 +1,3 @@
1
1
  """Overleaf Two-Way Sync Tool"""
2
2
 
3
- __version__ = '2.0.0a3'
3
+ __version__ = '2.0.0a4'
@@ -12,6 +12,7 @@ import requests as reqs
12
12
  from bs4 import BeautifulSoup
13
13
  import json
14
14
  import ssl
15
+ import sys
15
16
  import uuid
16
17
  import websocket
17
18
  from socketIO_client import SocketIO
@@ -36,6 +37,35 @@ COMPILE_PATH = "/project/{}/compile?enable_pdf_caching=true" # Compile the proj
36
37
  SOCKET_IO_PATH = "/socket.io/1/" # Real-time service handshake
37
38
  PATH_SEP = "/" # Use hardcoded path separator for both windows and posix system
38
39
 
40
+ # Overleaf answers with HTTP 429 (without Retry-After) when a rate limit is reached, e.g. 30 dashboard loads or 10 zip
41
+ # downloads per minute and project. Its limits use one-minute windows, so wait this many seconds before each retry.
42
+ RATE_LIMIT_WAITS = (5, 10, 20, 30)
43
+
44
+
45
+ class RateLimitError(reqs.HTTPError):
46
+ """Overleaf's rate limit was still reached after waiting and retrying"""
47
+
48
+
49
+ def _request(method, url, **kwargs):
50
+ """requests.request that waits and retries when Overleaf's rate limit is reached"""
51
+ for wait in RATE_LIMIT_WAITS:
52
+ r = reqs.request(method, url, **kwargs)
53
+ if r.status_code != 429:
54
+ return r
55
+ print(f"Overleaf's rate limit was reached, retrying in {wait}s ...", file=sys.stderr)
56
+ time.sleep(wait)
57
+ # Rewind uploaded files, the rejected request has already read them
58
+ for value in (kwargs.get("files") or {}).values():
59
+ file = value[1] if isinstance(value, tuple) else value
60
+ if hasattr(file, "seek"):
61
+ file.seek(0)
62
+ r = reqs.request(method, url, **kwargs)
63
+ if r.status_code == 429:
64
+ raise RateLimitError("Overleaf's rate limit was reached (too many requests in a short time). "
65
+ "Please wait a minute and try again.", response=r)
66
+ return r
67
+
68
+
39
69
  class RealtimeSocket(SocketIO):
40
70
  """
41
71
  socketIO-client's SocketIO, minus its cleanup quirk: SocketIO.__del__ calls disconnect(), which opens a brand-new
@@ -136,7 +166,7 @@ class OverleafClient(object):
136
166
  Get the CSRF token from the project dashboard, e.g. after logging in with a cookie copied from a browser
137
167
  Returns: the CSRF token, or None if the session cookie is not (or no longer) valid
138
168
  """
139
- projects_page = reqs.get(self._url(PROJECT_PATH), cookies=self._cookie, allow_redirects=False)
169
+ projects_page = _request("GET", self._url(PROJECT_PATH), cookies=self._cookie, allow_redirects=False)
140
170
  if not projects_page.ok or projects_page.is_redirect:
141
171
  return None
142
172
  csrf_tag = BeautifulSoup(projects_page.content, 'html.parser').find('meta', {'name': 'ol-csrfToken'})
@@ -149,7 +179,7 @@ class OverleafClient(object):
149
179
  Get all of a user's active projects (= not archived and not trashed)
150
180
  Returns: List of project objects
151
181
  """
152
- projects_page = reqs.get(self._url(PROJECT_PATH), cookies=self._cookie)
182
+ projects_page = _request("GET", self._url(PROJECT_PATH), cookies=self._cookie)
153
183
  json_content = OverleafClient._extract_projects(projects_page.content)
154
184
  return list(OverleafClient.filter_projects(json_content))
155
185
 
@@ -160,7 +190,7 @@ class OverleafClient(object):
160
190
  Returns: project object
161
191
  """
162
192
 
163
- projects_page = reqs.get(self._url(PROJECT_PATH), cookies=self._cookie)
193
+ projects_page = _request("GET", self._url(PROJECT_PATH), cookies=self._cookie)
164
194
  json_content = OverleafClient._extract_projects(projects_page.content)
165
195
  return next(OverleafClient.filter_projects(json_content, {"name": project_name}), None)
166
196
 
@@ -170,7 +200,7 @@ class OverleafClient(object):
170
200
  Params: project_id, the id of the project
171
201
  Returns: bytes string (zip file)
172
202
  """
173
- r = reqs.get(self._url(DOWNLOAD_PATH.format(project_id)),
203
+ r = _request("GET", self._url(DOWNLOAD_PATH.format(project_id)),
174
204
  stream=True, cookies=self._cookie)
175
205
  return r.content
176
206
 
@@ -193,7 +223,7 @@ class OverleafClient(object):
193
223
  headers = {
194
224
  "X-Csrf-Token": self._csrf
195
225
  }
196
- r = reqs.post(self._url(FOLDER_PATH.format(project_id)),
226
+ r = _request("POST", self._url(FOLDER_PATH.format(project_id)),
197
227
  cookies=self._cookie, headers=headers, json=params)
198
228
 
199
229
  if r.ok:
@@ -240,7 +270,7 @@ class OverleafClient(object):
240
270
  # (Built from whatever cookies are present rather than hardcoded names:
241
271
  # a session may not carry every cookie -- this previously raised
242
272
  # KeyError here even though overleaf_session2 alone is enough to connect.)
243
- handshake = reqs.get(
273
+ handshake = _request("GET",
244
274
  self._url(SOCKET_IO_PATH),
245
275
  params={"t": int(time.time() * 1000)},
246
276
  cookies=self._cookie,
@@ -348,7 +378,7 @@ class OverleafClient(object):
348
378
  }
349
379
 
350
380
  # Upload the file to the predefined folder
351
- r = reqs.post(self._url(UPLOAD_PATH.format(project_id)), cookies=self._cookie, params=params, files=files)
381
+ r = _request("POST", self._url(UPLOAD_PATH.format(project_id)), cookies=self._cookie, params=params, files=files)
352
382
 
353
383
  # status_code is an int; comparing to str(200) was always False here,
354
384
  # so this reported failure on every call regardless of the real
@@ -392,7 +422,7 @@ class OverleafClient(object):
392
422
  "X-Csrf-Token": self._csrf
393
423
  }
394
424
 
395
- r = reqs.delete(self._url(DELETE_PATH.format(project_id, entity_type, file['_id'])), cookies=self._cookie,
425
+ r = _request("DELETE", self._url(DELETE_PATH.format(project_id, entity_type, file['_id'])), cookies=self._cookie,
396
426
  headers=headers, json={})
397
427
 
398
428
  # status_code is an int; comparing to str(204) was always False
@@ -421,7 +451,7 @@ class OverleafClient(object):
421
451
  "stopOnFirstError": False
422
452
  }
423
453
 
424
- r = reqs.post(self._url(COMPILE_PATH.format(project_id)), cookies=self._cookie, headers=headers, json=body)
454
+ r = _request("POST", self._url(COMPILE_PATH.format(project_id)), cookies=self._cookie, headers=headers, json=body)
425
455
 
426
456
  if not r.ok:
427
457
  raise reqs.HTTPError()
@@ -441,7 +471,7 @@ class OverleafClient(object):
441
471
  if compile_result.get("clsiServerId"):
442
472
  params["clsiserverid"] = compile_result["clsiServerId"]
443
473
 
444
- download_req = reqs.get(self._url(pdf_file['url']), cookies=self._cookie, headers=headers, params=params)
474
+ download_req = _request("GET", self._url(pdf_file['url']), cookies=self._cookie, headers=headers, params=params)
445
475
 
446
476
  if download_req.ok:
447
477
  return pdf_file['path'], download_req.content
@@ -23,10 +23,12 @@ from pathlib import Path
23
23
 
24
24
  try:
25
25
  # Import for pip installation / wheel
26
- from olsync.olclient import BASE_URL, OverleafClient
26
+ from olsync.olclient import BASE_URL, OverleafClient, RateLimitError
27
+ from olsync import syncstate
27
28
  except ImportError:
28
29
  # Import for development
29
- from olclient import BASE_URL, OverleafClient
30
+ from olclient import BASE_URL, OverleafClient, RateLimitError
31
+ import syncstate
30
32
 
31
33
  # Session cookie names: overleaf.com, and self-hosted servers (Community Edition, "sharelatex.sid" before version 5)
32
34
  OVERLEAF_COM_SESSION_COOKIES = ["overleaf_session2"]
@@ -98,9 +100,13 @@ def main(ctx, local, remote, project_name, cookie_path, sync_path, olignore_path
98
100
  "Project could not be downloaded.",
99
101
  verbose)
100
102
 
101
- sync = not (local or remote)
103
+ if not (local or remote):
104
+ two_way_sync(overleaf_client, project, project_infos, zip_file, olignore_path, verbose)
105
+ return
102
106
 
103
- if remote or sync:
107
+ sync = False # -l and -r below sync in one direction only
108
+
109
+ if remote:
104
110
  remote_files = zip_file.namelist()
105
111
  deleted_files = [] if sync else [f for f in olignore_keep_list(olignore_path) if f not in remote_files]
106
112
  sync_func(
@@ -117,8 +123,8 @@ def main(ctx, local, remote, project_name, cookie_path, sync_path, olignore_path
117
123
  from_name="remote",
118
124
  to_name="local",
119
125
  verbose=verbose)
120
- if local or sync:
121
- # Computed once (#60), and after the remote -> local pass so newly downloaded files are included
126
+ if local:
127
+ # Computed once (#60)
122
128
  local_files = olignore_keep_list(olignore_path)
123
129
  deleted_files = [] if sync else [f for f in zip_file.namelist() if f not in local_files]
124
130
  sync_func(
@@ -326,6 +332,93 @@ def write_file(path, content):
326
332
  f.write(content)
327
333
 
328
334
 
335
+ def two_way_sync(overleaf_client, project, project_infos, zip_file, olignore_path, verbose=False):
336
+ """
337
+ Two-way sync that decides per file, based on the state of the last sync (see syncstate.py), instead of the
338
+ project-wide lastUpdated timestamp. Changes on one side are synced; files changed on both sides, or differing
339
+ without sync history, are only overwritten after asking.
340
+ """
341
+ def read(path):
342
+ with open(path, 'rb') as f:
343
+ return f.read()
344
+
345
+ server = overleaf_client.base_url
346
+ remote_content = {name: zip_file.read(name) for name in zip_file.namelist() if not name.endswith("/")}
347
+ # Local candidates: files allowed by .olignore, plus local copies of remote files (to compare them)
348
+ local_paths = set(olignore_keep_list(olignore_path)) | {name for name in remote_content if os.path.isfile(name)}
349
+ local = {path: syncstate.file_hash(read(path)) for path in local_paths}
350
+ remote = {name: syncstate.file_hash(content) for name, content in remote_content.items()}
351
+ state = syncstate.load_state(".", server, project["id"])
352
+ plan = syncstate.plan_two_way(local, remote, state)
353
+
354
+ click.echo("\nSyncing files between [local] and [remote]")
355
+ click.echo('=' * 40)
356
+
357
+ downloads, uploads, skipped = list(plan["download"]), list(plan["upload"]), []
358
+ for path in plan["conflict"]:
359
+ reason = "was changed locally and on Overleaf since the last sync" if path in state \
360
+ else "differs between local and Overleaf, and there is no sync history for it yet"
361
+ choice = click.prompt(
362
+ '\n-> Conflict: <%s> %s.\nKeep the [l]ocal version, keep the [r]emote version or [s]kip?' % (path, reason),
363
+ default="s", type=click.Choice(['l', 'r', 's']))
364
+ {"l": uploads, "r": downloads, "s": skipped}[choice].append(path)
365
+
366
+ failed = []
367
+ for path in downloads:
368
+ try:
369
+ write_file(path, remote_content[path])
370
+ except OSError:
371
+ if verbose:
372
+ print(traceback.format_exc())
373
+ failed.append(path)
374
+ for path in uploads:
375
+ try:
376
+ with open(path, 'rb') as f:
377
+ ok = overleaf_client.upload_file(project["id"], project_infos, path, os.path.getsize(path), f)
378
+ except Exception:
379
+ if verbose:
380
+ print(traceback.format_exc())
381
+ ok = False
382
+ if not ok:
383
+ failed.append(path)
384
+
385
+ for title, paths in [("[DOWNLOAD] Following file(s) downloaded from [remote]", downloads),
386
+ ("[UPLOAD] Following file(s) uploaded to [remote]", uploads),
387
+ ("[SYNC] Following file(s) are up to date", plan["in_sync"]),
388
+ ("[SKIP] Following conflicting file(s) have not been synced", skipped),
389
+ ("[ERROR] Following file(s) could not be synced", failed)]:
390
+ click.echo("\n" + title)
391
+ for path in paths:
392
+ click.echo("\t%s" % path)
393
+
394
+ # Record what both sides look like now. After uploads, read the project again, so that changes Overleaf makes
395
+ # to uploaded files (e.g. line endings) are not mistaken for remote edits next time.
396
+ if set(uploads) - set(failed):
397
+ try:
398
+ fresh = zipfile.ZipFile(io.BytesIO(overleaf_client.download_project(project["id"])))
399
+ remote_content = {name: fresh.read(name) for name in fresh.namelist() if not name.endswith("/")}
400
+ except Exception:
401
+ if verbose:
402
+ print(traceback.format_exc())
403
+ for path in set(uploads) - set(failed):
404
+ remote_content[path] = read(path)
405
+ new_state = {}
406
+ for path in sorted(set(local_paths) | set(remote_content) | set(downloads)):
407
+ if path in skipped or path in failed:
408
+ if path in state:
409
+ new_state[path] = state[path]
410
+ elif path in remote_content and os.path.isfile(path):
411
+ new_state[path] = {"local": syncstate.file_hash(read(path)),
412
+ "remote": syncstate.file_hash(remote_content[path])}
413
+ syncstate.save_state(".", server, project["id"], new_state)
414
+
415
+ click.echo("")
416
+ if failed:
417
+ raise click.ClickException("%d file(s) could not be synced, see above" % len(failed))
418
+ click.echo("✅ Synced files between [local] and [remote]")
419
+ click.echo("")
420
+
421
+
329
422
  def sync_func(files_from, deleted_files, create_file_at_to, delete_file_at_to, create_file_at_from, from_exists_in_to,
330
423
  from_equal_to_to, from_newer_than_to, from_name,
331
424
  to_name, verbose=False):
@@ -436,6 +529,10 @@ def execute_action(action, progress_message, success_message, fail_message, verb
436
529
  with yaspin(text=progress_message, color="green") as spinner:
437
530
  try:
438
531
  success = action()
532
+ except RateLimitError as e:
533
+ # A clear reason is more helpful than the generic failure message
534
+ spinner.fail("💥 ")
535
+ raise click.ClickException(str(e))
439
536
  except:
440
537
  if verbose_error_logging:
441
538
  print(traceback.format_exc())
@@ -0,0 +1,98 @@
1
+ """Sync state for two-way sync"""
2
+ ##################################################
3
+ # MIT License
4
+ ##################################################
5
+ # File: syncstate.py
6
+ # Description: Per-file sync state, so two-way sync can tell which side changed a file
7
+ # Author: Moritz Glöckl
8
+ # License: MIT
9
+ ##################################################
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ # Stored in the sync folder. Dotfiles are never uploaded (glob skips them), so it stays local.
18
+ STATE_FILE = ".olsync-state.json"
19
+ STATE_VERSION = 1
20
+
21
+
22
+ def file_hash(content):
23
+ """
24
+ Content hash used to detect changes
25
+ Params: content, the file content (bytes)
26
+ Returns: hex digest
27
+ """
28
+ return hashlib.sha256(content).hexdigest()
29
+
30
+
31
+ def load_state(sync_dir, server, project_id):
32
+ """
33
+ Load the per-file state of the last successful sync of this project
34
+ Params: sync_dir, server, project_id
35
+ Returns: {path: {"local": hash, "remote": hash}}, empty if there is no (valid) state for this server and project
36
+ """
37
+ try:
38
+ with open(Path(sync_dir) / STATE_FILE) as f:
39
+ state = json.load(f)
40
+ except (OSError, ValueError):
41
+ return {}
42
+ if not isinstance(state, dict) or state.get("version") != STATE_VERSION \
43
+ or state.get("server") != server or state.get("project_id") != project_id:
44
+ return {}
45
+ return state.get("files", {})
46
+
47
+
48
+ def save_state(sync_dir, server, project_id, files):
49
+ """
50
+ Save the per-file state atomically, so an interrupted sync never leaves a broken state file
51
+ Params: sync_dir, server, project_id, files ({path: {"local": hash, "remote": hash}})
52
+ """
53
+ state = {"version": STATE_VERSION, "server": server, "project_id": project_id, "files": files}
54
+ fd, tmp_path = tempfile.mkstemp(dir=sync_dir, prefix=STATE_FILE + ".", suffix=".tmp")
55
+ try:
56
+ with os.fdopen(fd, "w") as f:
57
+ json.dump(state, f, indent=1, sort_keys=True)
58
+ os.replace(tmp_path, Path(sync_dir) / STATE_FILE)
59
+ except BaseException:
60
+ os.unlink(tmp_path)
61
+ raise
62
+
63
+
64
+ def plan_two_way(local, remote, state):
65
+ """
66
+ Decide per file what a two-way sync has to do
67
+ Params:
68
+ local: {path: hash} of the local files
69
+ remote: {path: hash} of the files on Overleaf
70
+ state: {path: {"local": hash, "remote": hash}} from the last successful sync
71
+ Returns: {"upload": [...], "download": [...], "conflict": [...], "in_sync": [...]} (sorted paths)
72
+ """
73
+ plan = {"upload": [], "download": [], "conflict": [], "in_sync": []}
74
+ for path in sorted(set(local) | set(remote)):
75
+ local_hash, remote_hash, last = local.get(path), remote.get(path), state.get(path)
76
+ if remote_hash is None:
77
+ action = "upload"
78
+ elif local_hash is None:
79
+ action = "download"
80
+ elif local_hash == remote_hash:
81
+ action = "in_sync"
82
+ elif last is None:
83
+ # No sync history for this file: never guess which version is newer
84
+ action = "conflict"
85
+ else:
86
+ local_changed = local_hash != last.get("local")
87
+ remote_changed = remote_hash != last.get("remote")
88
+ if local_changed and remote_changed:
89
+ action = "conflict"
90
+ elif local_changed:
91
+ action = "upload"
92
+ elif remote_changed:
93
+ action = "download"
94
+ else:
95
+ # Differs, but neither side changed since the last sync (e.g. Overleaf normalised the file)
96
+ action = "in_sync"
97
+ plan[action].append(path)
98
+ return plan
File without changes