PKЭNZ77olsync/__init__.py"""Overleaf Two-Way Sync Tool""" __version__ = '1.0.1'PKN?ߔolsync/olclient.py"""Overleaf Two-Way Sync Tool""" ################################################## # MIT License ################################################## # File: olclient.py # Description: Overleaf API Wrapper # Author: Moritz Glöckl # License: MIT # Version: 1.0.0 ################################################## import requests as reqs from bs4 import BeautifulSoup import json import uuid LOGIN_URL = "https://www.overleaf.com/login" # Where to get the CSRF Token and where to send the login request to PROJECT_URL = "https://www.overleaf.com/project" # The dashboard URL DOWNLOAD_URL = "https://www.overleaf.com/project/{}/download/zip" # The URL to download all the files in zip format UPLOAD_URL = "https://www.overleaf.com/project/{}/upload" # The URL to upload files class OverleafClient(object): """ Overleaf API Wrapper Supports login, querying all projects, querying a specific project, downloading a project and uploading a file to a project. """ def __init__(self, cookie=None, csrf=None): self._cookie = cookie # Store the cookie for authenticated requests self._csrf = csrf # Store the CSRF token since it is needed for some requests def login(self, username, password): """ Login to the Overleaf Service with a username and a password Params: username, password Returns: Dict of cookie and CSRF """ get_login = reqs.get(LOGIN_URL) self._csrf = BeautifulSoup(get_login.content, 'html.parser').find('input', {'name': '_csrf'}).get('value') login_json = { "_csrf": self._csrf, "email": username, "password": password } post_login = reqs.post(LOGIN_URL, json=login_json, cookies=get_login.cookies) # On a successful authentication the Overleaf API returns a new authenticated cookie. # If the cookie is different than the cookie of the GET request the authentication was successful if post_login.status_code == 200 and get_login.cookies["overleaf_session"] != post_login.cookies[ "overleaf_session"]: self._cookie = post_login.cookies return {"cookie": self._cookie, "csrf": self._csrf} def all_projects(self): """ Get all of a user's active projects (= not archived) Returns: List of project objects """ projects_page = reqs.get(PROJECT_URL, cookies=self._cookie) json_content = json.loads( BeautifulSoup(projects_page.content, 'html.parser').find('script', {'id': 'data'}).contents[0]) return list(filter(lambda x: not x.get("archived"), json_content.get("projects"))) def get_project(self, project_name): """ Get a specific project by project_name Params: project_name, the name of the project Returns: project object """ projects_page = reqs.get(PROJECT_URL, cookies=self._cookie) json_content = json.loads( BeautifulSoup(projects_page.content, 'html.parser').find('script', {'id': 'data'}).contents[0]) return next( filter(lambda x: not x.get("archived") and x.get("name") == project_name, json_content.get("projects")), None) def download_project(self, project_id): """ Download project in zip format Params: project_id, the id of the project Returns: bytes string (zip file) """ r = reqs.get(DOWNLOAD_URL.format(project_id), stream=True, cookies=self._cookie) return r.content def upload_file(self, project_id, file_name, file_size, file): """ Upload a file to the project Params: project_id, the id of the project, file_name, how the file will be named, file_size, the size of the file in bytes, file, the file itself Returns: True on success, False on fail """ # To get the folder_id, we convert the hex project_id to int, subtract 1 and convert it back to hex params = { "folder_id": format(int(project_id, 16) - 1, 'x'), "_csrf": self._csrf, "qquuid": str(uuid.uuid4()), "qqfilename": file_name, "qqtotalfilesize": file_size, } files = { "qqfile": file } r = reqs.post(UPLOAD_URL.format(project_id), cookies=self._cookie, params=params, files=files) return r.status_code == 200 and json.loads(r.content)["success"] PKN ))olsync/olsync.py"""Overleaf Two-Way Sync Tool""" ################################################## # MIT License ################################################## # File: olsync.py # Description: Overleaf Two-Way Sync # Author: Moritz Glöckl # License: MIT # Version: 1.0.0 ################################################## import click import os from yaspin import yaspin from olsync.olclient import OverleafClient import pickle import zipfile import io import dateutil.parser @click.group(invoke_without_command=True) @click.option('-l', '--local-only', 'local', is_flag=True, help="Sync local project files to Overleaf only.") @click.option('-r', '--remote-only', 'remote', is_flag=True, help="Sync remote project files from Overleaf to local file system only.") @click.option('--store-path', 'cookie_path', default=".olauth", type=click.Path(exists=False), help="Path to load the persisted Overleaf cookie.") @click.option('-p', '--path', 'sync_path', default=".", type=click.Path(exists=True), help="Path of the project to sync.") @click.pass_context def main(ctx, local, remote, cookie_path, sync_path): if ctx.invoked_subcommand is None: if not os.path.isfile(cookie_path): raise click.ClickException("Persisted Overleaf cookie not found. Please login or check store path.") with open(cookie_path, 'rb') as f: store = pickle.load(f) overleaf_client = OverleafClient(store["cookie"], store["csrf"]) project = execute_action( lambda: overleaf_client.get_project(os.path.basename(os.path.join(sync_path, os.getcwd()))), "Querying project", "Project queried successfully.", "Project could not be queried.") zip_file = execute_action(lambda: zipfile.ZipFile(io.BytesIO(overleaf_client.download_project(project["id"]))) , "Downloading project", "Project downloaded successfully.", "Project could not be downloaded.") sync = not (local or remote) if remote or sync: sync_func(zip_file.namelist(), lambda name: write_file(os.path.join(sync_path, name), zip_file.read(name)), lambda name: os.path.isfile(os.path.join(sync_path, name)), lambda name: open(os.path.join(sync_path, name), 'rb').read() == zip_file.read(name), lambda name: dateutil.parser.isoparse(project["lastUpdated"]).timestamp() > os.path.getmtime( os.path.join(sync_path, name)), "remote", "local") if local or sync: sync_func( [f for f in os.listdir(sync_path) if os.path.isfile(os.path.join(sync_path, f)) and not f.startswith(".")], lambda name: overleaf_client.upload_file(project["id"], name, os.path.getsize(os.path.join(sync_path, name)), open(os.path.join(sync_path, name), 'rb')), lambda name: name in zip_file.namelist(), lambda name: open(os.path.join(sync_path, name), 'rb').read() == zip_file.read(name), lambda name: os.path.getmtime(os.path.join(sync_path, name)) > dateutil.parser.isoparse( project["lastUpdated"]).timestamp(), "local", "remote") @main.command() @click.option('-u', '--username', prompt=True, required=True, help="You Overleaf username. Will NOT be stored or used for anything else.") @click.option('-p', '--password', prompt=True, hide_input=True, required=True, help="You Overleaf password. Will NOT be stored or used for anything else.") @click.option('--path', 'cookie_path', default=".olauth", type=click.Path(exists=False), help="Path to store the persisted Overleaf cookie.") def login(username, password, cookie_path): if os.path.isfile(cookie_path) and not click.confirm( 'Persisted Overleaf cookie already exist. Do you want to override it?'): return click.clear() execute_action(lambda: login_handler(username, password, cookie_path), "Login", "Login successful. Cookie persisted as `" + click.format_filename( cookie_path) + "`. You may now sync your project.", "Login failed. Check username and/or password.") def login_handler(username, password, path): overleaf_client = OverleafClient() store = overleaf_client.login(username, password) if store is None: return False with open(path, 'wb+') as f: pickle.dump(store, f) return True def write_file(path, content): with open(path, 'wb+') as f: f.write(content) def sync_func(files_from, create_file_at_to, from_exists_in_to, from_equal_to_to, from_newer_than_to, from_name, to_name): click.echo("\nSyncing files from %s to %s" % (from_name, to_name)) click.echo("====================\n") for name in files_from: click.echo("[SYNCING] %s" % name) if from_exists_in_to(name): if not from_equal_to_to(name): if not from_newer_than_to(name) and not click.confirm( 'Warning %s file will be overwritten by %s. Continue?' % (to_name, from_name)): continue click.echo("%s syncing from %s to %s." % (name, from_name, to_name)) create_file_at_to(name) else: click.echo("%s file is equal to %s file. No sync necessary." % (name, to_name)) else: click.echo("%s does not exist on %s. Creating file." % (name, to_name)) create_file_at_to(name) click.echo("") click.echo("") click.echo("✅ Syncing files from %s to %s" % (from_name, to_name)) click.echo("") def execute_action(action, progress_message, success_message, fail_message): with yaspin(text=progress_message, color="green") as spinner: try: success = action() except: success = False if success: spinner.write(success_message) spinner.ok("✅ ") else: raise click.ClickException(fail_message) spinner.fail("💥 ") return success if __name__ == "__main__": main() PK!Hd&*.overleaf_sync-1.0.1.dist-info/entry_points.txtN+I/N.,())ʼd=ePKN [W//%overleaf_sync-1.0.1.dist-info/LICENSEMIT License Copyright (c) 2019 Moritz Glöckl Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. PK!HPO#overleaf_sync-1.0.1.dist-info/WHEEL HM K-*ϳR03rOK-J,/RH,szd&Y)r$[)T&UrPK!H* &overleaf_sync-1.0.1.dist-info/METADATAWrSD`b&j DBj`Њ !pInbQ,(]ߢI]GX3,wwsv9m>}'-UuF(_3R)Ep*w޷I7ul(({PuҔ)U:Wj-:_֪mk}/ezZתR?v64(b>DE,Heފoڕjhlenu.3zFN^Q[ld#;B9g(tȺ@UZ7>иr!3u}fG*![C̥ fOySiLlZVKrYϫ7RgЭ~+M+RMmkY=ݭ,-,(!eK r4V9C8/ޏ>@F;ٱJ^IQε'U6/E~N`7?x~yJ/ާwY+} 縺5NBrw%z[3&WwWSToﭘIVRS&&f MyE+ʴ8EiѨ54,UJU%M+"Eu.T-UZcx⏩A"o7کrTnsn9<4N؝jW9,(GVR `Z5w@+Pr-oy9 &+Ւnn1vY.eE$1gd^j:y8˩Aqԝ=z_NE8fnTbUc޾})Ǖz`)&mXWjYk{t/sbjzWt 5qfcv~Y'eM|r7dŽI# Z!l M膝%οG ,>#P]rع'[ўzO9sؒ'qoRrm6vqX_ FQ`i vO|wv6ԼzI#Cg@U`{ww]LnouZ%^ߜiwzF߳; ?>ȸB<fQG@MZ9ˉG1#TB ni”#'qŒ b2A0ɘ~=蛌jffC?PK!H{#\$overleaf_sync-1.0.1.dist-info/RECORDIs@ཿ C" 6he2MQdjA׿JY}ST^0Ϊb̒鮱 %Բ&DHtVIjPHYEƹ$-.NEvȟ̯:~}@|yF169V(F'RM =y&y9SWPqs[CKXM.*__NYE;QӬKzR"0O时9V(1I k9>z؝UpK"{W0\}wLz]1<'?{B~؛(auT\g/m6@s3;bdEfjC[LSL}URh'CD,2v0PKЭNZ77olsync/__init__.pyPKN?ߔgolsync/olclient.pyPKN ))+olsync/olsync.pyPK!Hd&*.+overleaf_sync-1.0.1.dist-info/entry_points.txtPKN [W//%+overleaf_sync-1.0.1.dist-info/LICENSEPK!HPO#f0overleaf_sync-1.0.1.dist-info/WHEELPK!H* &0overleaf_sync-1.0.1.dist-info/METADATAPK!H{#\$:overleaf_sync-1.0.1.dist-info/RECORDPKd<