overleaf-sync 2.0.0a2__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.0a2 → overleaf_sync-2.0.0a4}/PKG-INFO +14 -3
- {overleaf_sync-2.0.0a2 → overleaf_sync-2.0.0a4}/README.md +13 -2
- {overleaf_sync-2.0.0a2 → overleaf_sync-2.0.0a4}/olsync/__init__.py +1 -1
- {overleaf_sync-2.0.0a2 → overleaf_sync-2.0.0a4}/olsync/olbrowserlogin.py +13 -10
- {overleaf_sync-2.0.0a2 → overleaf_sync-2.0.0a4}/olsync/olclient.py +92 -37
- {overleaf_sync-2.0.0a2 → overleaf_sync-2.0.0a4}/olsync/olsync.py +168 -50
- overleaf_sync-2.0.0a4/olsync/syncstate.py +98 -0
- {overleaf_sync-2.0.0a2 → overleaf_sync-2.0.0a4}/LICENSE +0 -0
- {overleaf_sync-2.0.0a2 → 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>
|
|
@@ -78,11 +78,20 @@ Keep the `.olauth` file save, as it can be used to log in into your account.
|
|
|
78
78
|
On a server without a display, or if the login window doesn't work for your account (e.g. single sign-on via ORCID or your institution), log in with a session cookie instead:
|
|
79
79
|
|
|
80
80
|
1. Log in to Overleaf in your normal browser.
|
|
81
|
-
2. Open the developer tools → Application (Firefox: Storage) → Cookies → `https://www.overleaf.com` and copy the value of `overleaf_session2
|
|
81
|
+
2. Open the developer tools → Application (Firefox: Storage) → Cookies → `https://www.overleaf.com` and copy the value of `overleaf_session2` (on a self-hosted server: the cookie `overleaf.sid` of your server).
|
|
82
82
|
3. Run `ols login --cookie` and paste the value at the (hidden) prompt, or provide it via the `OLSYNC_COOKIE` environment variable.
|
|
83
83
|
|
|
84
84
|
This works without installing the `gui` extra.
|
|
85
85
|
|
|
86
|
+
### Self-hosted Overleaf servers
|
|
87
|
+
overleaf-sync also works with self-hosted Overleaf servers. It is tested against Overleaf Community Edition; Server Pro shares its code and should work too, but is untested. Pass the server's URL when logging in:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
moritz@github:~/test$ ols login --server https://overleaf.example.com
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The server is stored together with the login, so all other commands (`ols`, `ols list`, `ols download`) use it automatically. You can also set it with `--server` on any command or with the `OLSYNC_SERVER` environment variable. `ols login --cookie --server ...` works too; self-hosted servers name the session cookie `overleaf.sid` (`sharelatex.sid` before version 5).
|
|
94
|
+
|
|
86
95
|
### Listing all projects
|
|
87
96
|
```
|
|
88
97
|
moritz@github:~/test$ ols list [--store-path -v/--verbose]
|
|
@@ -106,7 +115,9 @@ Use `ols download` to compile and download your project's PDF. Specify a downloa
|
|
|
106
115
|
moritz@github:~/test$ ols [-l/--local-only -r/--remote-only --store-path -p/--path -i/--olignore]
|
|
107
116
|
```
|
|
108
117
|
|
|
109
|
-
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.
|
|
110
121
|
|
|
111
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.
|
|
112
123
|
|
|
@@ -49,11 +49,20 @@ Keep the `.olauth` file save, as it can be used to log in into your account.
|
|
|
49
49
|
On a server without a display, or if the login window doesn't work for your account (e.g. single sign-on via ORCID or your institution), log in with a session cookie instead:
|
|
50
50
|
|
|
51
51
|
1. Log in to Overleaf in your normal browser.
|
|
52
|
-
2. Open the developer tools → Application (Firefox: Storage) → Cookies → `https://www.overleaf.com` and copy the value of `overleaf_session2
|
|
52
|
+
2. Open the developer tools → Application (Firefox: Storage) → Cookies → `https://www.overleaf.com` and copy the value of `overleaf_session2` (on a self-hosted server: the cookie `overleaf.sid` of your server).
|
|
53
53
|
3. Run `ols login --cookie` and paste the value at the (hidden) prompt, or provide it via the `OLSYNC_COOKIE` environment variable.
|
|
54
54
|
|
|
55
55
|
This works without installing the `gui` extra.
|
|
56
56
|
|
|
57
|
+
### Self-hosted Overleaf servers
|
|
58
|
+
overleaf-sync also works with self-hosted Overleaf servers. It is tested against Overleaf Community Edition; Server Pro shares its code and should work too, but is untested. Pass the server's URL when logging in:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
moritz@github:~/test$ ols login --server https://overleaf.example.com
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The server is stored together with the login, so all other commands (`ols`, `ols list`, `ols download`) use it automatically. You can also set it with `--server` on any command or with the `OLSYNC_SERVER` environment variable. `ols login --cookie --server ...` works too; self-hosted servers name the session cookie `overleaf.sid` (`sharelatex.sid` before version 5).
|
|
65
|
+
|
|
57
66
|
### Listing all projects
|
|
58
67
|
```
|
|
59
68
|
moritz@github:~/test$ ols list [--store-path -v/--verbose]
|
|
@@ -77,7 +86,9 @@ Use `ols download` to compile and download your project's PDF. Specify a downloa
|
|
|
77
86
|
moritz@github:~/test$ ols [-l/--local-only -r/--remote-only --store-path -p/--path -i/--olignore]
|
|
78
87
|
```
|
|
79
88
|
|
|
80
|
-
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.
|
|
81
92
|
|
|
82
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.
|
|
83
94
|
|
|
@@ -13,13 +13,14 @@ from PySide6.QtWidgets import *
|
|
|
13
13
|
from PySide6.QtWebEngineWidgets import *
|
|
14
14
|
from PySide6.QtWebEngineCore import QWebEngineProfile, QWebEngineSettings, QWebEnginePage
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
BASE_URL = "https://www.overleaf.com" # The default Overleaf server
|
|
17
|
+
LOGIN_PATH = "/login" # Where the user logs in
|
|
18
|
+
PROJECT_PATH = "/project" # The dashboard, reached after a successful login
|
|
19
19
|
# JS snippet to extract the csrfToken
|
|
20
20
|
JAVASCRIPT_CSRF_EXTRACTOR = "document.getElementsByName('ol-csrfToken')[0].content"
|
|
21
|
-
# Name of the cookies we want to extract
|
|
22
|
-
|
|
21
|
+
# Name of the cookies we want to extract: overleaf.com's session and load balancer cookies, and the session cookie of
|
|
22
|
+
# self-hosted servers (Community Edition; "sharelatex.sid" before version 5)
|
|
23
|
+
COOKIE_NAMES = ["overleaf_session2", "GCLB", "overleaf.sid", "sharelatex.sid"]
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
class OlBrowserLoginWindow(QMainWindow):
|
|
@@ -28,9 +29,11 @@ class OlBrowserLoginWindow(QMainWindow):
|
|
|
28
29
|
Opens a browser window to securely login the user and returns relevant login data.
|
|
29
30
|
"""
|
|
30
31
|
|
|
31
|
-
def __init__(self, *args, **kwargs):
|
|
32
|
+
def __init__(self, base_url=BASE_URL, *args, **kwargs):
|
|
32
33
|
super(OlBrowserLoginWindow, self).__init__(*args, **kwargs)
|
|
33
34
|
|
|
35
|
+
self._base_url = base_url.rstrip("/")
|
|
36
|
+
|
|
34
37
|
self.webview = QWebEngineView()
|
|
35
38
|
|
|
36
39
|
self._cookies = {}
|
|
@@ -46,7 +49,7 @@ class OlBrowserLoginWindow(QMainWindow):
|
|
|
46
49
|
|
|
47
50
|
webpage = QWebEnginePage(self.profile, self)
|
|
48
51
|
self.webview.setPage(webpage)
|
|
49
|
-
self.webview.load(QUrl.fromUserInput(
|
|
52
|
+
self.webview.load(QUrl.fromUserInput(self._base_url + LOGIN_PATH))
|
|
50
53
|
self.webview.loadFinished.connect(self.handle_load_finished)
|
|
51
54
|
|
|
52
55
|
self.setCentralWidget(self.webview)
|
|
@@ -58,7 +61,7 @@ class OlBrowserLoginWindow(QMainWindow):
|
|
|
58
61
|
self._login_success = True
|
|
59
62
|
QCoreApplication.quit()
|
|
60
63
|
|
|
61
|
-
if self.webview.url().toString() ==
|
|
64
|
+
if self.webview.url().toString().rstrip("/") == self._base_url + PROJECT_PATH:
|
|
62
65
|
self.webview.page().runJavaScript(
|
|
63
66
|
JAVASCRIPT_CSRF_EXTRACTOR, 0, callback
|
|
64
67
|
)
|
|
@@ -81,14 +84,14 @@ class OlBrowserLoginWindow(QMainWindow):
|
|
|
81
84
|
return self._login_success
|
|
82
85
|
|
|
83
86
|
|
|
84
|
-
def login():
|
|
87
|
+
def login(base_url=BASE_URL):
|
|
85
88
|
from PySide6.QtCore import QLoggingCategory
|
|
86
89
|
QLoggingCategory.setFilterRules('''\
|
|
87
90
|
qt.webenginecontext.info=false
|
|
88
91
|
''')
|
|
89
92
|
|
|
90
93
|
app = QApplication([])
|
|
91
|
-
ol_browser_login_window = OlBrowserLoginWindow()
|
|
94
|
+
ol_browser_login_window = OlBrowserLoginWindow(base_url)
|
|
92
95
|
ol_browser_login_window.show()
|
|
93
96
|
app.exec()
|
|
94
97
|
|
|
@@ -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
|
|
@@ -24,18 +25,61 @@ import time
|
|
|
24
25
|
if not hasattr(websocket, "SSLError"):
|
|
25
26
|
websocket.SSLError = ssl.SSLError
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# The
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
BASE_URL = "https://www.overleaf.com" # The default Overleaf server; self-hosted servers are passed as base_url
|
|
29
|
+
# Paths relative to the server's base URL
|
|
30
|
+
LOGIN_PATH = "/login" # Where to get the CSRF Token and where to send the login request to
|
|
31
|
+
PROJECT_PATH = "/project" # The dashboard
|
|
32
|
+
DOWNLOAD_PATH = "/project/{}/download/zip" # Download all the files in zip format
|
|
33
|
+
UPLOAD_PATH = "/project/{}/upload" # Upload files
|
|
34
|
+
FOLDER_PATH = "/project/{}/folder" # Create folders
|
|
35
|
+
DELETE_PATH = "/project/{}/{}/{}" # Delete files (entity type: doc or file)
|
|
36
|
+
COMPILE_PATH = "/project/{}/compile?enable_pdf_caching=true" # Compile the project
|
|
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
|
+
|
|
69
|
+
class RealtimeSocket(SocketIO):
|
|
70
|
+
"""
|
|
71
|
+
socketIO-client's SocketIO, minus its cleanup quirk: SocketIO.__del__ calls disconnect(), which opens a brand-new
|
|
72
|
+
connection when the old one is already closed (and, with wait_for_connection=True, retries that forever).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __del__(self):
|
|
76
|
+
try:
|
|
77
|
+
if self.connected:
|
|
78
|
+
self.disconnect()
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
39
83
|
class OverleafClient(object):
|
|
40
84
|
"""
|
|
41
85
|
Overleaf API Wrapper
|
|
@@ -51,9 +95,17 @@ class OverleafClient(object):
|
|
|
51
95
|
if all(p.get(k) == v for k, v in more_attrs.items()):
|
|
52
96
|
yield p
|
|
53
97
|
|
|
54
|
-
def __init__(self, cookie=None, csrf=None):
|
|
98
|
+
def __init__(self, cookie=None, csrf=None, base_url=BASE_URL):
|
|
55
99
|
self._cookie = cookie # Store the cookie for authenticated requests
|
|
56
100
|
self._csrf = csrf # Store the CSRF token since it is needed for some requests
|
|
101
|
+
self._base_url = base_url.rstrip("/") # overleaf.com or a self-hosted server
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def base_url(self):
|
|
105
|
+
return self._base_url
|
|
106
|
+
|
|
107
|
+
def _url(self, path):
|
|
108
|
+
return self._base_url + path
|
|
57
109
|
|
|
58
110
|
def login(self, username, password):
|
|
59
111
|
"""
|
|
@@ -63,7 +115,7 @@ class OverleafClient(object):
|
|
|
63
115
|
Returns: Dict of cookie and CSRF
|
|
64
116
|
"""
|
|
65
117
|
|
|
66
|
-
get_login = reqs.get(
|
|
118
|
+
get_login = reqs.get(self._url(LOGIN_PATH))
|
|
67
119
|
self._csrf = BeautifulSoup(get_login.content, 'html.parser').find(
|
|
68
120
|
'input', {'name': '_csrf'}).get('value')
|
|
69
121
|
login_json = {
|
|
@@ -71,7 +123,7 @@ class OverleafClient(object):
|
|
|
71
123
|
"email": username,
|
|
72
124
|
"password": password
|
|
73
125
|
}
|
|
74
|
-
post_login = reqs.post(
|
|
126
|
+
post_login = reqs.post(self._url(LOGIN_PATH), json=login_json,
|
|
75
127
|
cookies=get_login.cookies)
|
|
76
128
|
|
|
77
129
|
# On a successful authentication the Overleaf API returns a new authenticated cookie.
|
|
@@ -84,7 +136,7 @@ class OverleafClient(object):
|
|
|
84
136
|
self._cookie['GCLB'] = get_login.cookies['GCLB']
|
|
85
137
|
|
|
86
138
|
# CSRF changes after making the login request, new CSRF token will be on the projects page
|
|
87
|
-
projects_page = reqs.get(
|
|
139
|
+
projects_page = reqs.get(self._url(PROJECT_PATH), cookies=self._cookie)
|
|
88
140
|
self._csrf = BeautifulSoup(projects_page.content, 'html.parser').find('meta', {'name': 'ol-csrfToken'}) \
|
|
89
141
|
.get('content')
|
|
90
142
|
|
|
@@ -114,7 +166,7 @@ class OverleafClient(object):
|
|
|
114
166
|
Get the CSRF token from the project dashboard, e.g. after logging in with a cookie copied from a browser
|
|
115
167
|
Returns: the CSRF token, or None if the session cookie is not (or no longer) valid
|
|
116
168
|
"""
|
|
117
|
-
projects_page =
|
|
169
|
+
projects_page = _request("GET", self._url(PROJECT_PATH), cookies=self._cookie, allow_redirects=False)
|
|
118
170
|
if not projects_page.ok or projects_page.is_redirect:
|
|
119
171
|
return None
|
|
120
172
|
csrf_tag = BeautifulSoup(projects_page.content, 'html.parser').find('meta', {'name': 'ol-csrfToken'})
|
|
@@ -127,7 +179,7 @@ class OverleafClient(object):
|
|
|
127
179
|
Get all of a user's active projects (= not archived and not trashed)
|
|
128
180
|
Returns: List of project objects
|
|
129
181
|
"""
|
|
130
|
-
projects_page =
|
|
182
|
+
projects_page = _request("GET", self._url(PROJECT_PATH), cookies=self._cookie)
|
|
131
183
|
json_content = OverleafClient._extract_projects(projects_page.content)
|
|
132
184
|
return list(OverleafClient.filter_projects(json_content))
|
|
133
185
|
|
|
@@ -138,7 +190,7 @@ class OverleafClient(object):
|
|
|
138
190
|
Returns: project object
|
|
139
191
|
"""
|
|
140
192
|
|
|
141
|
-
projects_page =
|
|
193
|
+
projects_page = _request("GET", self._url(PROJECT_PATH), cookies=self._cookie)
|
|
142
194
|
json_content = OverleafClient._extract_projects(projects_page.content)
|
|
143
195
|
return next(OverleafClient.filter_projects(json_content, {"name": project_name}), None)
|
|
144
196
|
|
|
@@ -148,7 +200,7 @@ class OverleafClient(object):
|
|
|
148
200
|
Params: project_id, the id of the project
|
|
149
201
|
Returns: bytes string (zip file)
|
|
150
202
|
"""
|
|
151
|
-
r =
|
|
203
|
+
r = _request("GET", self._url(DOWNLOAD_PATH.format(project_id)),
|
|
152
204
|
stream=True, cookies=self._cookie)
|
|
153
205
|
return r.content
|
|
154
206
|
|
|
@@ -171,7 +223,7 @@ class OverleafClient(object):
|
|
|
171
223
|
headers = {
|
|
172
224
|
"X-Csrf-Token": self._csrf
|
|
173
225
|
}
|
|
174
|
-
r =
|
|
226
|
+
r = _request("POST", self._url(FOLDER_PATH.format(project_id)),
|
|
175
227
|
cookies=self._cookie, headers=headers, json=params)
|
|
176
228
|
|
|
177
229
|
if r.ok:
|
|
@@ -218,8 +270,8 @@ class OverleafClient(object):
|
|
|
218
270
|
# (Built from whatever cookies are present rather than hardcoded names:
|
|
219
271
|
# a session may not carry every cookie -- this previously raised
|
|
220
272
|
# KeyError here even though overleaf_session2 alone is enough to connect.)
|
|
221
|
-
handshake =
|
|
222
|
-
|
|
273
|
+
handshake = _request("GET",
|
|
274
|
+
self._url(SOCKET_IO_PATH),
|
|
223
275
|
params={"t": int(time.time() * 1000)},
|
|
224
276
|
cookies=self._cookie,
|
|
225
277
|
)
|
|
@@ -237,10 +289,12 @@ class OverleafClient(object):
|
|
|
237
289
|
# handshake"); confirmed it rejects even when projectId is only on
|
|
238
290
|
# the WebSocket upgrade URL -- it must be on the initial
|
|
239
291
|
# /socket.io/1/ GET specifically.
|
|
240
|
-
socket_io =
|
|
241
|
-
|
|
292
|
+
socket_io = RealtimeSocket(
|
|
293
|
+
self._base_url,
|
|
242
294
|
params={'t': int(time.time()), 'projectId': project_id},
|
|
243
|
-
headers={'Cookie': cookie}
|
|
295
|
+
headers={'Cookie': cookie},
|
|
296
|
+
# Fail instead of retrying forever (and hanging the CLI) when the real-time service is unreachable
|
|
297
|
+
wait_for_connection=False
|
|
244
298
|
)
|
|
245
299
|
|
|
246
300
|
# Wait until we connect to the socket
|
|
@@ -255,15 +309,16 @@ class OverleafClient(object):
|
|
|
255
309
|
# .on() never registers -- it would return immediately without
|
|
256
310
|
# reading anything. Wait in short slices instead, stopping as soon as
|
|
257
311
|
# the event has actually been processed.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
socket_io.
|
|
312
|
+
try:
|
|
313
|
+
socket_io.on('joinProjectResponse', set_project_infos)
|
|
314
|
+
for _ in range(20):
|
|
315
|
+
if project_infos is not None:
|
|
316
|
+
break
|
|
317
|
+
socket_io.wait(seconds=1)
|
|
318
|
+
finally:
|
|
319
|
+
# Disconnect from the socket if still connected
|
|
320
|
+
if socket_io.connected:
|
|
321
|
+
socket_io.disconnect()
|
|
267
322
|
|
|
268
323
|
return project_infos
|
|
269
324
|
|
|
@@ -323,7 +378,7 @@ class OverleafClient(object):
|
|
|
323
378
|
}
|
|
324
379
|
|
|
325
380
|
# Upload the file to the predefined folder
|
|
326
|
-
r =
|
|
381
|
+
r = _request("POST", self._url(UPLOAD_PATH.format(project_id)), cookies=self._cookie, params=params, files=files)
|
|
327
382
|
|
|
328
383
|
# status_code is an int; comparing to str(200) was always False here,
|
|
329
384
|
# so this reported failure on every call regardless of the real
|
|
@@ -367,7 +422,7 @@ class OverleafClient(object):
|
|
|
367
422
|
"X-Csrf-Token": self._csrf
|
|
368
423
|
}
|
|
369
424
|
|
|
370
|
-
r =
|
|
425
|
+
r = _request("DELETE", self._url(DELETE_PATH.format(project_id, entity_type, file['_id'])), cookies=self._cookie,
|
|
371
426
|
headers=headers, json={})
|
|
372
427
|
|
|
373
428
|
# status_code is an int; comparing to str(204) was always False
|
|
@@ -396,7 +451,7 @@ class OverleafClient(object):
|
|
|
396
451
|
"stopOnFirstError": False
|
|
397
452
|
}
|
|
398
453
|
|
|
399
|
-
r =
|
|
454
|
+
r = _request("POST", self._url(COMPILE_PATH.format(project_id)), cookies=self._cookie, headers=headers, json=body)
|
|
400
455
|
|
|
401
456
|
if not r.ok:
|
|
402
457
|
raise reqs.HTTPError()
|
|
@@ -416,7 +471,7 @@ class OverleafClient(object):
|
|
|
416
471
|
if compile_result.get("clsiServerId"):
|
|
417
472
|
params["clsiserverid"] = compile_result["clsiServerId"]
|
|
418
473
|
|
|
419
|
-
download_req =
|
|
474
|
+
download_req = _request("GET", self._url(pdf_file['url']), cookies=self._cookie, headers=headers, params=params)
|
|
420
475
|
|
|
421
476
|
if download_req.ok:
|
|
422
477
|
return pdf_file['path'], download_req.content
|
|
@@ -23,10 +23,21 @@ from pathlib import Path
|
|
|
23
23
|
|
|
24
24
|
try:
|
|
25
25
|
# Import for pip installation / wheel
|
|
26
|
-
from olsync.olclient import 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 OverleafClient
|
|
30
|
+
from olclient import BASE_URL, OverleafClient, RateLimitError
|
|
31
|
+
import syncstate
|
|
32
|
+
|
|
33
|
+
# Session cookie names: overleaf.com, and self-hosted servers (Community Edition, "sharelatex.sid" before version 5)
|
|
34
|
+
OVERLEAF_COM_SESSION_COOKIES = ["overleaf_session2"]
|
|
35
|
+
SELF_HOSTED_SESSION_COOKIES = ["overleaf.sid", "sharelatex.sid"]
|
|
36
|
+
|
|
37
|
+
server_option = click.option(
|
|
38
|
+
'--server', 'server', default=None, envvar='OLSYNC_SERVER', metavar='URL',
|
|
39
|
+
help="URL of a self-hosted Overleaf server (default: the server used at login, or https://www.overleaf.com). "
|
|
40
|
+
"Can also be set with OLSYNC_SERVER.")
|
|
30
41
|
|
|
31
42
|
|
|
32
43
|
def configure_console():
|
|
@@ -54,20 +65,14 @@ def configure_console():
|
|
|
54
65
|
@click.option('-i', '--olignore', 'olignore_path', default=".olignore", type=click.Path(exists=False),
|
|
55
66
|
help="Path to the .olignore file relative to sync path (ignored if syncing from remote to local). See "
|
|
56
67
|
"fnmatch / unix filename pattern matching for information on how to use it.")
|
|
68
|
+
@server_option
|
|
57
69
|
@click.option('-v', '--verbose', 'verbose', is_flag=True, help="Enable extended error logging.")
|
|
58
70
|
@click.version_option(package_name='overleaf-sync')
|
|
59
71
|
@click.pass_context
|
|
60
|
-
def main(ctx, local, remote, project_name, cookie_path, sync_path, olignore_path, verbose):
|
|
72
|
+
def main(ctx, local, remote, project_name, cookie_path, sync_path, olignore_path, server, verbose):
|
|
61
73
|
configure_console()
|
|
62
74
|
if ctx.invoked_subcommand is None:
|
|
63
|
-
|
|
64
|
-
raise click.ClickException(
|
|
65
|
-
"Persisted Overleaf cookie not found. Please login or check store path.")
|
|
66
|
-
|
|
67
|
-
with open(cookie_path, 'rb') as f:
|
|
68
|
-
store = pickle.load(f)
|
|
69
|
-
|
|
70
|
-
overleaf_client = OverleafClient(store["cookie"], store["csrf"])
|
|
75
|
+
overleaf_client = load_client(cookie_path, server)
|
|
71
76
|
|
|
72
77
|
# Change the current directory to the specified sync path
|
|
73
78
|
os.chdir(sync_path)
|
|
@@ -95,9 +100,13 @@ def main(ctx, local, remote, project_name, cookie_path, sync_path, olignore_path
|
|
|
95
100
|
"Project could not be downloaded.",
|
|
96
101
|
verbose)
|
|
97
102
|
|
|
98
|
-
|
|
103
|
+
if not (local or remote):
|
|
104
|
+
two_way_sync(overleaf_client, project, project_infos, zip_file, olignore_path, verbose)
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
sync = False # -l and -r below sync in one direction only
|
|
99
108
|
|
|
100
|
-
if remote
|
|
109
|
+
if remote:
|
|
101
110
|
remote_files = zip_file.namelist()
|
|
102
111
|
deleted_files = [] if sync else [f for f in olignore_keep_list(olignore_path) if f not in remote_files]
|
|
103
112
|
sync_func(
|
|
@@ -114,8 +123,8 @@ def main(ctx, local, remote, project_name, cookie_path, sync_path, olignore_path
|
|
|
114
123
|
from_name="remote",
|
|
115
124
|
to_name="local",
|
|
116
125
|
verbose=verbose)
|
|
117
|
-
if local
|
|
118
|
-
# Computed once (#60)
|
|
126
|
+
if local:
|
|
127
|
+
# Computed once (#60)
|
|
119
128
|
local_files = olignore_keep_list(olignore_path)
|
|
120
129
|
deleted_files = [] if sync else [f for f in zip_file.namelist() if f not in local_files]
|
|
121
130
|
sync_func(
|
|
@@ -138,26 +147,31 @@ def main(ctx, local, remote, project_name, cookie_path, sync_path, olignore_path
|
|
|
138
147
|
@click.option('--path', 'cookie_path', default=".olauth", type=click.Path(exists=False),
|
|
139
148
|
help="Path to store the persisted Overleaf cookie.")
|
|
140
149
|
@click.option('--cookie', 'use_cookie', is_flag=True,
|
|
141
|
-
help="Log in without the login window, using the
|
|
142
|
-
"
|
|
143
|
-
"servers without a display and for single
|
|
150
|
+
help="Log in without the login window, using the session cookie from a browser that is logged in to "
|
|
151
|
+
"Overleaf (`overleaf_session2` on overleaf.com, `overleaf.sid` on self-hosted servers), asked for "
|
|
152
|
+
"at a hidden prompt or read from OLSYNC_COOKIE. Useful on servers without a display and for single "
|
|
153
|
+
"sign-on accounts.")
|
|
154
|
+
@server_option
|
|
144
155
|
@click.option('-v', '--verbose', 'verbose', is_flag=True, help="Enable extended error logging.")
|
|
145
|
-
|
|
156
|
+
@click.pass_context
|
|
157
|
+
def login(ctx, cookie_path, use_cookie, server, verbose):
|
|
158
|
+
server = resolve_login_server(ctx, server)
|
|
146
159
|
if os.path.isfile(cookie_path) and not click.confirm(
|
|
147
160
|
'Persisted Overleaf cookie already exist. Do you want to override it?'):
|
|
148
161
|
return
|
|
149
162
|
if use_cookie:
|
|
163
|
+
cookie_names = OVERLEAF_COM_SESSION_COOKIES if server == BASE_URL else SELF_HOSTED_SESSION_COOKIES
|
|
150
164
|
# Never accept the cookie as a command line argument, it would end up in the shell history
|
|
151
|
-
cookie = os.environ.get("OLSYNC_COOKIE") or click.prompt("Value of the
|
|
165
|
+
cookie = os.environ.get("OLSYNC_COOKIE") or click.prompt(f"Value of the {cookie_names[0]} cookie",
|
|
152
166
|
hide_input=True)
|
|
153
|
-
execute_action(lambda: cookie_login_handler(cookie.strip(), cookie_path), "Login",
|
|
167
|
+
execute_action(lambda: cookie_login_handler(cookie.strip(), cookie_path, server, cookie_names), "Login",
|
|
154
168
|
"Login successful. Cookie persisted as `" + click.format_filename(
|
|
155
169
|
cookie_path) + "`. You may now sync your project.",
|
|
156
170
|
"Login failed: the cookie is not valid (expired, or copied incompletely).", verbose)
|
|
157
171
|
return
|
|
158
172
|
olbrowserlogin = load_browser_login()
|
|
159
173
|
click.clear()
|
|
160
|
-
execute_action(lambda: login_handler(olbrowserlogin, cookie_path), "Login",
|
|
174
|
+
execute_action(lambda: login_handler(olbrowserlogin, cookie_path, server), "Login",
|
|
161
175
|
"Login successful. Cookie persisted as `" + click.format_filename(
|
|
162
176
|
cookie_path) + "`. You may now sync your project.",
|
|
163
177
|
"Login failed. Please try again.", verbose)
|
|
@@ -166,8 +180,10 @@ def login(cookie_path, use_cookie, verbose):
|
|
|
166
180
|
@main.command(name='list')
|
|
167
181
|
@click.option('--store-path', 'cookie_path', default=".olauth", type=click.Path(exists=False),
|
|
168
182
|
help="Relative path to load the persisted Overleaf cookie.")
|
|
183
|
+
@server_option
|
|
169
184
|
@click.option('-v', '--verbose', 'verbose', is_flag=True, help="Enable extended error logging.")
|
|
170
|
-
|
|
185
|
+
@click.pass_context
|
|
186
|
+
def list_projects(ctx, cookie_path, server, verbose):
|
|
171
187
|
def query_projects():
|
|
172
188
|
for index, p in enumerate(sorted(overleaf_client.all_projects(), key=lambda x: x['lastUpdated'], reverse=True)):
|
|
173
189
|
if not index:
|
|
@@ -175,14 +191,7 @@ def list_projects(cookie_path, verbose):
|
|
|
175
191
|
click.echo(f"{dateutil.parser.isoparse(p['lastUpdated']).strftime('%m/%d/%Y, %H:%M:%S')} - {p['name']}")
|
|
176
192
|
return True
|
|
177
193
|
|
|
178
|
-
|
|
179
|
-
raise click.ClickException(
|
|
180
|
-
"Persisted Overleaf cookie not found. Please login or check store path.")
|
|
181
|
-
|
|
182
|
-
with open(cookie_path, 'rb') as f:
|
|
183
|
-
store = pickle.load(f)
|
|
184
|
-
|
|
185
|
-
overleaf_client = OverleafClient(store["cookie"], store["csrf"])
|
|
194
|
+
overleaf_client = load_client(cookie_path, server or ctx.find_root().params.get("server"))
|
|
186
195
|
|
|
187
196
|
click.clear()
|
|
188
197
|
execute_action(query_projects, "Querying all projects",
|
|
@@ -196,8 +205,10 @@ def list_projects(cookie_path, verbose):
|
|
|
196
205
|
@click.option('--download-path', 'download_path', default=".", type=click.Path(exists=True))
|
|
197
206
|
@click.option('--store-path', 'cookie_path', default=".olauth", type=click.Path(exists=False),
|
|
198
207
|
help="Relative path to load the persisted Overleaf cookie.")
|
|
208
|
+
@server_option
|
|
199
209
|
@click.option('-v', '--verbose', 'verbose', is_flag=True, help="Enable extended error logging.")
|
|
200
|
-
|
|
210
|
+
@click.pass_context
|
|
211
|
+
def download_pdf(ctx, project_name, download_path, cookie_path, server, verbose):
|
|
201
212
|
def download_project_pdf():
|
|
202
213
|
nonlocal project_name
|
|
203
214
|
project_name = project_name or os.path.basename(os.getcwd())
|
|
@@ -217,14 +228,7 @@ def download_pdf(project_name, download_path, cookie_path, verbose):
|
|
|
217
228
|
|
|
218
229
|
return True
|
|
219
230
|
|
|
220
|
-
|
|
221
|
-
raise click.ClickException(
|
|
222
|
-
"Persisted Overleaf cookie not found. Please login or check store path.")
|
|
223
|
-
|
|
224
|
-
with open(cookie_path, 'rb') as f:
|
|
225
|
-
store = pickle.load(f)
|
|
226
|
-
|
|
227
|
-
overleaf_client = OverleafClient(store["cookie"], store["csrf"])
|
|
231
|
+
overleaf_client = load_client(cookie_path, server or ctx.find_root().params.get("server"))
|
|
228
232
|
|
|
229
233
|
click.clear()
|
|
230
234
|
|
|
@@ -262,23 +266,46 @@ def load_browser_login():
|
|
|
262
266
|
return olbrowserlogin
|
|
263
267
|
|
|
264
268
|
|
|
265
|
-
def
|
|
266
|
-
|
|
269
|
+
def resolve_login_server(ctx, server):
|
|
270
|
+
"""--server on the login command, else on the group (both also read OLSYNC_SERVER), else overleaf.com"""
|
|
271
|
+
return (server or ctx.find_root().params.get("server") or BASE_URL).rstrip("/")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def load_client(cookie_path, server=None):
|
|
275
|
+
"""
|
|
276
|
+
Create an OverleafClient from the persisted login. The server is --server/OLSYNC_SERVER if given, otherwise the
|
|
277
|
+
server stored at login (auth files from before 2.0.0a3 have none and mean overleaf.com).
|
|
278
|
+
"""
|
|
279
|
+
if not os.path.isfile(cookie_path):
|
|
280
|
+
raise click.ClickException(
|
|
281
|
+
"Persisted Overleaf cookie not found. Please login or check store path.")
|
|
282
|
+
|
|
283
|
+
with open(cookie_path, 'rb') as f:
|
|
284
|
+
store = pickle.load(f)
|
|
285
|
+
|
|
286
|
+
return OverleafClient(store["cookie"], store["csrf"], base_url=server or store.get("server") or BASE_URL)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def login_handler(olbrowserlogin, path, server):
|
|
290
|
+
store = olbrowserlogin.login(server)
|
|
267
291
|
if store is None:
|
|
268
292
|
return False
|
|
293
|
+
store["server"] = server
|
|
269
294
|
with open(path, 'wb+') as f:
|
|
270
295
|
pickle.dump(store, f)
|
|
271
296
|
return True
|
|
272
297
|
|
|
273
298
|
|
|
274
|
-
def cookie_login_handler(cookie, path):
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
299
|
+
def cookie_login_handler(cookie, path, server, cookie_names):
|
|
300
|
+
# Self-hosted servers name the session cookie differently depending on the version, so try each name
|
|
301
|
+
for cookie_name in cookie_names:
|
|
302
|
+
cookies = {cookie_name: cookie}
|
|
303
|
+
csrf = OverleafClient(cookies, base_url=server).fetch_csrf()
|
|
304
|
+
if csrf is not None:
|
|
305
|
+
with open(path, 'wb+') as f:
|
|
306
|
+
pickle.dump({"cookie": cookies, "csrf": csrf, "server": server}, f)
|
|
307
|
+
return True
|
|
308
|
+
return False
|
|
282
309
|
|
|
283
310
|
|
|
284
311
|
def delete_file(path):
|
|
@@ -305,6 +332,93 @@ def write_file(path, content):
|
|
|
305
332
|
f.write(content)
|
|
306
333
|
|
|
307
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
|
+
|
|
308
422
|
def sync_func(files_from, deleted_files, create_file_at_to, delete_file_at_to, create_file_at_from, from_exists_in_to,
|
|
309
423
|
from_equal_to_to, from_newer_than_to, from_name,
|
|
310
424
|
to_name, verbose=False):
|
|
@@ -415,6 +529,10 @@ def execute_action(action, progress_message, success_message, fail_message, verb
|
|
|
415
529
|
with yaspin(text=progress_message, color="green") as spinner:
|
|
416
530
|
try:
|
|
417
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))
|
|
418
536
|
except:
|
|
419
537
|
if verbose_error_logging:
|
|
420
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
|