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
overleaf_sync/config.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import webbrowser
|
|
5
|
+
from dataclasses import dataclass, asdict
|
|
6
|
+
from typing import Optional, Tuple, Dict
|
|
7
|
+
|
|
8
|
+
APP_NAME = "overleaf_sync"
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Config:
|
|
12
|
+
base_dir: str
|
|
13
|
+
sync_interval: str = "1h" # one of: 1h, 12h, 24h
|
|
14
|
+
count: int = 10
|
|
15
|
+
browser: str = "safari" # safari|firefox
|
|
16
|
+
profile: Optional[str] = None
|
|
17
|
+
host: str = "www.overleaf.com"
|
|
18
|
+
git_helper: bool = True
|
|
19
|
+
cookies: Optional[Dict[str, str]] = None
|
|
20
|
+
git_token: Optional[str] = None
|
|
21
|
+
append_id_suffix: bool = True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _mac_paths() -> Tuple[str, str, str]:
|
|
25
|
+
home = os.path.expanduser("~")
|
|
26
|
+
support = os.path.join(home, "Library", "Application Support", APP_NAME)
|
|
27
|
+
logs = os.path.join(home, "Library", "Logs", APP_NAME)
|
|
28
|
+
caches = os.path.join(home, "Library", "Caches", APP_NAME)
|
|
29
|
+
return support, logs, caches
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _linux_paths() -> Tuple[str, str, str]:
|
|
33
|
+
home = os.path.expanduser("~")
|
|
34
|
+
config_home = os.environ.get("XDG_CONFIG_HOME", os.path.join(home, ".config"))
|
|
35
|
+
state_home = os.environ.get("XDG_STATE_HOME", os.path.join(home, ".local", "state"))
|
|
36
|
+
cache_home = os.environ.get("XDG_CACHE_HOME", os.path.join(home, ".cache"))
|
|
37
|
+
support = os.path.join(config_home, APP_NAME)
|
|
38
|
+
logs = os.path.join(state_home, APP_NAME, "logs")
|
|
39
|
+
caches = os.path.join(cache_home, APP_NAME)
|
|
40
|
+
return support, logs, caches
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_app_paths() -> Tuple[str, str, str]:
|
|
44
|
+
if platform.system() == "Darwin":
|
|
45
|
+
return _mac_paths()
|
|
46
|
+
return _linux_paths()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_config_path() -> str:
|
|
50
|
+
support, _, _ = get_app_paths()
|
|
51
|
+
os.makedirs(support, exist_ok=True)
|
|
52
|
+
return os.path.join(support, "config.json")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_logs_dir() -> str:
|
|
56
|
+
_, logs, _ = get_app_paths()
|
|
57
|
+
os.makedirs(logs, exist_ok=True)
|
|
58
|
+
return logs
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_cache_dir() -> str:
|
|
62
|
+
_, _, caches = get_app_paths()
|
|
63
|
+
os.makedirs(caches, exist_ok=True)
|
|
64
|
+
return caches
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_config() -> Optional[Config]:
|
|
68
|
+
cfg_path = get_config_path()
|
|
69
|
+
if not os.path.exists(cfg_path):
|
|
70
|
+
return None
|
|
71
|
+
with open(cfg_path, "r", encoding="utf-8") as f:
|
|
72
|
+
data = json.load(f)
|
|
73
|
+
return Config(**data)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_config(cfg: Config) -> None:
|
|
77
|
+
cfg_path = get_config_path()
|
|
78
|
+
with open(cfg_path, "w", encoding="utf-8") as f:
|
|
79
|
+
json.dump(asdict(cfg), f, indent=2)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def default_base_dir() -> str:
|
|
83
|
+
home = os.path.expanduser("~")
|
|
84
|
+
if platform.system() == "Darwin":
|
|
85
|
+
return os.path.join(home, "Documents", "Overleaf")
|
|
86
|
+
# Linux default
|
|
87
|
+
return os.path.join(home, "Overleaf")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def prompt_first_run() -> Config:
|
|
91
|
+
print("First-time setup for Overleaf Sync")
|
|
92
|
+
# Base directory
|
|
93
|
+
default_dir = default_base_dir()
|
|
94
|
+
base_dir = input(f"Base directory to clone projects into [{default_dir}]: ").strip() or default_dir
|
|
95
|
+
os.makedirs(base_dir, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
# Interval
|
|
98
|
+
interval_options = {"1": "1h", "2": "12h", "3": "24h"}
|
|
99
|
+
print("Select sync interval:")
|
|
100
|
+
print(" 1) 1 hour (default)")
|
|
101
|
+
print(" 2) 12 hours")
|
|
102
|
+
print(" 3) 24 hours")
|
|
103
|
+
interval_choice = input("Choice [1/2/3]: ").strip() or "1"
|
|
104
|
+
sync_interval = interval_options.get(interval_choice, "1h")
|
|
105
|
+
|
|
106
|
+
# Count
|
|
107
|
+
count_in = input("Number of latest projects to sync [10]: ").strip()
|
|
108
|
+
try:
|
|
109
|
+
count = int(count_in) if count_in else 10
|
|
110
|
+
except ValueError:
|
|
111
|
+
count = 10
|
|
112
|
+
|
|
113
|
+
# Browser default
|
|
114
|
+
browser_default = "safari" if platform.system() == "Darwin" else "firefox"
|
|
115
|
+
browser_in = input(f"Browser to read Overleaf cookies from [safari|firefox] (default {browser_default}): ").strip().lower()
|
|
116
|
+
if browser_in not in ("safari", "firefox", ""):
|
|
117
|
+
browser = browser_default
|
|
118
|
+
else:
|
|
119
|
+
browser = browser_in or browser_default
|
|
120
|
+
|
|
121
|
+
# Host
|
|
122
|
+
host_in = input("Overleaf host [www.overleaf.com]: ").strip()
|
|
123
|
+
host = host_in or "www.overleaf.com"
|
|
124
|
+
|
|
125
|
+
# Prefer Qt browser login to capture cookies automatically (if available)
|
|
126
|
+
cookies: Optional[Dict[str, str]] = None
|
|
127
|
+
try_qt = input("Use Qt browser to login and auto-capture cookies now? [Y/n]: ").strip().lower()
|
|
128
|
+
if try_qt != "n":
|
|
129
|
+
try:
|
|
130
|
+
from .olbrowser_login import login_via_qt
|
|
131
|
+
store = login_via_qt()
|
|
132
|
+
if store and store.get("cookie"):
|
|
133
|
+
cookies = store.get("cookie")
|
|
134
|
+
print("Stored cookies from Qt browser login.")
|
|
135
|
+
else:
|
|
136
|
+
print("Qt login did not complete; skipping.")
|
|
137
|
+
except RuntimeError as e:
|
|
138
|
+
print(str(e))
|
|
139
|
+
except Exception:
|
|
140
|
+
print("Qt login failed; you can set cookies later via 'overleaf-pull set-cookie'.")
|
|
141
|
+
|
|
142
|
+
# Git helper
|
|
143
|
+
git_helper_ans = input("Enable OS Git credential helper? [Y/n]: ").strip().lower()
|
|
144
|
+
git_helper = (git_helper_ans != "n")
|
|
145
|
+
|
|
146
|
+
# Git token (required for cloning/pulling)
|
|
147
|
+
print("Overleaf now requires a Git authentication token for cloning/pulling.")
|
|
148
|
+
print("Find it via your Overleaf project's Git panel or account settings.")
|
|
149
|
+
open_help = input("Open Overleaf in your browser to fetch the token now? [Y/n]: ").strip().lower()
|
|
150
|
+
if open_help != "n":
|
|
151
|
+
try:
|
|
152
|
+
webbrowser.open(f"https://{host}/project")
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
git_token = ""
|
|
156
|
+
while not git_token:
|
|
157
|
+
git_token = input("Enter Overleaf Git token (required): ").strip()
|
|
158
|
+
if not git_token:
|
|
159
|
+
print("Token cannot be empty. Please paste your token.")
|
|
160
|
+
|
|
161
|
+
# Folder naming preference
|
|
162
|
+
ans = input("Append short project ID to folder names to avoid collisions? [Y/n]: ").strip().lower()
|
|
163
|
+
append_id_suffix = (ans != "n")
|
|
164
|
+
|
|
165
|
+
# Optional: paste Overleaf cookies (JSON map or Cookie header) if not captured
|
|
166
|
+
if not cookies:
|
|
167
|
+
print("Optional: paste Overleaf cookies to avoid browser access (press Enter to skip).")
|
|
168
|
+
cookie_in = input("Cookies (JSON map or 'name=value; name2=value2'): ").strip()
|
|
169
|
+
if cookie_in:
|
|
170
|
+
from .cookies import parse_cookie_string
|
|
171
|
+
try:
|
|
172
|
+
cookies = parse_cookie_string(cookie_in)
|
|
173
|
+
except Exception:
|
|
174
|
+
try:
|
|
175
|
+
# Try JSON
|
|
176
|
+
import json as _json
|
|
177
|
+
data = _json.loads(cookie_in)
|
|
178
|
+
if isinstance(data, dict):
|
|
179
|
+
cookies = {str(k): str(v) for k, v in data.items()}
|
|
180
|
+
except Exception:
|
|
181
|
+
cookies = None
|
|
182
|
+
|
|
183
|
+
cfg = Config(
|
|
184
|
+
base_dir=base_dir,
|
|
185
|
+
sync_interval=sync_interval,
|
|
186
|
+
count=count,
|
|
187
|
+
browser=browser,
|
|
188
|
+
profile=None,
|
|
189
|
+
host=host,
|
|
190
|
+
git_helper=git_helper,
|
|
191
|
+
cookies=cookies,
|
|
192
|
+
git_token=git_token,
|
|
193
|
+
append_id_suffix=append_id_suffix,
|
|
194
|
+
)
|
|
195
|
+
save_config(cfg)
|
|
196
|
+
print(f"Saved config to {get_config_path()}")
|
|
197
|
+
return cfg
|
overleaf_sync/cookies.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import tempfile
|
|
4
|
+
from typing import Dict, List
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from rookiepy import safari as rookie_safari
|
|
8
|
+
from rookiepy import firefox as rookie_firefox
|
|
9
|
+
except Exception: # pragma: no cover
|
|
10
|
+
rookie_safari = None
|
|
11
|
+
rookie_firefox = None
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import browsercookie # type: ignore
|
|
15
|
+
except Exception: # pragma: no cover
|
|
16
|
+
browsercookie = None
|
|
17
|
+
|
|
18
|
+
OVERLEAF_DOMAINS = ["overleaf.com", ".overleaf.com", "www.overleaf.com"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _to_cookie_dict(cookies: List[dict]) -> Dict[str, str]:
|
|
22
|
+
jar: Dict[str, str] = {}
|
|
23
|
+
for c in cookies:
|
|
24
|
+
name = c.get("name") or c.get("Name")
|
|
25
|
+
value = c.get("value") or c.get("Value")
|
|
26
|
+
domain = c.get("domain") or c.get("Domain")
|
|
27
|
+
if not name or value is None:
|
|
28
|
+
continue
|
|
29
|
+
if domain and not any(d in domain for d in OVERLEAF_DOMAINS):
|
|
30
|
+
continue
|
|
31
|
+
jar[name] = value
|
|
32
|
+
return jar
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_cookie_string(s: str) -> Dict[str, str]:
|
|
36
|
+
"""Parse a Cookie header or simple `name=value; name2=value2` string into a dict.
|
|
37
|
+
|
|
38
|
+
Accepts optional leading 'Cookie:' and trims whitespace.
|
|
39
|
+
"""
|
|
40
|
+
s = s.strip()
|
|
41
|
+
if s.lower().startswith("cookie:"):
|
|
42
|
+
s = s.split(":", 1)[1].strip()
|
|
43
|
+
jar: Dict[str, str] = {}
|
|
44
|
+
parts = [p.strip() for p in s.split(";") if p.strip()]
|
|
45
|
+
for part in parts:
|
|
46
|
+
if "=" not in part:
|
|
47
|
+
continue
|
|
48
|
+
name, value = part.split("=", 1)
|
|
49
|
+
jar[name.strip()] = value.strip()
|
|
50
|
+
return jar
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_overleaf_cookies(browser: str, profile: str | None = None) -> Dict[str, str]:
|
|
54
|
+
"""Load Overleaf cookies using Rookie from the selected browser/profile.
|
|
55
|
+
|
|
56
|
+
Returns a dict of cookie name -> value suitable for PyOverleaf Api.login_from_cookies.
|
|
57
|
+
"""
|
|
58
|
+
if browser == "safari":
|
|
59
|
+
if rookie_safari is None:
|
|
60
|
+
raise RuntimeError("Safari cookie access requires rookiepy; install with 'uv add rookiepy' or 'pip install rookiepy'. On macOS, granting Full Disk Access to your terminal may be required.")
|
|
61
|
+
# Rookie handles Safari paths internally
|
|
62
|
+
cookies = rookie_safari(OVERLEAF_DOMAINS)
|
|
63
|
+
return _to_cookie_dict(cookies)
|
|
64
|
+
elif browser == "firefox":
|
|
65
|
+
if rookie_firefox is None:
|
|
66
|
+
# Fallback to browsercookie for Firefox if rookiepy is unavailable
|
|
67
|
+
if browsercookie is None:
|
|
68
|
+
raise RuntimeError("Firefox cookie access requires rookiepy or browsercookie.")
|
|
69
|
+
cj = browsercookie.firefox()
|
|
70
|
+
jar: Dict[str, str] = {}
|
|
71
|
+
for c in cj:
|
|
72
|
+
domain = getattr(c, "domain", None)
|
|
73
|
+
if domain and not any(d in domain for d in OVERLEAF_DOMAINS):
|
|
74
|
+
continue
|
|
75
|
+
jar[c.name] = c.value
|
|
76
|
+
return jar
|
|
77
|
+
# Firefox may lock cookies.sqlite; Rookie generally reads via its own logic,
|
|
78
|
+
# but if needed, we can copy the profile dir to a temp path.
|
|
79
|
+
cookies = rookie_firefox(OVERLEAF_DOMAINS)
|
|
80
|
+
return _to_cookie_dict(cookies)
|
|
81
|
+
else:
|
|
82
|
+
raise ValueError("Unsupported browser; choose 'safari' or 'firefox'.")
|
overleaf_sync/git_ops.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
REMOTE_NAME = "overleaf"
|
|
6
|
+
REMOTE_URL_FMT = "https://git.overleaf.com/{id}"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_remote_url(project_id: str, token: Optional[str] = None) -> str:
|
|
10
|
+
if token:
|
|
11
|
+
return f"https://git:{token}@git.overleaf.com/{project_id}"
|
|
12
|
+
return REMOTE_URL_FMT.format(id=project_id)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _git_env() -> dict:
|
|
16
|
+
env = os.environ.copy()
|
|
17
|
+
env.setdefault("GIT_TERMINAL_PROMPT", "0")
|
|
18
|
+
return env
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run(cmd: list[str], cwd: Optional[str] = None) -> subprocess.CompletedProcess:
|
|
22
|
+
return subprocess.run(cmd, cwd=cwd, check=False, capture_output=True, text=True, env=_git_env())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run_stream(cmd: list[str], cwd: Optional[str] = None, mask_token: Optional[str] = None) -> tuple[int, str]:
|
|
26
|
+
"""Run a command and stream combined stdout/stderr live to the console.
|
|
27
|
+
|
|
28
|
+
Returns (returncode, combined_output). Masks token occurrences in output if provided.
|
|
29
|
+
"""
|
|
30
|
+
proc = subprocess.Popen(
|
|
31
|
+
cmd,
|
|
32
|
+
cwd=cwd,
|
|
33
|
+
stdout=subprocess.PIPE,
|
|
34
|
+
stderr=subprocess.STDOUT,
|
|
35
|
+
text=True,
|
|
36
|
+
env=_git_env(),
|
|
37
|
+
bufsize=1,
|
|
38
|
+
)
|
|
39
|
+
combined: list[str] = []
|
|
40
|
+
if proc.stdout is not None:
|
|
41
|
+
for line in proc.stdout:
|
|
42
|
+
s = line.rstrip("\n")
|
|
43
|
+
if mask_token:
|
|
44
|
+
s = s.replace(mask_token, "***")
|
|
45
|
+
print(s)
|
|
46
|
+
combined.append(s)
|
|
47
|
+
rc = proc.wait()
|
|
48
|
+
return rc, "\n".join(combined)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def repo_exists(path: str) -> bool:
|
|
52
|
+
return os.path.isdir(os.path.join(path, ".git"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def clone_if_missing(base_dir: str, folder: str, project_id: str, token: Optional[str] = None) -> str:
|
|
56
|
+
path = os.path.join(base_dir, folder)
|
|
57
|
+
if not repo_exists(path):
|
|
58
|
+
url = build_remote_url(project_id, token)
|
|
59
|
+
safe_url = url
|
|
60
|
+
if token:
|
|
61
|
+
safe_url = url.replace(token, "***")
|
|
62
|
+
print(f"$ git clone {safe_url} {path}")
|
|
63
|
+
rc, combined = _run_stream(["git", "clone", url, path], mask_token=token)
|
|
64
|
+
if rc != 0:
|
|
65
|
+
raise RuntimeError(f"git clone failed: {combined.splitlines()[-1] if combined else 'unknown error'}")
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ensure_remote(path: str, project_id: str, token: Optional[str] = None) -> None:
|
|
70
|
+
# If token is provided, ensure remote URL includes it; if not, avoid overriding
|
|
71
|
+
target_url = build_remote_url(project_id, token) if token else None
|
|
72
|
+
res = _run(["git", "remote", "get-url", REMOTE_NAME], cwd=path)
|
|
73
|
+
if res.returncode != 0:
|
|
74
|
+
if target_url:
|
|
75
|
+
safe_url = target_url
|
|
76
|
+
if token:
|
|
77
|
+
safe_url = target_url.replace(token, "***")
|
|
78
|
+
print(f"$ git remote add {REMOTE_NAME} {safe_url}")
|
|
79
|
+
_run(["git", "remote", "add", REMOTE_NAME, target_url], cwd=path)
|
|
80
|
+
return
|
|
81
|
+
current = res.stdout.strip()
|
|
82
|
+
if target_url and current != target_url:
|
|
83
|
+
safe_url = target_url
|
|
84
|
+
if token:
|
|
85
|
+
safe_url = target_url.replace(token, "***")
|
|
86
|
+
print(f"$ git remote set-url {REMOTE_NAME} {safe_url}")
|
|
87
|
+
_run(["git", "remote", "set-url", REMOTE_NAME, target_url], cwd=path)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def detect_default_branch(path: str) -> str:
|
|
91
|
+
# Try remote heads (quiet)
|
|
92
|
+
res = _run(["git", "ls-remote", "--heads", REMOTE_NAME], cwd=path)
|
|
93
|
+
heads = res.stdout.splitlines()
|
|
94
|
+
for line in heads:
|
|
95
|
+
if line.endswith("refs/heads/master"):
|
|
96
|
+
return "master"
|
|
97
|
+
for line in heads:
|
|
98
|
+
if line.endswith("refs/heads/main"):
|
|
99
|
+
return "main"
|
|
100
|
+
# Fallback to local current
|
|
101
|
+
res2 = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=path)
|
|
102
|
+
return res2.stdout.strip() or "master"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def pull_remote(path: str, branch: str) -> None:
|
|
106
|
+
print(f"$ git pull {REMOTE_NAME} {branch}")
|
|
107
|
+
rc, combined = _run_stream(["git", "pull", REMOTE_NAME, branch], cwd=path)
|
|
108
|
+
if rc != 0:
|
|
109
|
+
raise RuntimeError(f"git pull failed: {combined.splitlines()[-1] if combined else 'unknown error'}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_remote_branch_head(path: str, branch: str) -> Optional[str]:
|
|
113
|
+
"""Return the remote branch head commit SHA for the given branch, or None if not found (quiet)."""
|
|
114
|
+
ref = f"refs/heads/{branch}"
|
|
115
|
+
res = _run(["git", "ls-remote", REMOTE_NAME, ref], cwd=path)
|
|
116
|
+
if res.returncode != 0:
|
|
117
|
+
return None
|
|
118
|
+
line = (res.stdout or "").strip()
|
|
119
|
+
if not line:
|
|
120
|
+
return None
|
|
121
|
+
sha = line.split("\t")[0]
|
|
122
|
+
return sha or None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_local_branch_head(path: str, branch: str) -> Optional[str]:
|
|
126
|
+
"""Return the local branch head commit SHA, or HEAD if branch missing."""
|
|
127
|
+
res = _run(["git", "rev-parse", f"refs/heads/{branch}"], cwd=path)
|
|
128
|
+
if res.returncode == 0:
|
|
129
|
+
return (res.stdout or "").strip() or None
|
|
130
|
+
# Fallback to HEAD
|
|
131
|
+
res2 = _run(["git", "rev-parse", "HEAD"], cwd=path)
|
|
132
|
+
if res2.returncode == 0:
|
|
133
|
+
return (res2.stdout or "").strip() or None
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def is_worktree_clean(path: str) -> bool:
|
|
138
|
+
"""Return True if working tree and index are clean (no local modifications)."""
|
|
139
|
+
res = _run(["git", "status", "--porcelain"], cwd=path)
|
|
140
|
+
return res.returncode == 0 and (res.stdout.strip() == "")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def has_unpushed_commits(path: str, branch: str) -> Optional[bool]:
|
|
144
|
+
"""Return True if local is ahead of remote for branch. None if cannot determine."""
|
|
145
|
+
# Ensure remote ref exists locally; do a quiet ls-remote to confirm
|
|
146
|
+
rsha = get_remote_branch_head(path, branch)
|
|
147
|
+
if not rsha:
|
|
148
|
+
return None
|
|
149
|
+
# Count commits that are in HEAD but not in remote branch
|
|
150
|
+
res = _run(["git", "rev-list", f"overleaf/{branch}..HEAD", "--count"], cwd=path)
|
|
151
|
+
if res.returncode != 0:
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
cnt = int((res.stdout or "0").strip())
|
|
155
|
+
except ValueError:
|
|
156
|
+
return None
|
|
157
|
+
return cnt > 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def enable_git_helper(os_name: str) -> None:
|
|
161
|
+
if os_name == "Darwin":
|
|
162
|
+
_run(["git", "config", "--global", "credential.helper", "osxkeychain"])
|
|
163
|
+
else:
|
|
164
|
+
# Desktop Linux: libsecret; headless fallback to store
|
|
165
|
+
res = _run(["git", "config", "--global", "credential.helper", "libsecret"])
|
|
166
|
+
if res.returncode != 0:
|
|
167
|
+
_run(["git", "config", "--global", "credential.helper", "store"])
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
LOGIN_URL = "https://www.overleaf.com/login"
|
|
2
|
+
PROJECT_URL = "https://www.overleaf.com/project"
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def login_via_qt():
|
|
6
|
+
"""Open a Qt WebEngine browser to login and capture cookies + CSRF.
|
|
7
|
+
|
|
8
|
+
Returns a dict: {"cookie": {name: value, ...}, "csrf": str}
|
|
9
|
+
Requires PySide6 with Qt WebEngine.
|
|
10
|
+
"""
|
|
11
|
+
try:
|
|
12
|
+
from PySide6.QtCore import QUrl, QCoreApplication
|
|
13
|
+
from PySide6.QtWidgets import QApplication, QMainWindow
|
|
14
|
+
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
15
|
+
from PySide6.QtWebEngineCore import QWebEngineProfile, QWebEngineSettings, QWebEnginePage
|
|
16
|
+
except Exception as e:
|
|
17
|
+
raise RuntimeError(
|
|
18
|
+
"PySide6 (Qt WebEngine) is required for this command. Install with 'conda install -c conda-forge pyside6' or 'pip install PySide6'."
|
|
19
|
+
) from e
|
|
20
|
+
|
|
21
|
+
class _Window(QMainWindow):
|
|
22
|
+
def __init__(self):
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.webview = QWebEngineView()
|
|
25
|
+
self._cookies = {}
|
|
26
|
+
self._csrf = ""
|
|
27
|
+
self._done = False
|
|
28
|
+
|
|
29
|
+
self.profile = QWebEngineProfile(self.webview)
|
|
30
|
+
self.cookie_store = self.profile.cookieStore()
|
|
31
|
+
self.cookie_store.cookieAdded.connect(self._on_cookie_added)
|
|
32
|
+
self.profile.setPersistentCookiesPolicy(QWebEngineProfile.NoPersistentCookies)
|
|
33
|
+
self.profile.settings().setAttribute(QWebEngineSettings.JavascriptEnabled, True)
|
|
34
|
+
|
|
35
|
+
webpage = QWebEnginePage(self.profile, self)
|
|
36
|
+
self.webview.setPage(webpage)
|
|
37
|
+
self.webview.load(QUrl.fromUserInput(LOGIN_URL))
|
|
38
|
+
self.webview.loadFinished.connect(self._on_load_finished)
|
|
39
|
+
|
|
40
|
+
self.setCentralWidget(self.webview)
|
|
41
|
+
self.resize(700, 900)
|
|
42
|
+
|
|
43
|
+
def _on_cookie_added(self, cookie):
|
|
44
|
+
name = cookie.name().data().decode("utf-8")
|
|
45
|
+
if name in ("overleaf_session2", "GCLB"):
|
|
46
|
+
self._cookies[name] = cookie.value().data().decode("utf-8")
|
|
47
|
+
|
|
48
|
+
def _on_load_finished(self):
|
|
49
|
+
# When arriving at dashboard, extract csrf from meta
|
|
50
|
+
if self.webview.url().toString().startswith(PROJECT_URL):
|
|
51
|
+
def _cb(result):
|
|
52
|
+
self._csrf = result or ""
|
|
53
|
+
self._done = True
|
|
54
|
+
QCoreApplication.quit()
|
|
55
|
+
|
|
56
|
+
js = """
|
|
57
|
+
(function(){
|
|
58
|
+
var m = document.querySelector('meta[name="ol-csrfToken"]');
|
|
59
|
+
return m ? m.content : '';
|
|
60
|
+
})();
|
|
61
|
+
"""
|
|
62
|
+
self.webview.page().runJavaScript(js, 0, _cb)
|
|
63
|
+
|
|
64
|
+
app = QApplication([])
|
|
65
|
+
win = _Window()
|
|
66
|
+
win.show()
|
|
67
|
+
app.exec()
|
|
68
|
+
|
|
69
|
+
if not win._done:
|
|
70
|
+
return None
|
|
71
|
+
return {"cookie": win._cookies, "csrf": win._csrf}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import List, Dict, Any
|
|
2
|
+
|
|
3
|
+
# Attempt to import PyOverleaf Api with flexibility (module paths may vary)
|
|
4
|
+
try:
|
|
5
|
+
from pyoverleaf.api import Api # type: ignore
|
|
6
|
+
except Exception: # pragma: no cover
|
|
7
|
+
try:
|
|
8
|
+
from pyoverleaf import Api # type: ignore
|
|
9
|
+
except Exception:
|
|
10
|
+
Api = None # type: ignore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_api(host: str = "www.overleaf.com"):
|
|
14
|
+
if Api is None:
|
|
15
|
+
raise RuntimeError("pyoverleaf not installed; install with 'pip install pyoverleaf'.")
|
|
16
|
+
return Api(host=host)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def list_projects_sorted_by_last_updated(api, cookies: Dict[str, str], limit: int) -> List[Dict[str, Any]]:
|
|
20
|
+
"""Login with cookies and list projects, sorted by lastUpdated descending, limited to 'limit'."""
|
|
21
|
+
api.login_from_cookies(cookies)
|
|
22
|
+
projects = api.get_projects()
|
|
23
|
+
# Each project likely has attributes: id, name, lastUpdated
|
|
24
|
+
def last_updated(p):
|
|
25
|
+
# p may be a dict or an object; support both
|
|
26
|
+
v = getattr(p, "lastUpdated", None)
|
|
27
|
+
if v is None:
|
|
28
|
+
v = p.get("lastUpdated") if isinstance(p, dict) else None
|
|
29
|
+
return v or 0
|
|
30
|
+
|
|
31
|
+
projects_sorted = sorted(projects, key=last_updated, reverse=True)
|
|
32
|
+
result: List[Dict[str, Any]] = []
|
|
33
|
+
for p in projects_sorted[:limit]:
|
|
34
|
+
pid = getattr(p, "id", None) or (p.get("id") if isinstance(p, dict) else None)
|
|
35
|
+
name = getattr(p, "name", None) or (p.get("name") if isinstance(p, dict) else None)
|
|
36
|
+
lu = getattr(p, "lastUpdated", None) or (p.get("lastUpdated") if isinstance(p, dict) else None)
|
|
37
|
+
result.append({"id": pid, "name": name, "lastUpdated": lu})
|
|
38
|
+
return result
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
SAFE_CHARS = re.compile(r"[^A-Za-z0-9._-]+")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def folder_name_for(project_name: str | None, project_id: str) -> str:
|
|
9
|
+
if not project_name:
|
|
10
|
+
return project_id
|
|
11
|
+
base = SAFE_CHARS.sub("-", project_name).strip("-._")
|
|
12
|
+
suffix = project_id[:8] if project_id else ""
|
|
13
|
+
name = f"{base}-{suffix}" if suffix else base
|
|
14
|
+
return name or (project_id or "overleaf-project")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ensure_dir(path: str) -> None:
|
|
18
|
+
os.makedirs(path, exist_ok=True)
|