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,132 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import subprocess
|
|
4
|
+
from .config import get_app_paths, get_config_path
|
|
5
|
+
|
|
6
|
+
LAUNCHAGENTS_DIR = os.path.expanduser("~/Library/LaunchAgents")
|
|
7
|
+
SYSTEMD_USER_DIR = os.path.expanduser("~/.config/systemd/user")
|
|
8
|
+
|
|
9
|
+
PLIST_LABEL = "com.overleaf.sync"
|
|
10
|
+
SERVICE_NAME = "overleaf-sync.service"
|
|
11
|
+
TIMER_NAME = "overleaf-sync.timer"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run(cmd: list[str]) -> subprocess.CompletedProcess:
|
|
15
|
+
return subprocess.run(cmd, check=False, capture_output=True, text=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _python_exec() -> str:
|
|
19
|
+
return os.environ.get("PYTHON", "python3")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cli_entry() -> list[str]:
|
|
23
|
+
# Use module invocation to avoid packaging complexities
|
|
24
|
+
return [_python_exec(), "-m", "overleaf_sync.cli", "run-once"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def install_macos_launchagent(interval: str):
|
|
28
|
+
os.makedirs(LAUNCHAGENTS_DIR, exist_ok=True)
|
|
29
|
+
start_interval = {"1h": 3600, "12h": 43200, "24h": 86400}.get(interval, 3600)
|
|
30
|
+
support, logs_dir, _ = get_app_paths()
|
|
31
|
+
os.makedirs(logs_dir, exist_ok=True)
|
|
32
|
+
stdout = os.path.join(logs_dir, "runner.log")
|
|
33
|
+
stderr = os.path.join(logs_dir, "runner.err.log")
|
|
34
|
+
|
|
35
|
+
args = _cli_entry()
|
|
36
|
+
program_arguments_xml = "\n".join([f"\t\t<string>{a}</string>" for a in args])
|
|
37
|
+
|
|
38
|
+
plist = f"""
|
|
39
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
40
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
41
|
+
<plist version="1.0">
|
|
42
|
+
<dict>
|
|
43
|
+
<key>Label</key>
|
|
44
|
+
<string>{PLIST_LABEL}</string>
|
|
45
|
+
<key>ProgramArguments</key>
|
|
46
|
+
<array>
|
|
47
|
+
{program_arguments_xml}
|
|
48
|
+
</array>
|
|
49
|
+
<key>RunAtLoad</key>
|
|
50
|
+
<true/>
|
|
51
|
+
<key>StartInterval</key>
|
|
52
|
+
<integer>{start_interval}</integer>
|
|
53
|
+
<key>StandardOutPath</key>
|
|
54
|
+
<string>{stdout}</string>
|
|
55
|
+
<key>StandardErrorPath</key>
|
|
56
|
+
<string>{stderr}</string>
|
|
57
|
+
</dict>
|
|
58
|
+
</plist>
|
|
59
|
+
"""
|
|
60
|
+
plist_path = os.path.join(LAUNCHAGENTS_DIR, f"{PLIST_LABEL}.plist")
|
|
61
|
+
with open(plist_path, "w", encoding="utf-8") as f:
|
|
62
|
+
f.write(plist)
|
|
63
|
+
_run(["launchctl", "unload", "-w", plist_path])
|
|
64
|
+
res = _run(["launchctl", "load", "-w", plist_path])
|
|
65
|
+
if res.returncode == 0:
|
|
66
|
+
print(f"Installed LaunchAgent at {plist_path}")
|
|
67
|
+
else:
|
|
68
|
+
print(f"Failed to load LaunchAgent: {res.stderr}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def uninstall_macos_launchagent():
|
|
72
|
+
plist_path = os.path.join(LAUNCHAGENTS_DIR, f"{PLIST_LABEL}.plist")
|
|
73
|
+
_run(["launchctl", "unload", "-w", plist_path])
|
|
74
|
+
if os.path.exists(plist_path):
|
|
75
|
+
os.remove(plist_path)
|
|
76
|
+
print(f"Removed {plist_path}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def install_systemd_user(interval: str):
|
|
80
|
+
os.makedirs(SYSTEMD_USER_DIR, exist_ok=True)
|
|
81
|
+
args = " ".join(_cli_entry())
|
|
82
|
+
service = f"""
|
|
83
|
+
[Unit]
|
|
84
|
+
Description=Overleaf Sync pull-only job
|
|
85
|
+
|
|
86
|
+
[Service]
|
|
87
|
+
Type=oneshot
|
|
88
|
+
ExecStart={args}
|
|
89
|
+
"""
|
|
90
|
+
if interval == "1h":
|
|
91
|
+
on_calendar = "hourly"
|
|
92
|
+
elif interval == "12h":
|
|
93
|
+
on_calendar = "*-*-* 00,12:00:00"
|
|
94
|
+
else:
|
|
95
|
+
on_calendar = "daily"
|
|
96
|
+
|
|
97
|
+
timer = f"""
|
|
98
|
+
[Unit]
|
|
99
|
+
Description=Run Overleaf Sync periodically ({interval})
|
|
100
|
+
|
|
101
|
+
[Timer]
|
|
102
|
+
OnCalendar={on_calendar}
|
|
103
|
+
Persistent=true
|
|
104
|
+
RandomizedDelaySec=300
|
|
105
|
+
|
|
106
|
+
[Install]
|
|
107
|
+
WantedBy=timers.target
|
|
108
|
+
"""
|
|
109
|
+
service_path = os.path.join(SYSTEMD_USER_DIR, SERVICE_NAME)
|
|
110
|
+
timer_path = os.path.join(SYSTEMD_USER_DIR, TIMER_NAME)
|
|
111
|
+
with open(service_path, "w", encoding="utf-8") as f:
|
|
112
|
+
f.write(service)
|
|
113
|
+
with open(timer_path, "w", encoding="utf-8") as f:
|
|
114
|
+
f.write(timer)
|
|
115
|
+
|
|
116
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
117
|
+
_run(["systemctl", "--user", "disable", "--now", TIMER_NAME])
|
|
118
|
+
res = _run(["systemctl", "--user", "enable", "--now", TIMER_NAME])
|
|
119
|
+
if res.returncode == 0:
|
|
120
|
+
print(f"Installed systemd user timer at {timer_path}")
|
|
121
|
+
else:
|
|
122
|
+
print(f"Failed to enable timer: {res.stderr}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def uninstall_systemd_user():
|
|
126
|
+
_run(["systemctl", "--user", "disable", "--now", TIMER_NAME])
|
|
127
|
+
service_path = os.path.join(SYSTEMD_USER_DIR, SERVICE_NAME)
|
|
128
|
+
timer_path = os.path.join(SYSTEMD_USER_DIR, TIMER_NAME)
|
|
129
|
+
for p in (service_path, timer_path):
|
|
130
|
+
if os.path.exists(p):
|
|
131
|
+
os.remove(p)
|
|
132
|
+
print(f"Removed {p}")
|
overleaf_sync/sync.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from .config import load_config, prompt_first_run, Config, get_logs_dir
|
|
5
|
+
from .cookies import load_overleaf_cookies
|
|
6
|
+
from .overleaf_api import create_api, list_projects_sorted_by_last_updated
|
|
7
|
+
from .projects import folder_name_for, ensure_dir
|
|
8
|
+
from .git_ops import (
|
|
9
|
+
clone_if_missing,
|
|
10
|
+
ensure_remote,
|
|
11
|
+
detect_default_branch,
|
|
12
|
+
pull_remote,
|
|
13
|
+
enable_git_helper,
|
|
14
|
+
is_worktree_clean,
|
|
15
|
+
has_unpushed_commits,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_sync(cfg: Config):
|
|
20
|
+
# Require Git token for all sync operations to ensure non-interactive background runs
|
|
21
|
+
if not cfg.git_token:
|
|
22
|
+
raise RuntimeError("Git token is required. Run 'overleaf-pull set-git-token' and retry.")
|
|
23
|
+
if cfg.git_helper:
|
|
24
|
+
enable_git_helper(platform.system())
|
|
25
|
+
|
|
26
|
+
ensure_dir(cfg.base_dir)
|
|
27
|
+
# Prefer cookies from config if present
|
|
28
|
+
if cfg.cookies:
|
|
29
|
+
cookies = cfg.cookies
|
|
30
|
+
else:
|
|
31
|
+
cookies = load_overleaf_cookies(cfg.browser, cfg.profile)
|
|
32
|
+
api = create_api(cfg.host)
|
|
33
|
+
|
|
34
|
+
projects = list_projects_sorted_by_last_updated(api, cookies, cfg.count)
|
|
35
|
+
|
|
36
|
+
for p in projects:
|
|
37
|
+
pid = p["id"]
|
|
38
|
+
name = p["name"]
|
|
39
|
+
folder = folder_name_for(name, pid)
|
|
40
|
+
repo_dir = os.path.join(cfg.base_dir, folder)
|
|
41
|
+
needs_clone = not os.path.isdir(os.path.join(repo_dir, ".git"))
|
|
42
|
+
if needs_clone and not cfg.git_token:
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
"Missing Overleaf Git token for cloning. Run 'overleaf-pull set-git-token' and retry."
|
|
45
|
+
)
|
|
46
|
+
repo_path = clone_if_missing(cfg.base_dir, folder, pid, cfg.git_token)
|
|
47
|
+
ensure_remote(repo_path, pid, cfg.git_token)
|
|
48
|
+
branch = detect_default_branch(repo_path)
|
|
49
|
+
pull_remote(repo_path, branch)
|
|
50
|
+
# After successful sync of latest set, automatically prune old projects safely
|
|
51
|
+
expected = {folder_name_for(p.get("name"), p.get("id")) for p in projects}
|
|
52
|
+
pruned = 0
|
|
53
|
+
lingering = 0
|
|
54
|
+
for entry in os.listdir(cfg.base_dir):
|
|
55
|
+
path = os.path.join(cfg.base_dir, entry)
|
|
56
|
+
if os.path.isdir(os.path.join(path, ".git")) and entry not in expected:
|
|
57
|
+
# Remove only if clean and with no unpushed commits
|
|
58
|
+
try:
|
|
59
|
+
branch = detect_default_branch(path)
|
|
60
|
+
clean = is_worktree_clean(path)
|
|
61
|
+
ahead = has_unpushed_commits(path, branch)
|
|
62
|
+
if clean and ahead is False:
|
|
63
|
+
import shutil
|
|
64
|
+
shutil.rmtree(path)
|
|
65
|
+
pruned += 1
|
|
66
|
+
else:
|
|
67
|
+
lingering += 1
|
|
68
|
+
except Exception:
|
|
69
|
+
lingering += 1
|
|
70
|
+
msg = f"[{datetime.now().isoformat(timespec='seconds')}] Synced {len(projects)} projects into {cfg.base_dir}"
|
|
71
|
+
if pruned or lingering:
|
|
72
|
+
msg += f"; pruned {pruned} old, {lingering} lingering"
|
|
73
|
+
print(msg)
|
|
74
|
+
# Append to app log for status checks
|
|
75
|
+
try:
|
|
76
|
+
logs_dir = get_logs_dir()
|
|
77
|
+
with open(os.path.join(logs_dir, "app.log"), "a", encoding="utf-8") as lf:
|
|
78
|
+
lf.write(msg + "\n")
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_sync_validate_first(cfg: Config):
|
|
84
|
+
if cfg.git_helper:
|
|
85
|
+
enable_git_helper(platform.system())
|
|
86
|
+
|
|
87
|
+
ensure_dir(cfg.base_dir)
|
|
88
|
+
if cfg.cookies:
|
|
89
|
+
cookies = cfg.cookies
|
|
90
|
+
else:
|
|
91
|
+
cookies = load_overleaf_cookies(cfg.browser, cfg.profile)
|
|
92
|
+
api = create_api(cfg.host)
|
|
93
|
+
projects = list_projects_sorted_by_last_updated(api, cookies, 1)
|
|
94
|
+
if not projects:
|
|
95
|
+
raise RuntimeError("No projects found for validation.")
|
|
96
|
+
p = projects[0]
|
|
97
|
+
pid = p["id"]
|
|
98
|
+
name = p["name"]
|
|
99
|
+
folder = folder_name_for(name, pid)
|
|
100
|
+
repo_dir = os.path.join(cfg.base_dir, folder)
|
|
101
|
+
needs_clone = not os.path.isdir(os.path.join(repo_dir, ".git"))
|
|
102
|
+
if needs_clone and not cfg.git_token:
|
|
103
|
+
raise RuntimeError("Missing Overleaf Git token for cloning. Run 'overleaf-pull set-git-token'.")
|
|
104
|
+
repo_path = clone_if_missing(cfg.base_dir, folder, pid, cfg.git_token)
|
|
105
|
+
ensure_remote(repo_path, pid, cfg.git_token)
|
|
106
|
+
branch = detect_default_branch(repo_path)
|
|
107
|
+
pull_remote(repo_path, branch)
|
|
108
|
+
print(f"Validation sync OK for '{name}' ({pid}).")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_sync_once():
|
|
112
|
+
cfg = load_config() or prompt_first_run()
|
|
113
|
+
run_sync(cfg)
|