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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overleaf-sync
3
- Version: 2.0.0a2
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. 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.
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. 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.
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
 
@@ -1,3 +1,3 @@
1
1
  """Overleaf Two-Way Sync Tool"""
2
2
 
3
- __version__ = '2.0.0a2'
3
+ __version__ = '2.0.0a4'
@@ -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
- # Where to get the CSRF Token and where to send the login request to
17
- LOGIN_URL = "https://www.overleaf.com/login"
18
- PROJECT_URL = "https://www.overleaf.com/project" # The dashboard URL
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
- COOKIE_NAMES = ["overleaf_session2", "GCLB"]
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(LOGIN_URL))
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() == PROJECT_URL:
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
- # Where to get the CSRF Token and where to send the login request to
28
- LOGIN_URL = "https://www.overleaf.com/login"
29
- PROJECT_URL = "https://www.overleaf.com/project" # The dashboard URL
30
- # The URL to download all the files in zip format
31
- DOWNLOAD_URL = "https://www.overleaf.com/project/{}/download/zip"
32
- UPLOAD_URL = "https://www.overleaf.com/project/{}/upload" # The URL to upload files
33
- FOLDER_URL = "https://www.overleaf.com/project/{}/folder" # The URL to create folders
34
- DELETE_URL = "https://www.overleaf.com/project/{}/{}/{}" # The URL to delete files (entity type: doc or file)
35
- COMPILE_URL = "https://www.overleaf.com/project/{}/compile?enable_pdf_caching=true" # The URL to compile the project
36
- BASE_URL = "https://www.overleaf.com" # The Overleaf Base URL
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(LOGIN_URL)
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(LOGIN_URL, json=login_json,
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(PROJECT_URL, cookies=self._cookie)
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 = reqs.get(PROJECT_URL, cookies=self._cookie, allow_redirects=False)
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 = reqs.get(PROJECT_URL, cookies=self._cookie)
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 = reqs.get(PROJECT_URL, cookies=self._cookie)
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 = reqs.get(DOWNLOAD_URL.format(project_id),
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 = reqs.post(FOLDER_URL.format(project_id),
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 = reqs.get(
222
- "{}/socket.io/1/".format(BASE_URL),
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 = SocketIO(
241
- BASE_URL,
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
- socket_io.on('joinProjectResponse', set_project_infos)
259
- for _ in range(20):
260
- if project_infos is not None:
261
- break
262
- socket_io.wait(seconds=1)
263
-
264
- # Disconnect from the socket if still connected
265
- if socket_io.connected:
266
- socket_io.disconnect()
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 = reqs.post(UPLOAD_URL.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)
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 = reqs.delete(DELETE_URL.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,
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 = reqs.post(COMPILE_URL.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)
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 = reqs.get(BASE_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)
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
- if not os.path.isfile(cookie_path):
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
- 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
106
+
107
+ sync = False # -l and -r below sync in one direction only
99
108
 
100
- if remote or sync:
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 or sync:
118
- # Computed once (#60), and after the remote -> local pass so newly downloaded files are included
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 `overleaf_session2` cookie from a browser that is "
142
- "logged in to Overleaf (asked for at a hidden prompt, or read from OLSYNC_COOKIE). Useful on "
143
- "servers without a display and for single sign-on accounts.")
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
- def login(cookie_path, use_cookie, verbose):
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 overleaf_session2 cookie",
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
- def list_projects(cookie_path, verbose):
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
- if not os.path.isfile(cookie_path):
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
- def download_pdf(project_name, download_path, cookie_path, verbose):
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
- if not os.path.isfile(cookie_path):
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 login_handler(olbrowserlogin, path):
266
- store = olbrowserlogin.login()
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
- cookies = {"overleaf_session2": cookie}
276
- csrf = OverleafClient(cookies).fetch_csrf()
277
- if csrf is None:
278
- return False
279
- with open(path, 'wb+') as f:
280
- pickle.dump({"cookie": cookies, "csrf": csrf}, f)
281
- return True
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