canvas-pull 2.4.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.
- canvas_pull/__init__.py +3 -0
- canvas_pull/__main__.py +3 -0
- canvas_pull/api.py +187 -0
- canvas_pull/cli.py +531 -0
- canvas_pull/collector.py +308 -0
- canvas_pull/config.py +211 -0
- canvas_pull/gitops.py +52 -0
- canvas_pull/manifest.py +132 -0
- canvas_pull/models.py +61 -0
- canvas_pull/notify.py +46 -0
- canvas_pull/schedule.py +215 -0
- canvas_pull/summary.py +72 -0
- canvas_pull/sync.py +231 -0
- canvas_pull-2.4.0.dist-info/METADATA +280 -0
- canvas_pull-2.4.0.dist-info/RECORD +18 -0
- canvas_pull-2.4.0.dist-info/WHEEL +4 -0
- canvas_pull-2.4.0.dist-info/entry_points.txt +2 -0
- canvas_pull-2.4.0.dist-info/licenses/LICENSE +21 -0
canvas_pull/cli.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from contextlib import nullcontext
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from . import __version__, notify
|
|
16
|
+
from . import schedule as sched
|
|
17
|
+
from .api import AuthError, CanvasClient, CanvasError
|
|
18
|
+
from .collector import CourseScan, course_dirnames, list_courses, scan_courses
|
|
19
|
+
from .config import (
|
|
20
|
+
DEFAULT_SAVE_DIR,
|
|
21
|
+
Config,
|
|
22
|
+
ConfigError,
|
|
23
|
+
default_config_file,
|
|
24
|
+
load_config,
|
|
25
|
+
normalize_website,
|
|
26
|
+
save_config,
|
|
27
|
+
)
|
|
28
|
+
from .gitops import GitError, commit_sync
|
|
29
|
+
from .manifest import Manifest, SyncLocked, append_log, now_iso, read_log, sync_lock
|
|
30
|
+
from .models import Course
|
|
31
|
+
from .summary import parse_since, summarize
|
|
32
|
+
from .sync import Change, Kind, execute, make_plan
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
help="Sync files from your active Canvas courses and see what changed.",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
add_completion=False,
|
|
38
|
+
rich_markup_mode="rich",
|
|
39
|
+
)
|
|
40
|
+
console = Console(highlight=False)
|
|
41
|
+
err = Console(stderr=True, highlight=False)
|
|
42
|
+
|
|
43
|
+
STYLE = {
|
|
44
|
+
Kind.NEW: ("+", "green"),
|
|
45
|
+
Kind.MODIFIED: ("~", "yellow"),
|
|
46
|
+
Kind.MOVED: ("→", "cyan"),
|
|
47
|
+
Kind.DELETED: ("-", "red"),
|
|
48
|
+
Kind.RESTORED: ("↺", "blue"),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fail(message: str) -> typer.Exit:
|
|
53
|
+
err.print(f"[red]Error:[/] {message}")
|
|
54
|
+
return typer.Exit(1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pretty(path: Path) -> str:
|
|
58
|
+
"""Show paths under the home folder as ~/..."""
|
|
59
|
+
try:
|
|
60
|
+
return "~/" + path.relative_to(Path.home()).as_posix()
|
|
61
|
+
except ValueError:
|
|
62
|
+
return str(path)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load(require_token: bool = True) -> Config:
|
|
66
|
+
try:
|
|
67
|
+
return load_config(require_token=require_token)
|
|
68
|
+
except ConfigError as exc:
|
|
69
|
+
raise _fail(str(exc)) from None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _matches(course: Course, patterns: list[str]) -> bool:
|
|
73
|
+
label = course.label.lower()
|
|
74
|
+
code = (course.course_code or "").lower()
|
|
75
|
+
return any(p == str(course.id) or p.lower() in label or p.lower() in code for p in patterns)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _version(value: bool) -> None:
|
|
79
|
+
if value:
|
|
80
|
+
console.print(f"canvas-pull {__version__}")
|
|
81
|
+
raise typer.Exit()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.callback()
|
|
85
|
+
def main(
|
|
86
|
+
version: Annotated[
|
|
87
|
+
bool, typer.Option("--version", callback=_version, is_eager=True, help="Show version.")
|
|
88
|
+
] = False,
|
|
89
|
+
) -> None:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def init() -> None:
|
|
95
|
+
"""Set your Canvas URL, save folder and access token."""
|
|
96
|
+
try:
|
|
97
|
+
current = load_config(require_token=False)
|
|
98
|
+
except ConfigError:
|
|
99
|
+
current = None
|
|
100
|
+
|
|
101
|
+
console.print(
|
|
102
|
+
"Create a token in Canvas: [bold]Account → Settings → New Access Token[/].\n"
|
|
103
|
+
"The token is stored in your system keychain.\n"
|
|
104
|
+
)
|
|
105
|
+
website = normalize_website(
|
|
106
|
+
typer.prompt("Canvas URL", default=current.website if current else "oc.sjtu.edu.cn")
|
|
107
|
+
)
|
|
108
|
+
save_dir = (
|
|
109
|
+
Path(
|
|
110
|
+
typer.prompt(
|
|
111
|
+
"Save files to", default=str(current.save_dir) if current else DEFAULT_SAVE_DIR
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
.expanduser()
|
|
115
|
+
.resolve()
|
|
116
|
+
)
|
|
117
|
+
token = typer.prompt("Access token", hide_input=True).strip()
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
with CanvasClient(website, token) as client:
|
|
121
|
+
me = client.get("users/self")
|
|
122
|
+
except CanvasError as exc:
|
|
123
|
+
raise _fail(f"Could not sign in to {website}: {exc}") from None
|
|
124
|
+
|
|
125
|
+
cfg = current or Config(website=website, save_dir=save_dir)
|
|
126
|
+
cfg.website, cfg.save_dir, cfg.token = website, save_dir, token
|
|
127
|
+
where = save_config(cfg)
|
|
128
|
+
console.print(f"[green]✓[/] Signed in as [bold]{me.get('name', '?')}[/].")
|
|
129
|
+
console.print(f"Settings: {default_config_file()} · token: {where}")
|
|
130
|
+
console.print("Next: [bold]canvas-pull sync[/]")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command()
|
|
134
|
+
def courses(
|
|
135
|
+
all_: Annotated[bool, typer.Option("--all", help="Include courses whose term ended.")] = False,
|
|
136
|
+
completed: Annotated[
|
|
137
|
+
bool | None,
|
|
138
|
+
typer.Option("--completed/--no-completed", help="Include completed (concluded) courses."),
|
|
139
|
+
] = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""List the courses that would be synced."""
|
|
142
|
+
cfg = _load()
|
|
143
|
+
with_completed = cfg.include_completed if completed is None else completed
|
|
144
|
+
try:
|
|
145
|
+
with _client(cfg) as client:
|
|
146
|
+
found = list_courses(client, include_past=all_, include_completed=with_completed)
|
|
147
|
+
except CanvasError as exc:
|
|
148
|
+
raise _fail(str(exc)) from None
|
|
149
|
+
|
|
150
|
+
table = Table(box=None, header_style="bold")
|
|
151
|
+
table.add_column("ID", justify="right")
|
|
152
|
+
table.add_column("Course")
|
|
153
|
+
table.add_column("Term")
|
|
154
|
+
table.add_column("")
|
|
155
|
+
for c in found:
|
|
156
|
+
notes = ["[dim]completed[/]"] if c.completed else []
|
|
157
|
+
if _matches(c, cfg.exclude):
|
|
158
|
+
notes.append("[dim]excluded[/]")
|
|
159
|
+
term = (c.term.name if c.term else "") or ""
|
|
160
|
+
table.add_row(str(c.id), c.label, term, " ".join(notes))
|
|
161
|
+
console.print(table)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _client(cfg: Config) -> CanvasClient:
|
|
165
|
+
return CanvasClient(cfg.website, cfg.token, max_concurrency=cfg.scan_concurrency)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _scan(
|
|
169
|
+
client: CanvasClient,
|
|
170
|
+
cfg: Config,
|
|
171
|
+
manifest: Manifest,
|
|
172
|
+
only: list[str],
|
|
173
|
+
all_courses: bool,
|
|
174
|
+
completed: bool,
|
|
175
|
+
refresh: bool,
|
|
176
|
+
) -> tuple[list[CourseScan], int]:
|
|
177
|
+
"""Scan matching courses in parallel. Returns (scans, number of archived courses skipped)."""
|
|
178
|
+
found = list_courses(client, include_past=all_courses, include_completed=completed)
|
|
179
|
+
found = [c for c in found if not _matches(c, cfg.exclude)]
|
|
180
|
+
if only:
|
|
181
|
+
found = [c for c in found if _matches(c, only)]
|
|
182
|
+
# Folder names come from the full list so skipping courses never renames a folder.
|
|
183
|
+
names = course_dirnames(found)
|
|
184
|
+
todo = [
|
|
185
|
+
c for c in found if refresh or not (c.completed and manifest.is_archived(c.id, cfg.sources))
|
|
186
|
+
]
|
|
187
|
+
if not todo:
|
|
188
|
+
return [], len(found)
|
|
189
|
+
done = 0
|
|
190
|
+
with console.status(f"Scanning {len(todo)} course(s)…") as status:
|
|
191
|
+
|
|
192
|
+
def progress(scan: CourseScan) -> None:
|
|
193
|
+
nonlocal done
|
|
194
|
+
done += 1
|
|
195
|
+
status.update(f"Scanning [{done}/{len(todo)}] {scan.course.label}")
|
|
196
|
+
|
|
197
|
+
scans = scan_courses(
|
|
198
|
+
client, todo, names, cfg.sources, workers=cfg.scan_concurrency, on_done=progress
|
|
199
|
+
)
|
|
200
|
+
return scans, len(found) - len(todo)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_KIND_ORDER = {Kind.NEW: 0, Kind.MODIFIED: 1, Kind.MOVED: 2, Kind.RESTORED: 3, Kind.DELETED: 4}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _print_changes(changes: list[Change]) -> None:
|
|
207
|
+
by_course: dict[str, list[Change]] = defaultdict(list)
|
|
208
|
+
ordered = sorted(changes, key=lambda c: (c.course.lower(), _KIND_ORDER[c.kind], c.path.lower()))
|
|
209
|
+
for c in ordered:
|
|
210
|
+
by_course[c.course].append(c)
|
|
211
|
+
for course, items in by_course.items():
|
|
212
|
+
console.print(f"\n[bold]{course}[/]")
|
|
213
|
+
for c in items:
|
|
214
|
+
mark, color = STYLE[c.kind]
|
|
215
|
+
shown = c.path.split("/", 1)[-1]
|
|
216
|
+
if c.old_path and c.old_path != c.path:
|
|
217
|
+
shown = f"{c.old_path.split('/', 1)[-1]} → {shown}"
|
|
218
|
+
console.print(f" [{color}]{mark} {c.kind:<8}[/] {shown}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _summary(changes: list[Change]) -> str:
|
|
222
|
+
counts: dict[Kind, int] = defaultdict(int)
|
|
223
|
+
for c in changes:
|
|
224
|
+
counts[c.kind] += 1
|
|
225
|
+
parts = [f"[{STYLE[k][1]}]{n} {k}[/]" for k, n in counts.items()]
|
|
226
|
+
return ", ".join(parts) if parts else "no changes"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _alert(enabled: bool, message: str) -> None:
|
|
230
|
+
if enabled:
|
|
231
|
+
notify.send(message)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.command()
|
|
235
|
+
def sync(
|
|
236
|
+
dry_run: Annotated[
|
|
237
|
+
bool, typer.Option("--dry-run", "-n", help="Only show what would change.")
|
|
238
|
+
] = False,
|
|
239
|
+
course: Annotated[
|
|
240
|
+
list[str] | None,
|
|
241
|
+
typer.Option("--course", "-c", help="Only these courses (id or part of the name)."),
|
|
242
|
+
] = None,
|
|
243
|
+
all_courses: Annotated[
|
|
244
|
+
bool, typer.Option("--all-courses", help="Include courses whose term ended.")
|
|
245
|
+
] = False,
|
|
246
|
+
completed: Annotated[
|
|
247
|
+
bool | None,
|
|
248
|
+
typer.Option("--completed/--no-completed", help="Include completed (concluded) courses."),
|
|
249
|
+
] = None,
|
|
250
|
+
git: Annotated[
|
|
251
|
+
bool | None, typer.Option("--git/--no-git", help="Commit the result in the save folder.")
|
|
252
|
+
] = None,
|
|
253
|
+
notify_: Annotated[
|
|
254
|
+
bool,
|
|
255
|
+
typer.Option("--notify", help="Desktop notification when files change or sync fails."),
|
|
256
|
+
] = False,
|
|
257
|
+
refresh: Annotated[
|
|
258
|
+
bool, typer.Option("--refresh", help="Scan archived completed courses again.")
|
|
259
|
+
] = False,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Download new and changed files from your active courses."""
|
|
262
|
+
if not console.is_terminal and not dry_run:
|
|
263
|
+
console.print(f"--- {now_iso()} canvas-pull sync") # separator in scheduled-run logs
|
|
264
|
+
try:
|
|
265
|
+
cfg = load_config()
|
|
266
|
+
except ConfigError as exc:
|
|
267
|
+
_alert(notify_, f"Sync failed: {exc}")
|
|
268
|
+
raise _fail(str(exc)) from None
|
|
269
|
+
use_git = cfg.git if git is None else git
|
|
270
|
+
with_completed = cfg.include_completed if completed is None else completed
|
|
271
|
+
manifest = Manifest.load(cfg.save_dir)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
lock = nullcontext() if dry_run else sync_lock(cfg.save_dir)
|
|
275
|
+
with lock, _client(cfg) as client:
|
|
276
|
+
scans, archived = _scan(
|
|
277
|
+
client, cfg, manifest, course or [], all_courses, with_completed, refresh
|
|
278
|
+
)
|
|
279
|
+
if archived:
|
|
280
|
+
console.print(
|
|
281
|
+
f"[dim]Skipped {archived} completed course(s) already archived "
|
|
282
|
+
"(use --refresh to scan them again).[/]"
|
|
283
|
+
)
|
|
284
|
+
if not scans:
|
|
285
|
+
if not archived:
|
|
286
|
+
console.print("No matching active courses.")
|
|
287
|
+
return
|
|
288
|
+
plan = make_plan(scans, manifest, cfg.save_dir)
|
|
289
|
+
|
|
290
|
+
for s in scans:
|
|
291
|
+
if s.error:
|
|
292
|
+
err.print(f"[yellow]![/] Skipped {s.course.label}: {s.error}")
|
|
293
|
+
elif s.files_tab_hidden:
|
|
294
|
+
console.print(
|
|
295
|
+
f"[dim]{s.course.label}: Files tab hidden; used Modules instead.[/]"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if dry_run:
|
|
299
|
+
_print_changes(plan.changes)
|
|
300
|
+
console.print(f"\n{_summary(plan.changes)} · {_pretty(cfg.save_dir)}")
|
|
301
|
+
if plan.locked:
|
|
302
|
+
console.print(f"[dim]{len(plan.locked)} locked file(s) not yet available.[/]")
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
total = sum(1 for c in plan.changes if c.needs_download)
|
|
306
|
+
columns = (
|
|
307
|
+
TextColumn("Downloading"),
|
|
308
|
+
BarColumn(),
|
|
309
|
+
MofNCompleteColumn(),
|
|
310
|
+
TimeElapsedColumn(),
|
|
311
|
+
)
|
|
312
|
+
with Progress(*columns, console=console, transient=True) as progress:
|
|
313
|
+
task = progress.add_task("", total=total)
|
|
314
|
+
try:
|
|
315
|
+
result = execute(
|
|
316
|
+
plan,
|
|
317
|
+
client=client,
|
|
318
|
+
manifest=manifest,
|
|
319
|
+
save_dir=cfg.save_dir,
|
|
320
|
+
keep_history=cfg.keep_history,
|
|
321
|
+
concurrency=cfg.concurrency,
|
|
322
|
+
on_progress=lambda _c: progress.advance(task),
|
|
323
|
+
)
|
|
324
|
+
failed_courses = {c.remote.course.id for c, _ in result.failed if c.remote}
|
|
325
|
+
for s in scans:
|
|
326
|
+
if s.course.completed and not s.error and s.course.id not in failed_courses:
|
|
327
|
+
manifest.mark_archived(s.course.id, cfg.sources)
|
|
328
|
+
finally:
|
|
329
|
+
manifest.save()
|
|
330
|
+
except SyncLocked:
|
|
331
|
+
raise _fail("Another sync is already running for this folder.") from None
|
|
332
|
+
except AuthError as exc:
|
|
333
|
+
_alert(notify_, "Sync failed: Canvas rejected your token. Run `canvas-pull init`.")
|
|
334
|
+
raise _fail(str(exc)) from None
|
|
335
|
+
except CanvasError as exc:
|
|
336
|
+
if exc.status is not None: # stay quiet when simply offline
|
|
337
|
+
_alert(notify_, f"Sync failed: {exc}")
|
|
338
|
+
raise _fail(str(exc)) from None
|
|
339
|
+
|
|
340
|
+
_print_changes(result.applied)
|
|
341
|
+
for change, reason in result.failed:
|
|
342
|
+
err.print(f"[red]✗[/] {change.path}: {reason}")
|
|
343
|
+
console.print(f"\n{_summary(result.applied)} · {_pretty(cfg.save_dir)}")
|
|
344
|
+
if plan.locked:
|
|
345
|
+
console.print(f"[dim]{len(plan.locked)} locked file(s) not yet available.[/]")
|
|
346
|
+
|
|
347
|
+
if result.applied:
|
|
348
|
+
record = {"time": now_iso(), "changes": [c.to_dict() for c in result.applied]}
|
|
349
|
+
append_log(cfg.save_dir, record)
|
|
350
|
+
if use_git:
|
|
351
|
+
try:
|
|
352
|
+
summary = commit_sync(cfg.save_dir, result.applied)
|
|
353
|
+
if summary:
|
|
354
|
+
console.print(f"[dim]git: {summary}[/]")
|
|
355
|
+
except GitError as exc:
|
|
356
|
+
err.print(f"[yellow]git commit skipped:[/] {exc}")
|
|
357
|
+
if result.applied or result.failed:
|
|
358
|
+
message = notify.describe(result.applied)
|
|
359
|
+
if result.failed:
|
|
360
|
+
message = f"{message} · {len(result.failed)} failed".lstrip(" ·")
|
|
361
|
+
_alert(notify_, message)
|
|
362
|
+
if result.failed:
|
|
363
|
+
raise typer.Exit(1)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@app.command()
|
|
367
|
+
def status(
|
|
368
|
+
course: Annotated[
|
|
369
|
+
list[str] | None, typer.Option("--course", "-c", help="Only these courses.")
|
|
370
|
+
] = None,
|
|
371
|
+
completed: Annotated[
|
|
372
|
+
bool | None,
|
|
373
|
+
typer.Option("--completed/--no-completed", help="Include completed (concluded) courses."),
|
|
374
|
+
] = None,
|
|
375
|
+
refresh: Annotated[
|
|
376
|
+
bool, typer.Option("--refresh", help="Scan archived completed courses again.")
|
|
377
|
+
] = False,
|
|
378
|
+
) -> None:
|
|
379
|
+
"""Show what changed on Canvas since the last sync (downloads nothing)."""
|
|
380
|
+
sync(
|
|
381
|
+
dry_run=True,
|
|
382
|
+
course=course,
|
|
383
|
+
all_courses=False,
|
|
384
|
+
completed=completed,
|
|
385
|
+
git=False,
|
|
386
|
+
notify_=False,
|
|
387
|
+
refresh=refresh,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@app.command()
|
|
392
|
+
def log(
|
|
393
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="How many syncs to show.")] = 10,
|
|
394
|
+
since: Annotated[
|
|
395
|
+
str | None,
|
|
396
|
+
typer.Option(
|
|
397
|
+
"--since", "-s", help="Net summary since 7d, 24h, 2w, today or a date (2026-10-01)."
|
|
398
|
+
),
|
|
399
|
+
] = None,
|
|
400
|
+
) -> None:
|
|
401
|
+
"""Show recent syncs, or a merged summary with --since (e.g. --since 7d)."""
|
|
402
|
+
cfg = _load(require_token=False)
|
|
403
|
+
if since:
|
|
404
|
+
try:
|
|
405
|
+
start = parse_since(since)
|
|
406
|
+
except ValueError as exc:
|
|
407
|
+
raise _fail(str(exc)) from None
|
|
408
|
+
net = summarize(read_log(cfg.save_dir, None), start)
|
|
409
|
+
local = start.astimezone().strftime("%Y-%m-%d %H:%M")
|
|
410
|
+
if not net:
|
|
411
|
+
console.print(f"No changes since {local}.")
|
|
412
|
+
return
|
|
413
|
+
console.print(f"[bold]Changes since {local}[/]")
|
|
414
|
+
by_course: dict[str, list] = defaultdict(list)
|
|
415
|
+
for n in net:
|
|
416
|
+
by_course[n.course].append(n)
|
|
417
|
+
for course_name, items in by_course.items():
|
|
418
|
+
counts: dict[str, int] = defaultdict(int)
|
|
419
|
+
for n in items:
|
|
420
|
+
counts[n.kind] += 1
|
|
421
|
+
tally = ", ".join(f"{v} {k}" for k, v in counts.items())
|
|
422
|
+
console.print(f"\n[bold]{course_name}[/] [dim]{tally}[/]")
|
|
423
|
+
for n in items:
|
|
424
|
+
mark, color = STYLE[Kind(n.kind)]
|
|
425
|
+
shown = n.path.split("/", 1)[-1]
|
|
426
|
+
if n.old_path:
|
|
427
|
+
shown = f"{n.old_path.split('/', 1)[-1]} → {shown}"
|
|
428
|
+
console.print(f" [{color}]{mark} {n.kind:<8}[/] {shown}")
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
records = read_log(cfg.save_dir, limit)
|
|
432
|
+
if not records:
|
|
433
|
+
console.print("No syncs recorded yet.")
|
|
434
|
+
return
|
|
435
|
+
for rec in reversed(records):
|
|
436
|
+
console.print(f"\n[bold]{rec['time']}[/]")
|
|
437
|
+
for c in rec["changes"]:
|
|
438
|
+
kind = Kind(c["kind"])
|
|
439
|
+
mark, color = STYLE[kind]
|
|
440
|
+
path = c["path"] if "old_path" not in c else f"{c['old_path']} → {c['path']}"
|
|
441
|
+
console.print(f" [{color}]{mark} {kind:<8}[/] {path}")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@app.command("config")
|
|
445
|
+
def show_config() -> None:
|
|
446
|
+
"""Show the settings in use (token hidden)."""
|
|
447
|
+
cfg = _load(require_token=False)
|
|
448
|
+
token = "set" if cfg.token else "missing"
|
|
449
|
+
table = Table(box=None, show_header=False)
|
|
450
|
+
for key, value in [
|
|
451
|
+
("config file", str(default_config_file())),
|
|
452
|
+
("website", cfg.website),
|
|
453
|
+
("save_dir", _pretty(cfg.save_dir)),
|
|
454
|
+
("token", token),
|
|
455
|
+
("concurrency", str(cfg.concurrency)),
|
|
456
|
+
("scan_concurrency", str(cfg.scan_concurrency)),
|
|
457
|
+
("keep_history", str(cfg.keep_history)),
|
|
458
|
+
("git", str(cfg.git)),
|
|
459
|
+
("exclude", ", ".join(cfg.exclude) or "-"),
|
|
460
|
+
("include_completed", str(cfg.include_completed)),
|
|
461
|
+
("sources", ", ".join(cfg.sources)),
|
|
462
|
+
]:
|
|
463
|
+
table.add_row(f"[bold]{key}[/]", value)
|
|
464
|
+
console.print(table)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
schedule_app = typer.Typer(
|
|
468
|
+
help="Sync automatically on a schedule, with desktop notifications.",
|
|
469
|
+
no_args_is_help=True,
|
|
470
|
+
)
|
|
471
|
+
app.add_typer(schedule_app, name="schedule")
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@schedule_app.command("enable")
|
|
475
|
+
def schedule_enable(
|
|
476
|
+
at: Annotated[
|
|
477
|
+
list[str] | None,
|
|
478
|
+
typer.Option("--at", help="Time of day, HH:MM. Repeat for several times. Default 09:00."),
|
|
479
|
+
] = None,
|
|
480
|
+
every: Annotated[
|
|
481
|
+
str | None, typer.Option("--every", help="Interval instead, e.g. 6h or 30m.")
|
|
482
|
+
] = None,
|
|
483
|
+
) -> None:
|
|
484
|
+
"""Install (or replace) the automatic sync."""
|
|
485
|
+
_load() # fail now, not silently at 9am
|
|
486
|
+
try:
|
|
487
|
+
spec = sched.parse_spec(at or [], every)
|
|
488
|
+
path = sched.enable(spec, workdir=Path.cwd())
|
|
489
|
+
except sched.ScheduleError as exc:
|
|
490
|
+
raise _fail(str(exc)) from None
|
|
491
|
+
cmd = sched.command()
|
|
492
|
+
console.print(f"[green]✓[/] canvas-pull will sync [bold]{spec.describe()}[/].")
|
|
493
|
+
console.print(f"[dim]Job: {path}\nCommand: {' '.join(cmd)}\nLog: {sched.log_file()}[/]")
|
|
494
|
+
if "/.venv/" in cmd[0]:
|
|
495
|
+
err.print(
|
|
496
|
+
"[yellow]Note:[/] this points at a project virtualenv. For a stable path, install "
|
|
497
|
+
"with `uv tool install canvas-pull` and run `canvas-pull schedule enable` again."
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@schedule_app.command("disable")
|
|
502
|
+
def schedule_disable() -> None:
|
|
503
|
+
"""Remove the automatic sync."""
|
|
504
|
+
if sched.disable():
|
|
505
|
+
console.print("[green]✓[/] Automatic sync removed.")
|
|
506
|
+
else:
|
|
507
|
+
console.print("No automatic sync was installed.")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@schedule_app.command("status")
|
|
511
|
+
def schedule_status(
|
|
512
|
+
lines: Annotated[int, typer.Option("--lines", "-n", help="Log lines to show.")] = 15,
|
|
513
|
+
) -> None:
|
|
514
|
+
"""Show the schedule and the end of its log."""
|
|
515
|
+
desc = sched.current()
|
|
516
|
+
console.print(f"Schedule: [bold]{desc}[/]" if desc else "No automatic sync installed.")
|
|
517
|
+
log_path = sched.log_file()
|
|
518
|
+
if log_path.is_file():
|
|
519
|
+
tail = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
|
|
520
|
+
console.print(f"[dim]{log_path}[/]")
|
|
521
|
+
console.print("\n".join(tail), markup=False, highlight=False)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@schedule_app.command("run")
|
|
525
|
+
def schedule_run() -> None:
|
|
526
|
+
"""Trigger the scheduled sync once now (output goes to the log)."""
|
|
527
|
+
try:
|
|
528
|
+
sched.run_now()
|
|
529
|
+
except sched.ScheduleError as exc:
|
|
530
|
+
raise _fail(str(exc)) from None
|
|
531
|
+
console.print(f"Started. Check with [bold]canvas-pull schedule status[/] ({sched.log_file()}).")
|