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.
- {overleaf_sync-2.0.0a3 → overleaf_sync-2.0.0a4}/PKG-INFO +4 -2
- {overleaf_sync-2.0.0a3 → overleaf_sync-2.0.0a4}/README.md +3 -1
- {overleaf_sync-2.0.0a3 → overleaf_sync-2.0.0a4}/olsync/__init__.py +1 -1
- {overleaf_sync-2.0.0a3 → overleaf_sync-2.0.0a4}/olsync/olclient.py +40 -10
- {overleaf_sync-2.0.0a3 → overleaf_sync-2.0.0a4}/olsync/olsync.py +103 -6
- overleaf_sync-2.0.0a4/olsync/syncstate.py +98 -0
- {overleaf_sync-2.0.0a3 → overleaf_sync-2.0.0a4}/LICENSE +0 -0
- {overleaf_sync-2.0.0a3 → overleaf_sync-2.0.0a4}/olsync/olbrowserlogin.py +0 -0
- {overleaf_sync-2.0.0a3 → overleaf_sync-2.0.0a4}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: overleaf-sync
|
|
3
|
-
Version: 2.0.
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
121
|
-
# Computed once (#60)
|
|
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
|
|
File without changes
|
|
File without changes
|