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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overleaf-sync
3
- Version: 2.0.0a1
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]
@@ -1,3 +1,3 @@
1
1
  """Overleaf Two-Way Sync Tool"""
2
2
 
3
- __version__ = '2.0.0a1'
3
+ __version__ = '2.0.0a2'
@@ -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/{}/doc/{}" # The URL to delete files
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 = None
334
-
335
- # The file name contains path separators, check folders
336
- if PATH_SEP in file_name:
337
- local_folders = file_name.split(PATH_SEP)[:-1] # Remove last item since this is the file name
338
- current_overleaf_folder = project_infos['rootFolder'][0]['folders'] # Set the current remote folder
339
-
340
- for local_folder in local_folders:
341
- for remote_folder in current_overleaf_folder:
342
- if local_folder.lower() == remote_folder['name'].lower():
343
- file = next((v for v in remote_folder['docs'] if v['name'] == file_name.split(PATH_SEP)[-1]),
344
- None)
345
- current_overleaf_folder = remote_folder['folders']
346
- break
347
- # File is in root folder
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, headers=headers, json={})
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
- "rootDoc_id": "",
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
- download_req = reqs.get(BASE_URL + pdf_file['url'], cookies=self._cookie, headers=headers)
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=zip_file.namelist(),
103
- deleted_files=[f for f in olignore_keep_list(olignore_path) if f not in zip_file.namelist() and not sync],
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=olignore_keep_list(olignore_path),
118
- deleted_files=[f for f in zip_file.namelist() if f not in olignore_keep_list(olignore_path) and not sync],
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("PySide6"):
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 not (e.name or "").startswith("PySide6"):
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 needs PySide6. Install it with: pip install \"overleaf-sync[gui]\"")
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
- ignore_pattern = f.read().splitlines()
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