overleaf-pull 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: overleaf-pull
3
+ Version: 0.1.0
4
+ Summary: Pull-only Overleaf project sync using Rookie + PyOverleaf
5
+ Project-URL: Homepage, https://github.com/LeanderK/overleaf_sync
6
+ Project-URL: Issues, https://github.com/LeanderK/overleaf_sync/issues
7
+ Author: Leander Kurscheidt
8
+ Keywords: cli,git,latex,overleaf,sync
9
+ Classifier: Environment :: Console
10
+ Classifier: Operating System :: MacOS
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: browsercookie>=0.7
17
+ Requires-Dist: pyoverleaf>=0.1.7
18
+ Requires-Dist: rookiepy==0.5.4
19
+ Provides-Extra: qt
20
+ Requires-Dist: pyside6>=6.6; extra == 'qt'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Overleaf Pull-Only Sync CLI
24
+
25
+ Overview
26
+ - Pull-only tool that periodically clones/pulls your latest Overleaf projects into a local directory.
27
+ - Discovers projects via your browser cookies (Rookie) and lists them via PyOverleaf; syncs using Git.
28
+ - Runs in the background as a macOS LaunchAgent or Linux systemd user timer.
29
+
30
+ Requirements
31
+ - macOS or Linux with Git installed.
32
+ - Python 3.10+.
33
+ - Packages: rookiepy, pyoverleaf (installed via requirements.txt).
34
+ - Overleaf Git integration enabled on your account to allow cloning/pulling via git.overleaf.com.
35
+
36
+ Install
37
+ ```bash
38
+ # # Using uv (recommended)
39
+ # uv currently not working!
40
+ # uv sync
41
+
42
+ # Or using conda
43
+ conda env create -f environment.yml
44
+ conda activate overleaf-sync
45
+
46
+ # Or using pip/venv
47
+ python3 -m venv .venv
48
+ . .venv/bin/activate
49
+ pip install -r requirements.txt
50
+ ```
51
+
52
+ First Run (setup)
53
+ ```bash
54
+ overleaf-pull init --install
55
+ # If the console script isn't found, use:
56
+ # uv currently not working!
57
+ # uv run python -m overleaf_sync.cli init --install
58
+ ```
59
+ - Prompts for the base directory, interval (1h/12h/24h), count (default 10), browser/profile, and host (default www.overleaf.com).
60
+ - Offers a Qt browser login to capture cookies automatically (default Yes if PySide6 is installed). Falls back to optional manual cookie paste.
61
+ - Prompts for your Overleaf Git authentication token (required for cloning/pulling and background runs). It will offer to open Overleaf in your browser to fetch it.
62
+ - Installs a background job (LaunchAgent on macOS, systemd user timer on Linux).
63
+ - Runs a validation sync before installing the scheduler, to confirm access.
64
+
65
+ Manual Commands
66
+ - Run once now:
67
+ ```bash
68
+ overleaf-pull run-once
69
+ # Or via uv:
70
+ uv run python -m overleaf_sync.cli run-once
71
+ ```
72
+ - Manual sync (with optional overrides):
73
+ ```bash
74
+ overleaf-pull sync --count 5 --base-dir ~/Overleaf --browser firefox
75
+ # Or via uv:
76
+ uv run python -m overleaf_sync.cli sync --count 5 --base-dir ~/Overleaf --browser firefox
77
+ ```
78
+ - Store or clear cookies in config:
79
+ - Folder naming preference:
80
+ ```bash
81
+ overleaf-pull set-name-suffix off # Use display name only
82
+ overleaf-pull set-name-suffix on # Default: append a short ID to avoid collisions
83
+ ```
84
+ This affects the local folder names only; project display names on Overleaf remain unchanged.
85
+ ```bash
86
+ overleaf-pull set-cookie "name=value; other=value2"
87
+ overleaf-pull clear-cookie
88
+ ```
89
+ - Browser-assisted cookie capture (like olbrowserlogin):
90
+ ```bash
91
+ overleaf-pull browser-login
92
+ # This opens Overleaf in your browser and guides you to copy document.cookie.
93
+ ```
94
+ Required cookies
95
+ - At minimum: `overleaf_session2` and `GCLB` must be present in your Cookie header for authenticated requests.
96
+ - document.cookie cannot see HttpOnly cookies; copy the full Cookie header from the Network tab for a request to your Overleaf host.
97
+
98
+ Qt browser login (optional)
99
+ - Use a built-in Qt browser to log in and auto-capture cookies.
100
+ - Conda (recommended on macOS/Linux):
101
+ ```bash
102
+ conda activate overleaf-sync
103
+ conda install -c conda-forge pyside6
104
+ overleaf-pull browser-login-qt
105
+ ```
106
+ - Pip/venv alternative:
107
+ ```bash
108
+ pip install PySide6
109
+ python -m overleaf_sync.cli browser-login-qt
110
+ ```
111
+ During setup, if PySide6 is present, the tool will offer the Qt login flow by default.
112
+
113
+ Git authentication token
114
+ - Overleaf requires a Git auth token for `git clone`/`git pull`.
115
+ - Generate a token in your Overleaf account (see the Git integration/authentication tokens page or the Git instructions shown in your project UI), then set it:
116
+ ```bash
117
+ overleaf-pull set-git-token
118
+ # Paste your token when prompted
119
+
120
+ # Clear it if needed
121
+ overleaf-pull clear-git-token
122
+ ```
123
+ - With a token set, the tool will use URLs like `https://git:<TOKEN>@git.overleaf.com/<PROJECT_ID>` automatically.
124
+ - Status from logs:
125
+ ```bash
126
+ overleaf-pull status
127
+ ```
128
+ - Install or remove background job:
129
+ ```bash
130
+ overleaf-pull install-scheduler
131
+ overleaf-pull uninstall-scheduler
132
+ # Or via uv:
133
+ uv run python -m overleaf_sync.cli install-scheduler
134
+ uv run python -m overleaf_sync.cli uninstall-scheduler
135
+ ```
136
+ Installing the scheduler is idempotent: it uninstalls any existing instance first, then reinstalls to ensure only one scheduler is active.
137
+ Publish to PyPI (CI)
138
+ - This repo includes a GitHub Actions workflow that publishes on tags `v*` using PyPI Trusted Publishers (OIDC).
139
+ - Trigger a release:
140
+ ```bash
141
+ git tag v0.1.0
142
+ git push origin v0.1.0
143
+ ```
144
+ - The workflow builds sdist/wheel and publishes without storing secrets.
145
+ - Adjust interval or latest count:
146
+ ```bash
147
+ overleaf-pull set-interval 12h
148
+ overleaf-pull set-count 20
149
+ ```
150
+ - Change base directory:
151
+ ```bash
152
+ overleaf-pull set-base-dir /path/to/Overleaf
153
+ ```
154
+
155
+ macOS Logs
156
+ - Logs: ~/Library/Logs/overleaf_sync/runner.log
157
+
158
+ Linux Logs
159
+ - `journalctl --user -u overleaf-sync.timer -u overleaf-sync.service`
160
+ - And ~/.local/state/overleaf_sync/logs/ if configured.
161
+
162
+ Notes
163
+ - This tool is pull-only; it never pushes to Overleaf.
164
+ - Safari cookie access may require permissions; Firefox is often more reliable for unattended use.
165
+ - If Safari access fails, paste Overleaf cookies once via `set-cookie` to avoid elevated access.
166
+ - Use Git credential helpers for smooth pulls:
167
+ ```bash
168
+ git config --global credential.helper osxkeychain # macOS
169
+ git config --global credential.helper libsecret # Linux
170
+ ```
171
+
172
+ Background runs
173
+ - To avoid interactive Git prompts in schedulers, set an Overleaf Git token once:
174
+ ```bash
175
+ overleaf-pull set-git-token
176
+ ```
177
+ - Without a token, new clones will fail with 403; existing repos may also fail if their remotes lack the token. Prompts are disabled in background.
178
+
@@ -0,0 +1,14 @@
1
+ overleaf_sync/__init__.py,sha256=DvTRO9kaeQIQAhZ70jOnDdO0QLpW6BVfQZzQRjE-OZM,35
2
+ overleaf_sync/cli.py,sha256=5KlQAs-lbBBv2edSak8ucdKwhX2FrcBUkvMfRcP-pb8,16209
3
+ overleaf_sync/config.py,sha256=76Fn4jLm7k0hEt-T_EUGF2XazDQNzTqxd-rU0HUjMPU,6772
4
+ overleaf_sync/cookies.py,sha256=VxAfKGpfcFO8XMDmFwHS-MB0yuF56B-Cd5Gcpd_Vg74,3069
5
+ overleaf_sync/git_ops.py,sha256=O2BTgFSRqzTERBDsFlQ-d_K7IUZFIcZEtUHEGft_BEY,6131
6
+ overleaf_sync/olbrowser_login.py,sha256=hLDlpGqfE4jRVh3FjC7uEyI5rnzZl8eFyVqMJITZ35w,2749
7
+ overleaf_sync/overleaf_api.py,sha256=jFAy91MAji3KiNCFtRxYE6707BlRZEHLryiDMALFzr0,1613
8
+ overleaf_sync/projects.py,sha256=n0V5D-1jJ7V1G8klikJVEfx2SkZj3I6q_VaPDWWR_B8,500
9
+ overleaf_sync/scheduler.py,sha256=qRZcpq-jzF4Ko6QEs-OKDw7kteLqoGG3lL25e6cAImA,4012
10
+ overleaf_sync/sync.py,sha256=Rv76Em39ZRVesS7aSIOsh1w-l3ER2mKOylUJ4KKY4Js,4252
11
+ overleaf_pull-0.1.0.dist-info/METADATA,sha256=5B_sU7VRfR7PIgHp2udLuGQyGtFSrGiGVI5zlVqNQI4,6349
12
+ overleaf_pull-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
13
+ overleaf_pull-0.1.0.dist-info/entry_points.txt,sha256=5eOqjySKRXlUJ5Xxudo4-JDn7HXkQYgodqXFx593Mns,57
14
+ overleaf_pull-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ overleaf-pull = overleaf_sync.cli:main
@@ -0,0 +1,2 @@
1
+ __all__ = []
2
+ __version__ = "0.1.0"
overleaf_sync/cli.py ADDED
@@ -0,0 +1,449 @@
1
+ import argparse
2
+ import webbrowser
3
+ import os
4
+ import platform
5
+ import sys
6
+ import concurrent.futures
7
+
8
+ from .config import load_config, prompt_first_run, save_config, Config, get_logs_dir
9
+ from .sync import run_sync_once, run_sync, run_sync_validate_first
10
+ from .scheduler import install_macos_launchagent, uninstall_macos_launchagent, install_systemd_user, uninstall_systemd_user
11
+ from .olbrowser_login import login_via_qt
12
+
13
+
14
+ def cmd_init(args):
15
+ cfg = load_config()
16
+ if cfg is None:
17
+ cfg = prompt_first_run()
18
+ else:
19
+ print("Config already exists; run with --reset to reconfigure.")
20
+ # Optionally install scheduler
21
+ if args.install:
22
+ install_scheduler(cfg)
23
+
24
+
25
+ def install_scheduler(cfg: Config):
26
+ os_name = platform.system()
27
+ interval = cfg.sync_interval
28
+ if os_name == "Darwin":
29
+ install_macos_launchagent(interval)
30
+ else:
31
+ install_systemd_user(interval)
32
+
33
+
34
+ def uninstall_scheduler():
35
+ os_name = platform.system()
36
+ if os_name == "Darwin":
37
+ uninstall_macos_launchagent()
38
+ else:
39
+ uninstall_systemd_user()
40
+
41
+
42
+ def cmd_install(args):
43
+ cfg = load_config() or prompt_first_run()
44
+ # Run a manual sync first to validate config and access
45
+ try:
46
+ print("Running a validation sync before installing scheduler...")
47
+ run_sync_validate_first(cfg)
48
+ except Exception as e:
49
+ print(f"Validation sync failed: {e}")
50
+ print("Not installing scheduler. Fix the issue and retry.")
51
+ return
52
+ # Uninstall any existing scheduler instance, then install fresh
53
+ print("Ensuring scheduler is installed exactly once (uninstalling any existing instance)...")
54
+ try:
55
+ uninstall_scheduler()
56
+ except Exception:
57
+ # Ignore uninstall errors (e.g., not installed)
58
+ pass
59
+ install_scheduler(cfg)
60
+
61
+
62
+ def cmd_uninstall(args):
63
+ uninstall_scheduler()
64
+
65
+
66
+ def cmd_run_once(args):
67
+ run_sync_once()
68
+ def cmd_sync(args):
69
+ cfg = load_config() or prompt_first_run()
70
+ # Apply one-off overrides
71
+ if getattr(args, "count", None):
72
+ cfg.count = args.count
73
+ if getattr(args, "base_dir", None):
74
+ cfg.base_dir = args.base_dir
75
+ if getattr(args, "browser", None):
76
+ cfg.browser = args.browser
77
+ if getattr(args, "profile", None):
78
+ cfg.profile = args.profile
79
+ run_sync(cfg)
80
+
81
+
82
+
83
+ def cmd_set_interval(args):
84
+ cfg = load_config() or prompt_first_run()
85
+ val = args.interval
86
+ if val not in ("1h", "12h", "24h"):
87
+ print("Invalid interval; choose 1h, 12h, or 24h")
88
+ sys.exit(2)
89
+ cfg.sync_interval = val
90
+ save_config(cfg)
91
+ print("Updated interval; reinstall scheduler if needed.")
92
+
93
+
94
+ def cmd_set_count(args):
95
+ cfg = load_config() or prompt_first_run()
96
+ cfg.count = args.count
97
+ save_config(cfg)
98
+ print("Updated latest projects count.")
99
+
100
+
101
+ def cmd_set_base_dir(args):
102
+ cfg = load_config() or prompt_first_run()
103
+ cfg.base_dir = args.base_dir
104
+ save_config(cfg)
105
+ print("Updated base directory.")
106
+
107
+
108
+ def cmd_set_cookie(args):
109
+ cfg = load_config() or prompt_first_run()
110
+ value = args.value
111
+ if not value:
112
+ print("Paste cookie string, then press Ctrl-D (EOF):")
113
+ try:
114
+ value = sys.stdin.read()
115
+ except KeyboardInterrupt:
116
+ value = ""
117
+ from .cookies import parse_cookie_string
118
+ try:
119
+ cfg.cookies = parse_cookie_string(value)
120
+ save_config(cfg)
121
+ missing = [k for k in ("overleaf_session2", "GCLB") if k not in cfg.cookies]
122
+ print("Stored cookies in config.")
123
+ if missing:
124
+ print(f"Warning: missing expected cookie(s): {', '.join(missing)}. Make sure you copied the full Cookie header from the Network tab for a request to {cfg.host}.")
125
+ except Exception as e:
126
+ print(f"Failed to parse cookies: {e}")
127
+
128
+
129
+ def cmd_clear_cookie(args):
130
+ cfg = load_config() or prompt_first_run()
131
+ cfg.cookies = None
132
+ save_config(cfg)
133
+ print("Cleared stored cookies from config.")
134
+
135
+
136
+ def cmd_set_git_token(args):
137
+ cfg = load_config() or prompt_first_run()
138
+ token = args.value
139
+ if not token:
140
+ try:
141
+ token = input("Overleaf Git authentication token: ").strip()
142
+ except KeyboardInterrupt:
143
+ token = ""
144
+ if not token:
145
+ print("No token provided.")
146
+ return
147
+ cfg.git_token = token
148
+ save_config(cfg)
149
+ print("Stored Overleaf Git token in config. Keep it secret.")
150
+
151
+
152
+ def cmd_clear_git_token(args):
153
+ cfg = load_config() or prompt_first_run()
154
+ cfg.git_token = None
155
+ save_config(cfg)
156
+ print("Cleared Overleaf Git token from config.")
157
+
158
+
159
+ def cmd_set_name_suffix(args):
160
+ cfg = load_config() or prompt_first_run()
161
+ val = args.value.lower()
162
+ if val not in ("on", "off"):
163
+ print("Invalid value; use 'on' or 'off'")
164
+ return
165
+ cfg.append_id_suffix = (val == "on")
166
+ save_config(cfg)
167
+ print(f"Folder name ID suffix {'enabled' if cfg.append_id_suffix else 'disabled'}.")
168
+
169
+
170
+ def _tail(path: str, lines: int = 50) -> list[str]:
171
+ try:
172
+ with open(path, "r", encoding="utf-8") as f:
173
+ content = f.readlines()
174
+ return content[-lines:]
175
+ except Exception:
176
+ return []
177
+
178
+
179
+ def cmd_status(args):
180
+ # Sync health check: verify local repos match remote heads
181
+ cfg = load_config() or prompt_first_run()
182
+ if not cfg.git_token:
183
+ print("Git token missing. Run 'overleaf-pull set-git-token'.")
184
+ return
185
+ # Gather projects
186
+ cookies = cfg.cookies if cfg.cookies else None
187
+ if not cookies:
188
+ from .cookies import load_overleaf_cookies
189
+ cookies = load_overleaf_cookies(cfg.browser, cfg.profile)
190
+ from .overleaf_api import create_api, list_projects_sorted_by_last_updated
191
+ api = create_api(cfg.host)
192
+ projects = list_projects_sorted_by_last_updated(api, cookies, cfg.count)
193
+
194
+ from .projects import folder_name_for
195
+ from .git_ops import ensure_remote, detect_default_branch, get_remote_branch_head, get_local_branch_head
196
+
197
+ total = len(projects)
198
+ if total == 0:
199
+ print("No projects found.")
200
+ return
201
+ print(f"Checking status for {total} latest project(s)...", flush=True)
202
+
203
+ def _check(p: dict):
204
+ pid = p.get("id")
205
+ name = p.get("name")
206
+ folder = folder_name_for(name, pid)
207
+ repo_path = os.path.join(cfg.base_dir, folder)
208
+ if not os.path.isdir(os.path.join(repo_path, ".git")):
209
+ return ("missing", f"Missing: {name}")
210
+ try:
211
+ ensure_remote(repo_path, pid, cfg.git_token)
212
+ branch = detect_default_branch(repo_path)
213
+ rhead = get_remote_branch_head(repo_path, branch)
214
+ lhead = get_local_branch_head(repo_path, branch)
215
+ if not rhead or not lhead:
216
+ return ("outdated", f"Outdated: {name} (unable to determine heads)")
217
+ if rhead != lhead:
218
+ return ("outdated", f"Outdated: {name} (remote {rhead[:7]} vs local {lhead[:7]})")
219
+ return ("up", None)
220
+ except Exception as e:
221
+ return ("outdated", f"Outdated: {name} (error: {e})")
222
+
223
+ up_to_date = 0
224
+ missing = 0
225
+ outdated = 0
226
+ issues: list[str] = []
227
+ done = 0
228
+ max_workers = min(16, total)
229
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as ex:
230
+ futures = [ex.submit(_check, p) for p in projects]
231
+ for fut in concurrent.futures.as_completed(futures):
232
+ status, msg = fut.result()
233
+ done += 1
234
+ if status == "up":
235
+ up_to_date += 1
236
+ elif status == "missing":
237
+ missing += 1
238
+ if msg:
239
+ issues.append(msg)
240
+ else:
241
+ outdated += 1
242
+ if msg:
243
+ issues.append(msg)
244
+ print(f"Summary: {up_to_date}/{total} up to date, {missing}/{total} missing, {outdated}/{total} outdated.")
245
+
246
+ # Identify old projects (not in latest set)
247
+ from .projects import folder_name_for
248
+ expected = {folder_name_for(p.get("name"), p.get("id")) for p in projects}
249
+ old_repos = []
250
+ for entry in os.listdir(cfg.base_dir):
251
+ path = os.path.join(cfg.base_dir, entry)
252
+ if os.path.isdir(os.path.join(path, ".git")) and entry not in expected:
253
+ old_repos.append(path)
254
+
255
+ lingering = []
256
+ removed = []
257
+ if args.prune and old_repos:
258
+ import shutil
259
+ from .git_ops import is_worktree_clean, has_unpushed_commits
260
+ for repo in old_repos:
261
+ branch = detect_default_branch(repo)
262
+ clean = is_worktree_clean(repo)
263
+ ahead = has_unpushed_commits(repo, branch)
264
+ if clean and ahead is False:
265
+ try:
266
+ shutil.rmtree(repo)
267
+ removed.append(repo)
268
+ except Exception:
269
+ lingering.append(repo)
270
+ else:
271
+ lingering.append(repo)
272
+
273
+ if removed:
274
+ print(f"Pruned {len(removed)} old project(s).")
275
+ if lingering:
276
+ print(f"Lingering old projects (cannot delete safely): {len(lingering)}")
277
+ for r in lingering[:5]:
278
+ print(f"- {r}")
279
+
280
+ if not issues:
281
+ # Everything OK
282
+ logs_dir = get_logs_dir()
283
+ app_log = os.path.join(logs_dir, "app.log")
284
+ runner_log = os.path.join(logs_dir, "runner.log")
285
+ runner_err = os.path.join(logs_dir, "runner.err.log")
286
+ has_runner_logs = (os.path.exists(runner_log) or os.path.exists(runner_err))
287
+ last_success = None
288
+ if os.path.exists(app_log):
289
+ lines = _tail(app_log, 200)
290
+ for line in reversed(lines):
291
+ line = line.strip()
292
+ if line.startswith("[") and "] Synced" in line:
293
+ last_success = line
294
+ break
295
+ if last_success and has_runner_logs:
296
+ print(f"Background runner OK. {last_success}")
297
+ elif last_success:
298
+ print(f"Manual sync OK. {last_success}")
299
+ else:
300
+ if has_runner_logs:
301
+ print("Everything OK. No successful sync recorded yet.")
302
+ else:
303
+ print("Everything OK. No background runs recorded yet.")
304
+ return
305
+
306
+ # Print brief issues overview
307
+ if issues:
308
+ print("Issues:")
309
+ for msg in issues[:10]:
310
+ print(f"- {msg}")
311
+
312
+
313
+ def cmd_browser_login(args):
314
+ """Guide the user to obtain cookies via the browser (manual copy)."""
315
+ cfg = load_config() or prompt_first_run()
316
+ url = f"https://{cfg.host}/project"
317
+ print("Opening Overleaf in your default browser. If not logged in, please log in.")
318
+ try:
319
+ webbrowser.open(url)
320
+ except Exception:
321
+ print(f"Please open {url} manually.")
322
+
323
+ print("\nAfter login, copy your cookie string:")
324
+ print("- Open Developer Tools → Network, select any request to your Overleaf host.")
325
+ print("- Copy the full 'Cookie' header value (it includes HttpOnly cookies).")
326
+ print("- document.cookie is insufficient; it misses HttpOnly cookies like overleaf_session2.")
327
+ print("Paste cookie below and press Ctrl-D (EOF) when done:\n")
328
+ try:
329
+ value = sys.stdin.read()
330
+ except KeyboardInterrupt:
331
+ print("Aborted.")
332
+ return
333
+ if not value.strip():
334
+ print("No cookie provided.")
335
+ return
336
+ from .cookies import parse_cookie_string
337
+ try:
338
+ cfg.cookies = parse_cookie_string(value)
339
+ save_config(cfg)
340
+ print("Stored cookies in config.")
341
+ # Optional quick validation
342
+ try:
343
+ run_sync(cfg)
344
+ print("Cookie validation succeeded (projects synced).")
345
+ except Exception as e:
346
+ print(f"Validation failed (will keep cookies saved): {e}")
347
+ except Exception as e:
348
+ print(f"Failed to parse cookies: {e}")
349
+
350
+
351
+ def cmd_browser_login_qt(args):
352
+ """Open a Qt WebEngine window to login and capture cookies automatically."""
353
+ cfg = load_config() or prompt_first_run()
354
+ try:
355
+ store = login_via_qt()
356
+ except RuntimeError as e:
357
+ print(str(e))
358
+ return
359
+ if not store:
360
+ print("Login did not complete.")
361
+ return
362
+ # Store captured cookies and csrf
363
+ cfg.cookies = store.get("cookie")
364
+ save_config(cfg)
365
+ missing = [k for k in ("overleaf_session2", "GCLB") if not cfg.cookies or k not in cfg.cookies]
366
+ if missing:
367
+ print(f"Warning: missing expected cookie(s): {', '.join(missing)}")
368
+ print("Stored cookies from Qt browser login.")
369
+ # Optional quick validation
370
+ try:
371
+ run_sync(cfg)
372
+ print("Cookie validation succeeded (projects synced).")
373
+ except Exception as e:
374
+ print(f"Validation failed (will keep cookies saved): {e}")
375
+
376
+
377
+ def main():
378
+ parser = argparse.ArgumentParser(prog="overleaf-pull", description="Pull-only Overleaf project sync")
379
+ sub = parser.add_subparsers(dest="cmd")
380
+
381
+ p_init = sub.add_parser("init", help="First-run setup and optional scheduler install")
382
+ p_init.add_argument("--install", action="store_true", help="Install background scheduler after setup")
383
+ p_init.set_defaults(func=cmd_init)
384
+
385
+ p_install = sub.add_parser("install-scheduler", help="Install background scheduler (LaunchAgent/systemd)")
386
+ p_install.set_defaults(func=cmd_install)
387
+
388
+ p_uninstall = sub.add_parser("uninstall-scheduler", help="Uninstall background scheduler")
389
+ p_uninstall.set_defaults(func=cmd_uninstall)
390
+
391
+ p_run = sub.add_parser("run-once", help="Run a single pull-only sync now")
392
+ p_run.set_defaults(func=cmd_run_once)
393
+
394
+ p_sync = sub.add_parser("sync", help="Manual sync with optional overrides")
395
+ p_sync.add_argument("--count", type=int, help="Override latest projects count for this run")
396
+ p_sync.add_argument("--base-dir", help="Override base directory for this run")
397
+ p_sync.add_argument("--browser", choices=["safari", "firefox"], help="Override browser for this run")
398
+ p_sync.add_argument("--profile", help="Override profile for this run")
399
+ p_sync.set_defaults(func=cmd_sync)
400
+
401
+ p_si = sub.add_parser("set-interval", help="Set sync interval (1h|12h|24h)")
402
+ p_si.add_argument("interval", choices=["1h", "12h", "24h"])
403
+ p_si.set_defaults(func=cmd_set_interval)
404
+
405
+ p_sc = sub.add_parser("set-count", help="Set latest projects count")
406
+ p_sc.add_argument("count", type=int)
407
+ p_sc.set_defaults(func=cmd_set_count)
408
+
409
+ p_sb = sub.add_parser("set-base-dir", help="Set base directory for clones")
410
+ p_sb.add_argument("base_dir")
411
+ p_sb.set_defaults(func=cmd_set_base_dir)
412
+
413
+ p_scook = sub.add_parser("set-cookie", help="Store Overleaf cookies in config (paste or pass string)")
414
+ p_scook.add_argument("value", nargs="?", help="Cookie header or 'name=value; name2=value2' string")
415
+ p_scook.set_defaults(func=cmd_set_cookie)
416
+
417
+ p_ccook = sub.add_parser("clear-cookie", help="Clear stored cookies from config")
418
+ p_ccook.set_defaults(func=cmd_clear_cookie)
419
+
420
+ p_status = sub.add_parser("status", help="Show current sync state; optional prune old projects")
421
+ p_status.add_argument("--prune", action="store_true", help="Remove old local projects not in latest set if safe")
422
+ p_status.set_defaults(func=cmd_status)
423
+
424
+ p_blogin = sub.add_parser("browser-login", help="Open browser and guide you to copy cookies")
425
+ p_blogin.set_defaults(func=cmd_browser_login)
426
+
427
+ p_blogin_qt = sub.add_parser("browser-login-qt", help="Use a Qt browser to login and auto-capture cookies (requires PySide6)")
428
+ p_blogin_qt.set_defaults(func=cmd_browser_login_qt)
429
+
430
+ p_sgt = sub.add_parser("set-git-token", help="Store Overleaf Git authentication token for cloning/pulling")
431
+ p_sgt.add_argument("value", nargs="?", help="Token string")
432
+ p_sgt.set_defaults(func=cmd_set_git_token)
433
+
434
+ p_cgt = sub.add_parser("clear-git-token", help="Clear stored Overleaf Git token")
435
+ p_cgt.set_defaults(func=cmd_clear_git_token)
436
+
437
+ p_ns = sub.add_parser("set-name-suffix", help="Toggle appending short project ID to folder names (on|off)")
438
+ p_ns.add_argument("value", choices=["on", "off"])
439
+ p_ns.set_defaults(func=cmd_set_name_suffix)
440
+
441
+ args = parser.parse_args()
442
+ if not hasattr(args, "func"):
443
+ parser.print_help()
444
+ sys.exit(1)
445
+ args.func(args)
446
+
447
+
448
+ if __name__ == "__main__":
449
+ main()