├── .gitignore ├── LICENSE ├── README.md ├── olsync ├── __init__.py ├── olbrowserlogin.py ├── olclient.py └── olsync.py ├── pyproject.toml └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | pip-wheel-metadata/ 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | #PyCharm 108 | .idea/ 109 | 110 | # vscode 111 | .vscode 112 | .DS_Store 113 | 114 | # dev testing 115 | setup.py 116 | test* 117 | .olauth 118 | .olignore 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Moritz Glöckl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overleaf-Sync 2 | ### Easy Overleaf Two-Way Synchronization 3 | 4 | ![Made In Austria](https://img.shields.io/badge/Made%20in-Austria-%23ED2939.svg) ![PyPI - License](https://img.shields.io/pypi/l/overleaf-sync.svg) ![PyPI](https://img.shields.io/pypi/v/overleaf-sync.svg) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/overleaf-sync.svg) 5 | 6 | This tool provides an easy way to synchronize Overleaf projects from and to your local computer. No paid account necessary. 7 | 8 | ---- 9 | 10 | ## Features 11 | - Sync your locally modified `.tex` (and other) files to your Overleaf projects 12 | - Sync your remotely modified `.tex` (and other) files to computer 13 | - Works with free Overleaf account 14 | - No Git or Dropbox required 15 | - Does not steal or store your login credentials (works with a persisted cookie, logging in is done on the original Overleaf website) 16 | 17 | ## How To Use 18 | ### Install 19 | The package is available via [PyPI](https://pypi.org/project/overleaf-sync/). Just run: 20 | 21 | ``` 22 | moritz@github:~/test$ pip3 install overleaf-sync 23 | ``` 24 | 25 | That's it! Depending on your local Python installation, you might need to use `pip` instead of `pip3`. 26 | 27 | ### Prerequisites 28 | - Create your project on [Overleaf](https://www.overleaf.com/project), for example a project named `test`. Overleaf-sync is not able to create projects (yet). 29 | - Create a folder, preferably with the same name as the project (`test`) on your computer. 30 | - Execute the script from that folder (`test`). 31 | - If you do not specify the project name, overleaf-sync uses the current folder's name as the project name. 32 | 33 | ### Usage 34 | #### Login 35 | ``` 36 | moritz@github:~/test$ ols login [--path] 37 | Login successful. Cookie persisted as `.olauth`. You may now sync your project. 38 | ``` 39 | 40 | Logging in will be handled by a mini web browser opening on your device (using Qt5). You can then enter your username and password securely on the official Overleaf website. You might get asked to solve a CAPTCHA in the process. Your credentials are sent to Overleaf over HTTPS. 41 | 42 | It then stores your *cookie* (**not** your login credentials) in a hidden file called `.olauth` in the same folder you run the command from. It is possible to store the cookie elsewhere using the `--path` option. The cookie file will not be synced to or from Overleaf. 43 | 44 | Keep the `.olauth` file save, as it can be used to log in into your account. 45 | 46 | ### Listing all projects 47 | ``` 48 | moritz@github:~/test$ ols list [--store-path -v/--verbose] 49 | 10/31/2021, 01:23:45 - Project A 50 | 09/21/2020, 01:23:45 - Project B 51 | 08/11/2019, 01:23:45 - Project C 52 | 07/01/2018, 01:23:45 - Project D 53 | ``` 54 | 55 | Use `ols list` to conveniently list all projects in your account available for syncing. 56 | 57 | ### Downloading project's PDF 58 | ``` 59 | moritz@github:~/test$ ols download [--name --download-path --store-path -v/--verbose] 60 | ``` 61 | 62 | Use `ols download` to compile and download your project's PDF. Specify a download path if you do not want to store the PDF file in the current folder. Currently only downloads the first PDF file it finds. 63 | 64 | ### Syncing 65 | ``` 66 | moritz@github:~/test$ ols [-l/--local-only -r/--remote-only --store-path -p/--path -i/--olignore] 67 | ``` 68 | 69 | 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. 70 | 71 | 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. 72 | 73 | Sample Output: 74 | 75 | ``` 76 | Project queried successfully. 77 | ✅ Querying project 78 | Project downloaded successfully. 79 | ✅ Downloading project 80 | 81 | Syncing files from remote to local 82 | ==================== 83 | 84 | [SYNCING] report.tex 85 | report.tex does not exist on local. Creating file. 86 | 87 | [SYNCING] other-report.tex 88 | other-report.tex does not exist on local. Creating file. 89 | 90 | 91 | ✅ Syncing files from remote to local 92 | ``` 93 | 94 | ## Known Bugs 95 | - When modifying a file on Overleaf and immediately syncing afterwards, the tool might not detect the changes. Please allow 1-2 minutes after modifying a file on Overleaf before syncing it to your local computer. 96 | 97 | ## Contributing 98 | 99 | All pull requests and change/feature requests are welcome. 100 | 101 | ## Disclaimer 102 | THE AUTHOR OF THIS SOFTWARE AND THIS SOFTWARE IS NOT ENDORSED BY, DIRECTLY AFFILIATED WITH, MAINTAINED, AUTHORIZED, OR SPONSORED BY OVERLEAF OR WRITELATEX LIMITED. ALL PRODUCT AND COMPANY NAMES ARE THE REGISTERED TRADEMARKS OF THEIR ORIGINAL OWNERS. THE USE OF ANY TRADE NAME OR TRADEMARK IS FOR IDENTIFICATION AND REFERENCE PURPOSES ONLY AND DOES NOT IMPLY ANY ASSOCIATION WITH THE TRADEMARK HOLDER OF THEIR PRODUCT BRAND. 103 | 104 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 105 | 106 | THIS SOFTWARE WAS DESIGNED TO BE USED ONLY FOR RESEARCH PURPOSES. THIS SOFTWARE COMES WITH NO WARRANTIES OF ANY KIND WHATSOEVER. USE IT AT YOUR OWN RISK! IF THESE TERMS ARE NOT ACCEPTABLE, YOU AREN'T ALLOWED TO USE THE CODE. 107 | 108 | -------------------------------------------------------------------------------- /olsync/__init__.py: -------------------------------------------------------------------------------- 1 | """Overleaf Two-Way Sync Tool""" 2 | 3 | __version__ = '1.2.0' 4 | -------------------------------------------------------------------------------- /olsync/olbrowserlogin.py: -------------------------------------------------------------------------------- 1 | """Ol Browser Login Utility""" 2 | ################################################## 3 | # MIT License 4 | ################################################## 5 | # File: olbrowserlogin.py 6 | # Description: Overleaf Browser Login Utility 7 | # Author: Moritz Glöckl 8 | # License: MIT 9 | # Version: 1.2.0 10 | ################################################## 11 | 12 | from PySide6.QtCore import * 13 | from PySide6.QtWidgets import * 14 | from PySide6.QtWebEngineWidgets import * 15 | from PySide6.QtWebEngineCore import QWebEngineProfile, QWebEngineSettings, QWebEnginePage 16 | 17 | # Where to get the CSRF Token and where to send the login request to 18 | LOGIN_URL = "https://www.overleaf.com/login" 19 | PROJECT_URL = "https://www.overleaf.com/project" # The dashboard URL 20 | # JS snippet to extract the csrfToken 21 | JAVASCRIPT_CSRF_EXTRACTOR = "document.getElementsByName('ol-csrfToken')[0].content" 22 | # Name of the cookies we want to extract 23 | COOKIE_NAMES = ["overleaf_session2", "GCLB"] 24 | 25 | 26 | class OlBrowserLoginWindow(QMainWindow): 27 | """ 28 | Overleaf Browser Login Utility 29 | Opens a browser window to securely login the user and returns relevant login data. 30 | """ 31 | 32 | def __init__(self, *args, **kwargs): 33 | super(OlBrowserLoginWindow, self).__init__(*args, **kwargs) 34 | 35 | self.webview = QWebEngineView() 36 | 37 | self._cookies = {} 38 | self._csrf = "" 39 | self._login_success = False 40 | 41 | self.profile = QWebEngineProfile(self.webview) 42 | self.cookie_store = self.profile.cookieStore() 43 | self.cookie_store.cookieAdded.connect(self.handle_cookie_added) 44 | self.profile.setPersistentCookiesPolicy(QWebEngineProfile.NoPersistentCookies) 45 | 46 | self.profile.settings().setAttribute(QWebEngineSettings.JavascriptEnabled, True) 47 | 48 | webpage = QWebEnginePage(self.profile, self) 49 | self.webview.setPage(webpage) 50 | self.webview.load(QUrl.fromUserInput(LOGIN_URL)) 51 | self.webview.loadFinished.connect(self.handle_load_finished) 52 | 53 | self.setCentralWidget(self.webview) 54 | self.resize(600, 700) 55 | 56 | def handle_load_finished(self): 57 | def callback(result): 58 | self._csrf = result 59 | self._login_success = True 60 | QCoreApplication.quit() 61 | 62 | if self.webview.url().toString() == PROJECT_URL: 63 | self.webview.page().runJavaScript( 64 | JAVASCRIPT_CSRF_EXTRACTOR, 0, callback 65 | ) 66 | 67 | def handle_cookie_added(self, cookie): 68 | cookie_name = cookie.name().data().decode('utf-8') 69 | if cookie_name in COOKIE_NAMES: 70 | self._cookies[cookie_name] = cookie.value().data().decode('utf-8') 71 | 72 | @property 73 | def cookies(self): 74 | return self._cookies 75 | 76 | @property 77 | def csrf(self): 78 | return self._csrf 79 | 80 | @property 81 | def login_success(self): 82 | return self._login_success 83 | 84 | 85 | def login(): 86 | from PySide6.QtCore import QLoggingCategory 87 | QLoggingCategory.setFilterRules('''\ 88 | qt.webenginecontext.info=false 89 | ''') 90 | 91 | app = QApplication([]) 92 | ol_browser_login_window = OlBrowserLoginWindow() 93 | ol_browser_login_window.show() 94 | app.exec() 95 | 96 | if not ol_browser_login_window.login_success: 97 | return None 98 | 99 | return {"cookie": ol_browser_login_window.cookies, "csrf": ol_browser_login_window.csrf} 100 | -------------------------------------------------------------------------------- /olsync/olclient.py: -------------------------------------------------------------------------------- 1 | """Overleaf Client""" 2 | ################################################## 3 | # MIT License 4 | ################################################## 5 | # File: olclient.py 6 | # Description: Overleaf API Wrapper 7 | # Author: Moritz Glöckl 8 | # License: MIT 9 | # Version: 1.2.0 10 | ################################################## 11 | 12 | import requests as reqs 13 | from bs4 import BeautifulSoup 14 | import json 15 | import uuid 16 | from socketIO_client import SocketIO 17 | import time 18 | 19 | # Where to get the CSRF Token and where to send the login request to 20 | LOGIN_URL = "https://www.overleaf.com/login" 21 | PROJECT_URL = "https://www.overleaf.com/project" # The dashboard URL 22 | # The URL to download all the files in zip format 23 | DOWNLOAD_URL = "https://www.overleaf.com/project/{}/download/zip" 24 | UPLOAD_URL = "https://www.overleaf.com/project/{}/upload" # The URL to upload files 25 | FOLDER_URL = "https://www.overleaf.com/project/{}/folder" # The URL to create folders 26 | DELETE_URL = "https://www.overleaf.com/project/{}/doc/{}" # The URL to delete files 27 | COMPILE_URL = "https://www.overleaf.com/project/{}/compile?enable_pdf_caching=true" # The URL to compile the project 28 | BASE_URL = "https://www.overleaf.com" # The Overleaf Base URL 29 | PATH_SEP = "/" # Use hardcoded path separator for both windows and posix system 30 | 31 | class OverleafClient(object): 32 | """ 33 | Overleaf API Wrapper 34 | Supports login, querying all projects, querying a specific project, downloading a project and 35 | uploading a file to a project. 36 | """ 37 | 38 | @staticmethod 39 | def filter_projects(json_content, more_attrs=None): 40 | more_attrs = more_attrs or {} 41 | for p in json_content: 42 | if not p.get("archived") and not p.get("trashed"): 43 | if all(p.get(k) == v for k, v in more_attrs.items()): 44 | yield p 45 | 46 | def __init__(self, cookie=None, csrf=None): 47 | self._cookie = cookie # Store the cookie for authenticated requests 48 | self._csrf = csrf # Store the CSRF token since it is needed for some requests 49 | 50 | def login(self, username, password): 51 | """ 52 | WARNING - DEPRECATED - Not working as Overleaf introduced captchas 53 | Login to the Overleaf Service with a username and a password 54 | Params: username, password 55 | Returns: Dict of cookie and CSRF 56 | """ 57 | 58 | get_login = reqs.get(LOGIN_URL) 59 | self._csrf = BeautifulSoup(get_login.content, 'html.parser').find( 60 | 'input', {'name': '_csrf'}).get('value') 61 | login_json = { 62 | "_csrf": self._csrf, 63 | "email": username, 64 | "password": password 65 | } 66 | post_login = reqs.post(LOGIN_URL, json=login_json, 67 | cookies=get_login.cookies) 68 | 69 | # On a successful authentication the Overleaf API returns a new authenticated cookie. 70 | # If the cookie is different than the cookie of the GET request the authentication was successful 71 | if post_login.status_code == 200 and get_login.cookies["overleaf_session2"] != post_login.cookies[ 72 | "overleaf_session2"]: 73 | self._cookie = post_login.cookies 74 | 75 | # Enrich cookie with GCLB cookie from GET request above 76 | self._cookie['GCLB'] = get_login.cookies['GCLB'] 77 | 78 | # CSRF changes after making the login request, new CSRF token will be on the projects page 79 | projects_page = reqs.get(PROJECT_URL, cookies=self._cookie) 80 | self._csrf = BeautifulSoup(projects_page.content, 'html.parser').find('meta', {'name': 'ol-csrfToken'}) \ 81 | .get('content') 82 | 83 | return {"cookie": self._cookie, "csrf": self._csrf} 84 | 85 | def all_projects(self): 86 | """ 87 | Get all of a user's active projects (= not archived and not trashed) 88 | Returns: List of project objects 89 | """ 90 | projects_page = reqs.get(PROJECT_URL, cookies=self._cookie) 91 | json_content = json.loads( 92 | BeautifulSoup(projects_page.content, 'html.parser').find('meta', {'name': 'ol-projects'}).get('content')) 93 | return list(OverleafClient.filter_projects(json_content)) 94 | 95 | def get_project(self, project_name): 96 | """ 97 | Get a specific project by project_name 98 | Params: project_name, the name of the project 99 | Returns: project object 100 | """ 101 | 102 | projects_page = reqs.get(PROJECT_URL, cookies=self._cookie) 103 | json_content = json.loads( 104 | BeautifulSoup(projects_page.content, 'html.parser').find('meta', {'name': 'ol-projects'}).get('content')) 105 | return next(OverleafClient.filter_projects(json_content, {"name": project_name}), None) 106 | 107 | def download_project(self, project_id): 108 | """ 109 | Download project in zip format 110 | Params: project_id, the id of the project 111 | Returns: bytes string (zip file) 112 | """ 113 | r = reqs.get(DOWNLOAD_URL.format(project_id), 114 | stream=True, cookies=self._cookie) 115 | return r.content 116 | 117 | def create_folder(self, project_id, parent_folder_id, folder_name): 118 | """ 119 | Create a new folder in a project 120 | 121 | Params: 122 | project_id: the id of the project 123 | parent_folder_id: the id of the parent folder, root is the project_id 124 | folder_name: how the folder will be named 125 | 126 | Returns: folder id or None 127 | """ 128 | 129 | params = { 130 | "parent_folder_id": parent_folder_id, 131 | "name": folder_name 132 | } 133 | headers = { 134 | "X-Csrf-Token": self._csrf 135 | } 136 | r = reqs.post(FOLDER_URL.format(project_id), 137 | cookies=self._cookie, headers=headers, json=params) 138 | 139 | if r.ok: 140 | return json.loads(r.content) 141 | elif r.status_code == str(400): 142 | # Folder already exists 143 | return 144 | else: 145 | raise reqs.HTTPError() 146 | 147 | def get_project_infos(self, project_id): 148 | """ 149 | Get detailed project infos about the project 150 | 151 | Params: 152 | project_id: the id of the project 153 | 154 | Returns: project details 155 | """ 156 | project_infos = None 157 | 158 | # Callback function for the joinProject emitter 159 | def set_project_infos(a, project_infos_dict, c, d): 160 | # Set project_infos variable in outer scope 161 | nonlocal project_infos 162 | project_infos = project_infos_dict 163 | 164 | # Convert cookie from CookieJar to string 165 | cookie = "GCLB={}; overleaf_session2={}" \ 166 | .format( 167 | self._cookie["GCLB"], 168 | self._cookie["overleaf_session2"] 169 | ) 170 | 171 | # Connect to Overleaf Socket.IO, send a time parameter and the cookies 172 | socket_io = SocketIO( 173 | BASE_URL, 174 | params={'t': int(time.time())}, 175 | headers={'Cookie': cookie} 176 | ) 177 | 178 | # Wait until we connect to the socket 179 | socket_io.on('connect', lambda: None) 180 | socket_io.wait_for_callbacks() 181 | 182 | # Send the joinProject event and receive the project infos 183 | socket_io.emit('joinProject', {'project_id': project_id}, set_project_infos) 184 | socket_io.wait_for_callbacks() 185 | 186 | # Disconnect from the socket if still connected 187 | if socket_io.connected: 188 | socket_io.disconnect() 189 | 190 | return project_infos 191 | 192 | def upload_file(self, project_id, project_infos, file_name, file_size, file): 193 | """ 194 | Upload a file to the project 195 | 196 | Params: 197 | project_id: the id of the project 198 | file_name: how the file will be named 199 | file_size: the size of the file in bytes 200 | file: the file itself 201 | 202 | Returns: True on success, False on fail 203 | """ 204 | 205 | # Set the folder_id to the id of the root folder 206 | folder_id = project_infos['rootFolder'][0]['_id'] 207 | 208 | # The file name contains path separators, check folders 209 | if PATH_SEP in file_name: 210 | local_folders = file_name.split(PATH_SEP)[:-1] # Remove last item since this is the file name 211 | current_overleaf_folder = project_infos['rootFolder'][0]['folders'] # Set the current remote folder 212 | 213 | for local_folder in local_folders: 214 | exists_on_remote = False 215 | for remote_folder in current_overleaf_folder: 216 | # Check if the folder exists on remote, continue with the new folder structure 217 | if local_folder.lower() == remote_folder['name'].lower(): 218 | exists_on_remote = True 219 | folder_id = remote_folder['_id'] 220 | current_overleaf_folder = remote_folder['folders'] 221 | break 222 | # Create the folder if it doesn't exist 223 | if not exists_on_remote: 224 | new_folder = self.create_folder(project_id, folder_id, local_folder) 225 | current_overleaf_folder.append(new_folder) 226 | folder_id = new_folder['_id'] 227 | current_overleaf_folder = new_folder['folders'] 228 | params = { 229 | "folder_id": folder_id, 230 | "_csrf": self._csrf, 231 | "qquuid": str(uuid.uuid4()), 232 | "qqfilename": file_name, 233 | "qqtotalfilesize": file_size, 234 | } 235 | files = { 236 | "qqfile": file 237 | } 238 | 239 | # Upload the file to the predefined folder 240 | r = reqs.post(UPLOAD_URL.format(project_id), cookies=self._cookie, params=params, files=files) 241 | 242 | return r.status_code == str(200) and json.loads(r.content)["success"] 243 | 244 | def delete_file(self, project_id, project_infos, file_name): 245 | """ 246 | Deletes a project's file 247 | 248 | Params: 249 | project_id: the id of the project 250 | file_name: how the file will be named 251 | 252 | Returns: True on success, False on fail 253 | """ 254 | 255 | file = None 256 | 257 | # The file name contains path separators, check folders 258 | if PATH_SEP in file_name: 259 | local_folders = file_name.split(PATH_SEP)[:-1] # Remove last item since this is the file name 260 | current_overleaf_folder = project_infos['rootFolder'][0]['folders'] # Set the current remote folder 261 | 262 | for local_folder in local_folders: 263 | for remote_folder in current_overleaf_folder: 264 | if local_folder.lower() == remote_folder['name'].lower(): 265 | file = next((v for v in remote_folder['docs'] if v['name'] == file_name.split(PATH_SEP)[-1]), 266 | None) 267 | current_overleaf_folder = remote_folder['folders'] 268 | break 269 | # File is in root folder 270 | else: 271 | file = next((v for v in project_infos['rootFolder'][0]['docs'] if v['name'] == file_name), None) 272 | 273 | # File not found! 274 | if file is None: 275 | return False 276 | 277 | headers = { 278 | "X-Csrf-Token": self._csrf 279 | } 280 | 281 | r = reqs.delete(DELETE_URL.format(project_id, file['_id']), cookies=self._cookie, headers=headers, json={}) 282 | 283 | return r.status_code == str(204) 284 | 285 | def download_pdf(self, project_id): 286 | """ 287 | Compiles and returns a project's PDF 288 | 289 | Params: 290 | project_id: the id of the project 291 | 292 | Returns: PDF file name and content on success 293 | """ 294 | headers = { 295 | "X-Csrf-Token": self._csrf 296 | } 297 | 298 | body = { 299 | "check": "silent", 300 | "draft": False, 301 | "incrementalCompilesEnabled": True, 302 | "rootDoc_id": "", 303 | "stopOnFirstError": False 304 | } 305 | 306 | r = reqs.post(COMPILE_URL.format(project_id), cookies=self._cookie, headers=headers, json=body) 307 | 308 | if not r.ok: 309 | raise reqs.HTTPError() 310 | 311 | compile_result = json.loads(r.content) 312 | 313 | if compile_result["status"] != "success": 314 | raise reqs.HTTPError() 315 | 316 | pdf_file = next(v for v in compile_result['outputFiles'] if v['type'] == 'pdf') 317 | 318 | download_req = reqs.get(BASE_URL + pdf_file['url'], cookies=self._cookie, headers=headers) 319 | 320 | if download_req.ok: 321 | return pdf_file['path'], download_req.content 322 | 323 | return None 324 | -------------------------------------------------------------------------------- /olsync/olsync.py: -------------------------------------------------------------------------------- 1 | """Overleaf Two-Way Sync Tool""" 2 | ################################################## 3 | # MIT License 4 | ################################################## 5 | # File: olsync.py 6 | # Description: Overleaf Two-Way Sync 7 | # Author: Moritz Glöckl 8 | # License: MIT 9 | # Version: 1.2.0 10 | ################################################## 11 | 12 | import click 13 | import os 14 | from yaspin import yaspin 15 | import pickle 16 | import zipfile 17 | import io 18 | import dateutil.parser 19 | import glob 20 | import fnmatch 21 | import traceback 22 | from pathlib import Path 23 | 24 | try: 25 | # Import for pip installation / wheel 26 | from olsync.olclient import OverleafClient 27 | import olsync.olbrowserlogin as olbrowserlogin 28 | except ImportError: 29 | # Import for development 30 | from olclient import OverleafClient 31 | import olbrowserlogin 32 | 33 | 34 | @click.group(invoke_without_command=True) 35 | @click.option('-l', '--local-only', 'local', is_flag=True, help="Sync local project files to Overleaf only.") 36 | @click.option('-r', '--remote-only', 'remote', is_flag=True, 37 | help="Sync remote project files from Overleaf to local file system only.") 38 | @click.option('-n', '--name', 'project_name', default="", 39 | help="Specify the Overleaf project name instead of the default name of the sync directory.") 40 | @click.option('--store-path', 'cookie_path', default=".olauth", type=click.Path(exists=False), 41 | help="Relative path to load the persisted Overleaf cookie.") 42 | @click.option('-p', '--path', 'sync_path', default=".", type=click.Path(exists=True), 43 | help="Path of the project to sync.") 44 | @click.option('-i', '--olignore', 'olignore_path', default=".olignore", type=click.Path(exists=False), 45 | help="Path to the .olignore file relative to sync path (ignored if syncing from remote to local). See " 46 | "fnmatch / unix filename pattern matching for information on how to use it.") 47 | @click.option('-v', '--verbose', 'verbose', is_flag=True, help="Enable extended error logging.") 48 | @click.version_option(package_name='overleaf-sync') 49 | @click.pass_context 50 | def main(ctx, local, remote, project_name, cookie_path, sync_path, olignore_path, verbose): 51 | if ctx.invoked_subcommand is None: 52 | if not os.path.isfile(cookie_path): 53 | raise click.ClickException( 54 | "Persisted Overleaf cookie not found. Please login or check store path.") 55 | 56 | with open(cookie_path, 'rb') as f: 57 | store = pickle.load(f) 58 | 59 | overleaf_client = OverleafClient(store["cookie"], store["csrf"]) 60 | 61 | # Change the current directory to the specified sync path 62 | os.chdir(sync_path) 63 | 64 | project_name = project_name or os.path.basename(os.getcwd()) 65 | project = execute_action( 66 | lambda: overleaf_client.get_project(project_name), 67 | "Querying project", 68 | "Project queried successfully.", 69 | "Project could not be queried.", 70 | verbose) 71 | 72 | project_infos = execute_action( 73 | lambda: overleaf_client.get_project_infos(project["id"]), 74 | "Querying project details", 75 | "Project details queried successfully.", 76 | "Project details could not be queried.", 77 | verbose) 78 | 79 | zip_file = execute_action( 80 | lambda: zipfile.ZipFile(io.BytesIO( 81 | overleaf_client.download_project(project["id"]))), 82 | "Downloading project", 83 | "Project downloaded successfully.", 84 | "Project could not be downloaded.", 85 | verbose) 86 | 87 | sync = not (local or remote) 88 | 89 | if remote or sync: 90 | sync_func( 91 | files_from=zip_file.namelist(), 92 | deleted_files=[f for f in olignore_keep_list(olignore_path) if f not in zip_file.namelist() and not sync], 93 | create_file_at_to=lambda name: write_file(name, zip_file.read(name)), 94 | delete_file_at_to=lambda name: delete_file(name), 95 | create_file_at_from=lambda name: overleaf_client.upload_file( 96 | project["id"], project_infos, name, os.path.getsize(name), open(name, 'rb')), 97 | from_exists_in_to=lambda name: os.path.isfile(name), 98 | from_equal_to_to=lambda name: open(name, 'rb').read() == zip_file.read(name), 99 | from_newer_than_to=lambda name: dateutil.parser.isoparse(project["lastUpdated"]).timestamp() > 100 | os.path.getmtime(name), 101 | from_name="remote", 102 | to_name="local", 103 | verbose=verbose) 104 | if local or sync: 105 | sync_func( 106 | files_from=olignore_keep_list(olignore_path), 107 | deleted_files=[f for f in zip_file.namelist() if f not in olignore_keep_list(olignore_path) and not sync], 108 | create_file_at_to=lambda name: overleaf_client.upload_file( 109 | project["id"], project_infos, name, os.path.getsize(name), open(name, 'rb')), 110 | delete_file_at_to=lambda name: overleaf_client.delete_file(project["id"], project_infos, name), 111 | create_file_at_from=lambda name: write_file(name, zip_file.read(name)), 112 | from_exists_in_to=lambda name: name in zip_file.namelist(), 113 | from_equal_to_to=lambda name: open(name, 'rb').read() == zip_file.read(name), 114 | from_newer_than_to=lambda name: os.path.getmtime(name) > dateutil.parser.isoparse( 115 | project["lastUpdated"]).timestamp(), 116 | from_name="local", 117 | to_name="remote", 118 | verbose=verbose) 119 | 120 | 121 | @main.command() 122 | @click.option('--path', 'cookie_path', default=".olauth", type=click.Path(exists=False), 123 | help="Path to store the persisted Overleaf cookie.") 124 | @click.option('-v', '--verbose', 'verbose', is_flag=True, help="Enable extended error logging.") 125 | def login(cookie_path, verbose): 126 | if os.path.isfile(cookie_path) and not click.confirm( 127 | 'Persisted Overleaf cookie already exist. Do you want to override it?'): 128 | return 129 | click.clear() 130 | execute_action(lambda: login_handler(cookie_path), "Login", 131 | "Login successful. Cookie persisted as `" + click.format_filename( 132 | cookie_path) + "`. You may now sync your project.", 133 | "Login failed. Please try again.", verbose) 134 | 135 | 136 | @main.command(name='list') 137 | @click.option('--store-path', 'cookie_path', default=".olauth", type=click.Path(exists=False), 138 | help="Relative path to load the persisted Overleaf cookie.") 139 | @click.option('-v', '--verbose', 'verbose', is_flag=True, help="Enable extended error logging.") 140 | def list_projects(cookie_path, verbose): 141 | def query_projects(): 142 | for index, p in enumerate(sorted(overleaf_client.all_projects(), key=lambda x: x['lastUpdated'], reverse=True)): 143 | if not index: 144 | click.echo("\n") 145 | click.echo(f"{dateutil.parser.isoparse(p['lastUpdated']).strftime('%m/%d/%Y, %H:%M:%S')} - {p['name']}") 146 | return True 147 | 148 | if not os.path.isfile(cookie_path): 149 | raise click.ClickException( 150 | "Persisted Overleaf cookie not found. Please login or check store path.") 151 | 152 | with open(cookie_path, 'rb') as f: 153 | store = pickle.load(f) 154 | 155 | overleaf_client = OverleafClient(store["cookie"], store["csrf"]) 156 | 157 | click.clear() 158 | execute_action(query_projects, "Querying all projects", 159 | "Querying all projects successful.", 160 | "Querying all projects failed. Please try again.", verbose) 161 | 162 | 163 | @main.command(name='download') 164 | @click.option('-n', '--name', 'project_name', default="", 165 | help="Specify the Overleaf project name instead of the default name of the sync directory.") 166 | @click.option('--download-path', 'download_path', default=".", type=click.Path(exists=True)) 167 | @click.option('--store-path', 'cookie_path', default=".olauth", type=click.Path(exists=False), 168 | help="Relative path to load the persisted Overleaf cookie.") 169 | @click.option('-v', '--verbose', 'verbose', is_flag=True, help="Enable extended error logging.") 170 | def download_pdf(project_name, download_path, cookie_path, verbose): 171 | def download_project_pdf(): 172 | nonlocal project_name 173 | project_name = project_name or os.path.basename(os.getcwd()) 174 | project = execute_action( 175 | lambda: overleaf_client.get_project(project_name), 176 | "Querying project", 177 | "Project queried successfully.", 178 | "Project could not be queried.", 179 | verbose) 180 | 181 | file_name, content = overleaf_client.download_pdf(project["id"]) 182 | 183 | if file_name and content: 184 | # Change the current directory to the specified sync path 185 | os.chdir(download_path) 186 | open(file_name, 'wb').write(content) 187 | 188 | return True 189 | 190 | if not os.path.isfile(cookie_path): 191 | raise click.ClickException( 192 | "Persisted Overleaf cookie not found. Please login or check store path.") 193 | 194 | with open(cookie_path, 'rb') as f: 195 | store = pickle.load(f) 196 | 197 | overleaf_client = OverleafClient(store["cookie"], store["csrf"]) 198 | 199 | click.clear() 200 | 201 | execute_action(download_project_pdf, "Downloading project's PDF", 202 | "Downloading project's PDF successful.", 203 | "Downloading project's PDF failed. Please try again.", verbose) 204 | 205 | 206 | def login_handler(path): 207 | store = olbrowserlogin.login() 208 | if store is None: 209 | return False 210 | with open(path, 'wb+') as f: 211 | pickle.dump(store, f) 212 | return True 213 | 214 | 215 | def delete_file(path): 216 | _dir = os.path.dirname(path) 217 | if _dir == path: 218 | return 219 | 220 | if _dir != '' and not os.path.exists(_dir): 221 | return 222 | else: 223 | os.remove(path) 224 | 225 | 226 | def write_file(path, content): 227 | _dir = os.path.dirname(path) 228 | if _dir == path: 229 | return 230 | 231 | # path is a file 232 | if _dir != '' and not os.path.exists(_dir): 233 | os.makedirs(_dir) 234 | 235 | with open(path, 'wb+') as f: 236 | f.write(content) 237 | 238 | 239 | def sync_func(files_from, deleted_files, create_file_at_to, delete_file_at_to, create_file_at_from, from_exists_in_to, 240 | from_equal_to_to, from_newer_than_to, from_name, 241 | to_name, verbose=False): 242 | click.echo("\nSyncing files from [%s] to [%s]" % (from_name, to_name)) 243 | click.echo('=' * 40) 244 | 245 | newly_add_list = [] 246 | update_list = [] 247 | delete_list = [] 248 | restore_list = [] 249 | not_restored_list = [] 250 | not_sync_list = [] 251 | synced_list = [] 252 | 253 | for name in files_from: 254 | if from_exists_in_to(name): 255 | if not from_equal_to_to(name): 256 | if not from_newer_than_to(name) and not click.confirm( 257 | '\n-> Warning: last-edit time stamp of file <%s> from [%s] is older than [%s].\nContinue to ' 258 | 'overwrite with an older version?' % (name, from_name, to_name)): 259 | not_sync_list.append(name) 260 | continue 261 | 262 | update_list.append(name) 263 | else: 264 | synced_list.append(name) 265 | else: 266 | newly_add_list.append(name) 267 | 268 | for name in deleted_files: 269 | delete_choice = click.prompt( 270 | '\n-> Warning: file <%s> does not exist on [%s] anymore (but it still exists on [%s]).' 271 | '\nShould the file be [d]eleted, [r]estored or [i]gnored?' % (name, from_name, to_name), 272 | default="i", 273 | type=click.Choice(['d', 'r', 'i'])) 274 | if delete_choice == "d": 275 | delete_list.append(name) 276 | elif delete_choice == "r": 277 | restore_list.append(name) 278 | elif delete_choice == "i": 279 | not_restored_list.append(name) 280 | 281 | click.echo( 282 | "\n[NEW] Following new file(s) created on [%s]" % to_name) 283 | for name in newly_add_list: 284 | click.echo("\t%s" % name) 285 | try: 286 | create_file_at_to(name) 287 | except: 288 | if verbose: 289 | print(traceback.format_exc()) 290 | raise click.ClickException("\n[ERROR] An error occurred while creating new file(s) on [%s]" % to_name) 291 | 292 | click.echo( 293 | "\n[NEW] Following new file(s) created on [%s]" % from_name) 294 | for name in restore_list: 295 | click.echo("\t%s" % name) 296 | try: 297 | create_file_at_from(name) 298 | except: 299 | if verbose: 300 | print(traceback.format_exc()) 301 | raise click.ClickException("\n[ERROR] An error occurred while creating new file(s) on [%s]" % from_name) 302 | 303 | click.echo( 304 | "\n[UPDATE] Following file(s) updated on [%s]" % to_name) 305 | for name in update_list: 306 | click.echo("\t%s" % name) 307 | try: 308 | create_file_at_to(name) 309 | except: 310 | if verbose: 311 | print(traceback.format_exc()) 312 | raise click.ClickException("\n[ERROR] An error occurred while updating file(s) on [%s]" % to_name) 313 | 314 | click.echo( 315 | "\n[DELETE] Following file(s) deleted on [%s]" % to_name) 316 | for name in delete_list: 317 | click.echo("\t%s" % name) 318 | try: 319 | delete_file_at_to(name) 320 | except: 321 | if verbose: 322 | print(traceback.format_exc()) 323 | raise click.ClickException("\n[ERROR] An error occurred while creating new file(s) on [%s]" % to_name) 324 | 325 | click.echo( 326 | "\n[SYNC] Following file(s) are up to date") 327 | for name in synced_list: 328 | click.echo("\t%s" % name) 329 | 330 | click.echo( 331 | "\n[SKIP] Following file(s) on [%s] have not been synced to [%s]" % (from_name, to_name)) 332 | for name in not_sync_list: 333 | click.echo("\t%s" % name) 334 | 335 | click.echo( 336 | "\n[SKIP] Following file(s) on [%s] have not been synced to [%s]" % (to_name, from_name)) 337 | for name in not_restored_list: 338 | click.echo("\t%s" % name) 339 | 340 | click.echo("") 341 | click.echo("✅ Synced files from [%s] to [%s]" % (from_name, to_name)) 342 | click.echo("") 343 | 344 | 345 | def execute_action(action, progress_message, success_message, fail_message, verbose_error_logging=False): 346 | with yaspin(text=progress_message, color="green") as spinner: 347 | try: 348 | success = action() 349 | except: 350 | if verbose_error_logging: 351 | print(traceback.format_exc()) 352 | success = False 353 | 354 | if success: 355 | spinner.write(success_message) 356 | spinner.ok("✅ ") 357 | else: 358 | spinner.fail("💥 ") 359 | raise click.ClickException(fail_message) 360 | 361 | return success 362 | 363 | 364 | def olignore_keep_list(olignore_path): 365 | """ 366 | The list of files to keep synced, with support for sub-folders. 367 | Should only be called when syncing from local to remote. 368 | """ 369 | # get list of files recursively (ignore .* files) 370 | files = glob.glob('**', recursive=True) 371 | 372 | click.echo("="*40) 373 | if not os.path.isfile(olignore_path): 374 | click.echo("\nNotice: .olignore file does not exist, will sync all items.") 375 | keep_list = files 376 | else: 377 | click.echo("\n.olignore: using %s to filter items" % olignore_path) 378 | with open(olignore_path, 'r') as f: 379 | ignore_pattern = f.read().splitlines() 380 | 381 | keep_list = [f for f in files if not any( 382 | fnmatch.fnmatch(f, ignore) for ignore in ignore_pattern)] 383 | 384 | keep_list = [Path(item).as_posix() for item in keep_list if not os.path.isdir(item)] 385 | return keep_list 386 | 387 | 388 | if __name__ == "__main__": 389 | main() 390 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit"] 3 | build-backend = "flit.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "olsync" 7 | dist-name = "overleaf-sync" 8 | description-file = "README.md" 9 | author = "Moritz Glöckl" 10 | author-email = "moritzgloeckl@users.noreply.github.com" 11 | home-page = "https://github.com/moritzgloeckl/overleaf-sync" 12 | classifiers = ["License :: OSI Approved :: MIT License", "Intended Audience :: Science/Research", "Programming Language :: Python :: 3"] 13 | requires-python = ">=3" 14 | requires = [ 15 | "requests == 2.*", 16 | "beautifulsoup4 == 4.11.1", 17 | "yaspin == 2.*", 18 | "python-dateutil~=2.8.1", 19 | "click == 8.*", 20 | "socketIO-client == 0.5.7.2", 21 | "PySide6 == 6.*" 22 | ] 23 | keywords = "overleaf sync latex tex" 24 | 25 | [tool.flit.scripts] 26 | ols = "olsync.olsync:main" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.* 2 | beautifulsoup4==4.11.1 3 | yaspin==2.* 4 | python-dateutil~=2.8.1 5 | click==8.* 6 | socketIO-client==0.5.7.2 # Do not upgrade! 7 | PySide6==6.* --------------------------------------------------------------------------------