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,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
@@ -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'.")
@@ -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)