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.
- overleaf_pull-0.1.0.dist-info/METADATA +178 -0
- overleaf_pull-0.1.0.dist-info/RECORD +14 -0
- overleaf_pull-0.1.0.dist-info/WHEEL +4 -0
- overleaf_pull-0.1.0.dist-info/entry_points.txt +2 -0
- overleaf_sync/__init__.py +2 -0
- overleaf_sync/cli.py +449 -0
- overleaf_sync/config.py +197 -0
- overleaf_sync/cookies.py +82 -0
- overleaf_sync/git_ops.py +167 -0
- overleaf_sync/olbrowser_login.py +71 -0
- overleaf_sync/overleaf_api.py +38 -0
- overleaf_sync/projects.py +18 -0
- overleaf_sync/scheduler.py +132 -0
- overleaf_sync/sync.py +113 -0
|
@@ -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,,
|
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()
|