overleaf-sync 2.0.0a1__tar.gz → 2.0.0a2__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.0a1 → overleaf_sync-2.0.0a2}/PKG-INFO +10 -1
- {overleaf_sync-2.0.0a1 → overleaf_sync-2.0.0a2}/README.md +9 -0
- {overleaf_sync-2.0.0a1 → overleaf_sync-2.0.0a2}/olsync/__init__.py +1 -1
- {overleaf_sync-2.0.0a1 → overleaf_sync-2.0.0a2}/olsync/olclient.py +41 -21
- {overleaf_sync-2.0.0a1 → overleaf_sync-2.0.0a2}/olsync/olsync.py +47 -11
- {overleaf_sync-2.0.0a1 → overleaf_sync-2.0.0a2}/LICENSE +0 -0
- {overleaf_sync-2.0.0a1 → overleaf_sync-2.0.0a2}/olsync/olbrowserlogin.py +0 -0
- {overleaf_sync-2.0.0a1 → overleaf_sync-2.0.0a2}/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.0a2
|
|
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>
|
|
@@ -74,6 +74,15 @@ It then stores your *cookie* (**not** your login credentials) in a hidden file c
|
|
|
74
74
|
|
|
75
75
|
Keep the `.olauth` file save, as it can be used to log in into your account.
|
|
76
76
|
|
|
77
|
+
#### Login without the login window
|
|
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
|
+
|
|
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`.
|
|
82
|
+
3. Run `ols login --cookie` and paste the value at the (hidden) prompt, or provide it via the `OLSYNC_COOKIE` environment variable.
|
|
83
|
+
|
|
84
|
+
This works without installing the `gui` extra.
|
|
85
|
+
|
|
77
86
|
### Listing all projects
|
|
78
87
|
```
|
|
79
88
|
moritz@github:~/test$ ols list [--store-path -v/--verbose]
|
|
@@ -45,6 +45,15 @@ It then stores your *cookie* (**not** your login credentials) in a hidden file c
|
|
|
45
45
|
|
|
46
46
|
Keep the `.olauth` file save, as it can be used to log in into your account.
|
|
47
47
|
|
|
48
|
+
#### Login without the login window
|
|
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
|
+
|
|
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`.
|
|
53
|
+
3. Run `ols login --cookie` and paste the value at the (hidden) prompt, or provide it via the `OLSYNC_COOKIE` environment variable.
|
|
54
|
+
|
|
55
|
+
This works without installing the `gui` extra.
|
|
56
|
+
|
|
48
57
|
### Listing all projects
|
|
49
58
|
```
|
|
50
59
|
moritz@github:~/test$ ols list [--store-path -v/--verbose]
|
|
@@ -31,7 +31,7 @@ PROJECT_URL = "https://www.overleaf.com/project" # The dashboard URL
|
|
|
31
31
|
DOWNLOAD_URL = "https://www.overleaf.com/project/{}/download/zip"
|
|
32
32
|
UPLOAD_URL = "https://www.overleaf.com/project/{}/upload" # The URL to upload files
|
|
33
33
|
FOLDER_URL = "https://www.overleaf.com/project/{}/folder" # The URL to create folders
|
|
34
|
-
DELETE_URL = "https://www.overleaf.com/project/{}/
|
|
34
|
+
DELETE_URL = "https://www.overleaf.com/project/{}/{}/{}" # The URL to delete files (entity type: doc or file)
|
|
35
35
|
COMPILE_URL = "https://www.overleaf.com/project/{}/compile?enable_pdf_caching=true" # The URL to compile the project
|
|
36
36
|
BASE_URL = "https://www.overleaf.com" # The Overleaf Base URL
|
|
37
37
|
PATH_SEP = "/" # Use hardcoded path separator for both windows and posix system
|
|
@@ -109,6 +109,19 @@ class OverleafClient(object):
|
|
|
109
109
|
json_content = json_content.get('projects', [])
|
|
110
110
|
return json_content
|
|
111
111
|
|
|
112
|
+
def fetch_csrf(self):
|
|
113
|
+
"""
|
|
114
|
+
Get the CSRF token from the project dashboard, e.g. after logging in with a cookie copied from a browser
|
|
115
|
+
Returns: the CSRF token, or None if the session cookie is not (or no longer) valid
|
|
116
|
+
"""
|
|
117
|
+
projects_page = reqs.get(PROJECT_URL, cookies=self._cookie, allow_redirects=False)
|
|
118
|
+
if not projects_page.ok or projects_page.is_redirect:
|
|
119
|
+
return None
|
|
120
|
+
csrf_tag = BeautifulSoup(projects_page.content, 'html.parser').find('meta', {'name': 'ol-csrfToken'})
|
|
121
|
+
if csrf_tag is None:
|
|
122
|
+
return None
|
|
123
|
+
return csrf_tag.get('content')
|
|
124
|
+
|
|
112
125
|
def all_projects(self):
|
|
113
126
|
"""
|
|
114
127
|
Get all of a user's active projects (= not archived and not trashed)
|
|
@@ -330,23 +343,21 @@ class OverleafClient(object):
|
|
|
330
343
|
Returns: True on success, False on fail
|
|
331
344
|
"""
|
|
332
345
|
|
|
333
|
-
file
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
else:
|
|
349
|
-
file = next((v for v in project_infos['rootFolder'][0]['docs'] if v['name'] == file_name), None)
|
|
346
|
+
# Walk down to the folder that contains the file (folder names match case-insensitively, as in upload_file)
|
|
347
|
+
*local_folders, name = file_name.split(PATH_SEP)
|
|
348
|
+
current_folder = project_infos['rootFolder'][0]
|
|
349
|
+
for local_folder in local_folders:
|
|
350
|
+
current_folder = next((f for f in current_folder['folders'] if f['name'].lower() == local_folder.lower()),
|
|
351
|
+
None)
|
|
352
|
+
if current_folder is None:
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
# Text files are "docs", binary files (images, PDFs, ...) are "fileRefs" with their own endpoint
|
|
356
|
+
file = next((v for v in current_folder.get('docs', []) if v['name'] == name), None)
|
|
357
|
+
entity_type = "doc"
|
|
358
|
+
if file is None:
|
|
359
|
+
file = next((v for v in current_folder.get('fileRefs', []) if v['name'] == name), None)
|
|
360
|
+
entity_type = "file"
|
|
350
361
|
|
|
351
362
|
# File not found!
|
|
352
363
|
if file is None:
|
|
@@ -356,7 +367,8 @@ class OverleafClient(object):
|
|
|
356
367
|
"X-Csrf-Token": self._csrf
|
|
357
368
|
}
|
|
358
369
|
|
|
359
|
-
r = reqs.delete(DELETE_URL.format(project_id, file['_id']), cookies=self._cookie,
|
|
370
|
+
r = reqs.delete(DELETE_URL.format(project_id, entity_type, file['_id']), cookies=self._cookie,
|
|
371
|
+
headers=headers, json={})
|
|
360
372
|
|
|
361
373
|
# status_code is an int; comparing to str(204) was always False
|
|
362
374
|
# regardless of the real result, same bug class as upload_file and
|
|
@@ -380,7 +392,7 @@ class OverleafClient(object):
|
|
|
380
392
|
"check": "silent",
|
|
381
393
|
"draft": False,
|
|
382
394
|
"incrementalCompilesEnabled": True,
|
|
383
|
-
|
|
395
|
+
# No rootDoc_id: Overleaf validates it as an optional ObjectId and rejects "" (400)
|
|
384
396
|
"stopOnFirstError": False
|
|
385
397
|
}
|
|
386
398
|
|
|
@@ -396,7 +408,15 @@ class OverleafClient(object):
|
|
|
396
408
|
|
|
397
409
|
pdf_file = next(v for v in compile_result['outputFiles'] if v['type'] == 'pdf')
|
|
398
410
|
|
|
399
|
-
|
|
411
|
+
# The output is only served (otherwise 404) when the compile group and CLSI server are passed along,
|
|
412
|
+
# like Overleaf's own frontend does. Self-hosted servers may not send them.
|
|
413
|
+
params = {}
|
|
414
|
+
if compile_result.get("compileGroup"):
|
|
415
|
+
params["compileGroup"] = compile_result["compileGroup"]
|
|
416
|
+
if compile_result.get("clsiServerId"):
|
|
417
|
+
params["clsiserverid"] = compile_result["clsiServerId"]
|
|
418
|
+
|
|
419
|
+
download_req = reqs.get(BASE_URL + pdf_file['url'], cookies=self._cookie, headers=headers, params=params)
|
|
400
420
|
|
|
401
421
|
if download_req.ok:
|
|
402
422
|
return pdf_file['path'], download_req.content
|
|
@@ -98,9 +98,11 @@ def main(ctx, local, remote, project_name, cookie_path, sync_path, olignore_path
|
|
|
98
98
|
sync = not (local or remote)
|
|
99
99
|
|
|
100
100
|
if remote or sync:
|
|
101
|
+
remote_files = zip_file.namelist()
|
|
102
|
+
deleted_files = [] if sync else [f for f in olignore_keep_list(olignore_path) if f not in remote_files]
|
|
101
103
|
sync_func(
|
|
102
|
-
files_from=
|
|
103
|
-
deleted_files=
|
|
104
|
+
files_from=remote_files,
|
|
105
|
+
deleted_files=deleted_files,
|
|
104
106
|
create_file_at_to=lambda name: write_file(name, zip_file.read(name)),
|
|
105
107
|
delete_file_at_to=lambda name: delete_file(name),
|
|
106
108
|
create_file_at_from=lambda name: overleaf_client.upload_file(
|
|
@@ -113,9 +115,12 @@ def main(ctx, local, remote, project_name, cookie_path, sync_path, olignore_path
|
|
|
113
115
|
to_name="local",
|
|
114
116
|
verbose=verbose)
|
|
115
117
|
if local or sync:
|
|
118
|
+
# Computed once (#60), and after the remote -> local pass so newly downloaded files are included
|
|
119
|
+
local_files = olignore_keep_list(olignore_path)
|
|
120
|
+
deleted_files = [] if sync else [f for f in zip_file.namelist() if f not in local_files]
|
|
116
121
|
sync_func(
|
|
117
|
-
files_from=
|
|
118
|
-
deleted_files=
|
|
122
|
+
files_from=local_files,
|
|
123
|
+
deleted_files=deleted_files,
|
|
119
124
|
create_file_at_to=lambda name: overleaf_client.upload_file(
|
|
120
125
|
project["id"], project_infos, name, os.path.getsize(name), open(name, 'rb')),
|
|
121
126
|
delete_file_at_to=lambda name: overleaf_client.delete_file(project["id"], project_infos, name),
|
|
@@ -132,11 +137,24 @@ def main(ctx, local, remote, project_name, cookie_path, sync_path, olignore_path
|
|
|
132
137
|
@main.command()
|
|
133
138
|
@click.option('--path', 'cookie_path', default=".olauth", type=click.Path(exists=False),
|
|
134
139
|
help="Path to store the persisted Overleaf cookie.")
|
|
140
|
+
@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.")
|
|
135
144
|
@click.option('-v', '--verbose', 'verbose', is_flag=True, help="Enable extended error logging.")
|
|
136
|
-
def login(cookie_path, verbose):
|
|
145
|
+
def login(cookie_path, use_cookie, verbose):
|
|
137
146
|
if os.path.isfile(cookie_path) and not click.confirm(
|
|
138
147
|
'Persisted Overleaf cookie already exist. Do you want to override it?'):
|
|
139
148
|
return
|
|
149
|
+
if use_cookie:
|
|
150
|
+
# 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",
|
|
152
|
+
hide_input=True)
|
|
153
|
+
execute_action(lambda: cookie_login_handler(cookie.strip(), cookie_path), "Login",
|
|
154
|
+
"Login successful. Cookie persisted as `" + click.format_filename(
|
|
155
|
+
cookie_path) + "`. You may now sync your project.",
|
|
156
|
+
"Login failed: the cookie is not valid (expired, or copied incompletely).", verbose)
|
|
157
|
+
return
|
|
140
158
|
olbrowserlogin = load_browser_login()
|
|
141
159
|
click.clear()
|
|
142
160
|
execute_action(lambda: login_handler(olbrowserlogin, cookie_path), "Login",
|
|
@@ -225,15 +243,22 @@ def load_browser_login():
|
|
|
225
243
|
# Import for pip installation / wheel
|
|
226
244
|
import olsync.olbrowserlogin as olbrowserlogin
|
|
227
245
|
except ImportError as e:
|
|
228
|
-
if (e.name or "").startswith("
|
|
246
|
+
if not (e.name or "").startswith("olsync"):
|
|
229
247
|
raise
|
|
230
|
-
# Import for development
|
|
248
|
+
# Import for development (running olsync.py from inside the olsync folder)
|
|
231
249
|
import olbrowserlogin
|
|
232
250
|
except ImportError as e:
|
|
233
|
-
if
|
|
234
|
-
raise
|
|
251
|
+
if (e.name or "").startswith("PySide6"):
|
|
252
|
+
raise click.ClickException(
|
|
253
|
+
"The login window needs PySide6. Install it with: pip install \"overleaf-sync[gui]\"\n"
|
|
254
|
+
"Alternatively, log in without the window: ols login --cookie")
|
|
255
|
+
# PySide6 is installed but can't be loaded, e.g. because another Qt installation (such as Anaconda's)
|
|
256
|
+
# conflicts with it (#59)
|
|
235
257
|
raise click.ClickException(
|
|
236
|
-
"The login window
|
|
258
|
+
f"The login window could not be loaded: {e}\n"
|
|
259
|
+
"This usually means another Qt installation (e.g. from Anaconda) conflicts with PySide6. Installing "
|
|
260
|
+
"overleaf-sync into a fresh virtual environment based on a non-conda Python usually helps.\n"
|
|
261
|
+
"Alternatively, log in without the window: ols login --cookie")
|
|
237
262
|
return olbrowserlogin
|
|
238
263
|
|
|
239
264
|
|
|
@@ -246,6 +271,16 @@ def login_handler(olbrowserlogin, path):
|
|
|
246
271
|
return True
|
|
247
272
|
|
|
248
273
|
|
|
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
|
|
282
|
+
|
|
283
|
+
|
|
249
284
|
def delete_file(path):
|
|
250
285
|
_dir = os.path.dirname(path)
|
|
251
286
|
if _dir == path:
|
|
@@ -410,7 +445,8 @@ def olignore_keep_list(olignore_path):
|
|
|
410
445
|
else:
|
|
411
446
|
click.echo("\n.olignore: using %s to filter items" % olignore_path)
|
|
412
447
|
with open(olignore_path, 'r') as f:
|
|
413
|
-
|
|
448
|
+
# "./main.tex" and "main.tex" mean the same file, matched paths have no "./" prefix (#72)
|
|
449
|
+
ignore_pattern = [pattern.removeprefix("./") for pattern in f.read().splitlines()]
|
|
414
450
|
|
|
415
451
|
keep_list = [f for f in files if not any(
|
|
416
452
|
fnmatch.fnmatch(f, ignore) for ignore in ignore_pattern)]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|