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/manifest.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Local record of what has been synced, keyed by Canvas file id.
|
|
2
|
+
|
|
3
|
+
Stored as JSON in <save_dir>/.canvas-pull/ so it is readable and diffs well in git.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
STATE_DIR = ".canvas-pull"
|
|
17
|
+
MANIFEST_VERSION = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def now_iso() -> str:
|
|
21
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Entry:
|
|
26
|
+
id: int
|
|
27
|
+
course_id: int
|
|
28
|
+
course: str
|
|
29
|
+
path: str
|
|
30
|
+
size: int
|
|
31
|
+
stamp: str | None
|
|
32
|
+
sha256: str
|
|
33
|
+
synced_at: str
|
|
34
|
+
deleted: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Manifest:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
path: Path,
|
|
41
|
+
entries: dict[int, Entry] | None = None,
|
|
42
|
+
archived: dict[int, list[str]] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.path = path
|
|
45
|
+
self.entries: dict[int, Entry] = entries or {}
|
|
46
|
+
# Completed courses already synced in full: course id -> sources used then.
|
|
47
|
+
self.archived: dict[int, list[str]] = archived or {}
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def load(cls, save_dir: Path) -> Manifest:
|
|
51
|
+
path = save_dir / STATE_DIR / "manifest.json"
|
|
52
|
+
if not path.is_file():
|
|
53
|
+
return cls(path)
|
|
54
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
entries = {e["id"]: Entry(**e) for e in data.get("files", [])}
|
|
56
|
+
archived = {int(k): list(v) for k, v in data.get("archived_courses", {}).items()}
|
|
57
|
+
return cls(path, entries, archived)
|
|
58
|
+
|
|
59
|
+
def is_archived(self, course_id: int, sources: list[str]) -> bool:
|
|
60
|
+
"""True if this completed course was fully synced with (at least) these sources."""
|
|
61
|
+
done = self.archived.get(course_id)
|
|
62
|
+
return done is not None and set(sources) <= set(done)
|
|
63
|
+
|
|
64
|
+
def mark_archived(self, course_id: int, sources: list[str]) -> None:
|
|
65
|
+
self.archived[course_id] = sorted(sources)
|
|
66
|
+
|
|
67
|
+
def get(self, file_id: int) -> Entry | None:
|
|
68
|
+
return self.entries.get(file_id)
|
|
69
|
+
|
|
70
|
+
def upsert(self, entry: Entry) -> None:
|
|
71
|
+
self.entries[entry.id] = entry
|
|
72
|
+
|
|
73
|
+
def mark_deleted(self, file_id: int) -> None:
|
|
74
|
+
if file_id in self.entries:
|
|
75
|
+
self.entries[file_id].deleted = True
|
|
76
|
+
|
|
77
|
+
def save(self) -> None:
|
|
78
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
data = {
|
|
80
|
+
"version": MANIFEST_VERSION,
|
|
81
|
+
"files": [asdict(e) for e in sorted(self.entries.values(), key=lambda e: e.id)],
|
|
82
|
+
"archived_courses": {str(k): v for k, v in sorted(self.archived.items())},
|
|
83
|
+
}
|
|
84
|
+
tmp = self.path.with_suffix(".json.tmp")
|
|
85
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
|
86
|
+
os.replace(tmp, self.path)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def append_log(save_dir: Path, record: dict) -> None:
|
|
90
|
+
path = save_dir / STATE_DIR / "log.jsonl"
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
93
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_log(save_dir: Path, limit: int | None = 10) -> list[dict]:
|
|
97
|
+
"""Sync records, oldest first; the last `limit` of them (all if None)."""
|
|
98
|
+
path = save_dir / STATE_DIR / "log.jsonl"
|
|
99
|
+
if not path.is_file():
|
|
100
|
+
return []
|
|
101
|
+
lines = [line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
102
|
+
return [json.loads(line) for line in (lines if limit is None else lines[-limit:])]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SyncLocked(Exception):
|
|
106
|
+
"""Another canvas-pull sync is already running on this save folder."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def sync_lock(save_dir: Path) -> Iterator[None]:
|
|
111
|
+
"""Exclusive, non-blocking lock on the save folder (flock on Unix, msvcrt on Windows)."""
|
|
112
|
+
path = save_dir / STATE_DIR / "lock"
|
|
113
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
with open(path, "a+") as fh:
|
|
115
|
+
try:
|
|
116
|
+
if os.name == "nt":
|
|
117
|
+
import msvcrt
|
|
118
|
+
|
|
119
|
+
fh.seek(0)
|
|
120
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
|
121
|
+
else:
|
|
122
|
+
import fcntl
|
|
123
|
+
|
|
124
|
+
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
125
|
+
except OSError:
|
|
126
|
+
raise SyncLocked from None
|
|
127
|
+
try:
|
|
128
|
+
yield
|
|
129
|
+
finally:
|
|
130
|
+
if os.name == "nt":
|
|
131
|
+
fh.seek(0)
|
|
132
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
canvas_pull/models.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Canvas API objects (only the fields canvas-pull uses)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _Model(BaseModel):
|
|
11
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Term(_Model):
|
|
15
|
+
id: int | None = None
|
|
16
|
+
name: str | None = None
|
|
17
|
+
start_at: datetime | None = None
|
|
18
|
+
end_at: datetime | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Course(_Model):
|
|
22
|
+
id: int
|
|
23
|
+
name: str | None = None
|
|
24
|
+
course_code: str | None = None
|
|
25
|
+
term: Term | None = None
|
|
26
|
+
start_at: datetime | None = None
|
|
27
|
+
end_at: datetime | None = None
|
|
28
|
+
# Courses outside their access dates come back with only id + this flag.
|
|
29
|
+
access_restricted_by_date: bool = False
|
|
30
|
+
# Set by canvas-pull (not Canvas) for courses from completed enrollments.
|
|
31
|
+
completed: bool = False
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def label(self) -> str:
|
|
35
|
+
return self.name or self.course_code or f"Course {self.id}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Folder(_Model):
|
|
39
|
+
id: int
|
|
40
|
+
full_name: str
|
|
41
|
+
name: str = ""
|
|
42
|
+
parent_folder_id: int | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CanvasFile(_Model):
|
|
46
|
+
id: int
|
|
47
|
+
display_name: str
|
|
48
|
+
filename: str = ""
|
|
49
|
+
folder_id: int | None = None
|
|
50
|
+
size: int = 0
|
|
51
|
+
content_type: str = Field(default="", alias="content-type")
|
|
52
|
+
url: str = ""
|
|
53
|
+
updated_at: datetime | None = None
|
|
54
|
+
modified_at: datetime | None = None
|
|
55
|
+
locked_for_user: bool = False
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def stamp(self) -> str | None:
|
|
59
|
+
"""Timestamp of the last content change (modified_at ignores renames)."""
|
|
60
|
+
ts = self.modified_at or self.updated_at
|
|
61
|
+
return ts.isoformat() if ts else None
|
canvas_pull/notify.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Desktop notifications (macOS Notification Center, or notify-send on Linux)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from collections import Counter, defaultdict
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
|
|
11
|
+
from .sync import Change
|
|
12
|
+
|
|
13
|
+
TITLE = "Canvas Pull"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def send(message: str, title: str = TITLE) -> bool:
|
|
17
|
+
"""Show a notification. Returns False when no notifier is available."""
|
|
18
|
+
try:
|
|
19
|
+
if sys.platform == "darwin":
|
|
20
|
+
# Pass text as argv so quotes in file names cannot break the script.
|
|
21
|
+
script = [
|
|
22
|
+
"-e", "on run argv",
|
|
23
|
+
"-e", "display notification (item 2 of argv) with title (item 1 of argv)",
|
|
24
|
+
"-e", "end run",
|
|
25
|
+
] # fmt: skip
|
|
26
|
+
cmd = ["osascript", *script, title, message]
|
|
27
|
+
elif shutil.which("notify-send"):
|
|
28
|
+
cmd = ["notify-send", "--app-name=canvas-pull", title, message]
|
|
29
|
+
else:
|
|
30
|
+
return False
|
|
31
|
+
return subprocess.run(cmd, capture_output=True, timeout=10, check=False).returncode == 0
|
|
32
|
+
except (OSError, subprocess.SubprocessError):
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def describe(changes: Iterable[Change], limit: int = 220) -> str:
|
|
37
|
+
"""'Signals: 2 new, 1 modified · OS: 1 deleted' (truncated)."""
|
|
38
|
+
per_course: dict[str, Counter[str]] = defaultdict(Counter)
|
|
39
|
+
for c in changes:
|
|
40
|
+
per_course[c.course][str(c.kind)] += 1
|
|
41
|
+
parts = [
|
|
42
|
+
f"{course}: " + ", ".join(f"{n} {k}" for k, n in counts.items())
|
|
43
|
+
for course, counts in per_course.items()
|
|
44
|
+
]
|
|
45
|
+
text = " · ".join(parts)
|
|
46
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
canvas_pull/schedule.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Run `canvas-pull sync --notify` on a schedule.
|
|
2
|
+
|
|
3
|
+
macOS: a launchd user agent in ~/Library/LaunchAgents.
|
|
4
|
+
Linux: a systemd user timer in ~/.config/systemd/user.
|
|
5
|
+
A Mac that was asleep at the scheduled time runs the missed sync when it wakes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import plistlib
|
|
12
|
+
import re
|
|
13
|
+
import shlex
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Callable, Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
LABEL = "io.github.gxxxr-111.canvas-pull"
|
|
21
|
+
UNIT = "canvas-pull"
|
|
22
|
+
DEFAULT_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
23
|
+
|
|
24
|
+
Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ScheduleError(Exception):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run(cmd: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
|
32
|
+
return subprocess.run(list(cmd), capture_output=True, text=True, check=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Spec:
|
|
37
|
+
times: list[tuple[int, int]] # (hour, minute) every day
|
|
38
|
+
every: int | None = None # seconds; used instead of times
|
|
39
|
+
|
|
40
|
+
def describe(self) -> str:
|
|
41
|
+
if self.every:
|
|
42
|
+
mins = self.every // 60
|
|
43
|
+
return f"every {mins // 60}h" if mins % 60 == 0 else f"every {mins}m"
|
|
44
|
+
return "daily at " + ", ".join(f"{h:02d}:{m:02d}" for h, m in self.times)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_spec(at: Sequence[str], every: str | None) -> Spec:
|
|
48
|
+
if every and at:
|
|
49
|
+
raise ScheduleError("Use either --at or --every, not both.")
|
|
50
|
+
if every:
|
|
51
|
+
m = re.fullmatch(r"\s*(\d+)\s*([mh])\s*", every.lower())
|
|
52
|
+
if not m:
|
|
53
|
+
raise ScheduleError("--every looks like 30m or 6h.")
|
|
54
|
+
seconds = int(m[1]) * (60 if m[2] == "m" else 3600)
|
|
55
|
+
if seconds < 15 * 60:
|
|
56
|
+
raise ScheduleError("Please sync at most every 15 minutes.")
|
|
57
|
+
return Spec(times=[], every=seconds)
|
|
58
|
+
times = []
|
|
59
|
+
for value in at or ["09:00"]:
|
|
60
|
+
m = re.fullmatch(r"\s*(\d{1,2}):(\d{2})\s*", value)
|
|
61
|
+
if not m or int(m[1]) > 23 or int(m[2]) > 59:
|
|
62
|
+
raise ScheduleError(f"Invalid time {value!r}; use HH:MM, e.g. 08:30.")
|
|
63
|
+
times.append((int(m[1]), int(m[2])))
|
|
64
|
+
return Spec(times=sorted(set(times)))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def command() -> list[str]:
|
|
68
|
+
"""How the scheduler should start canvas-pull: the same install that is running now."""
|
|
69
|
+
argv0 = Path(sys.argv[0])
|
|
70
|
+
if argv0.name == "canvas-pull" and argv0.exists():
|
|
71
|
+
return [str(argv0.resolve()), "sync", "--notify"]
|
|
72
|
+
return [sys.executable, "-m", "canvas_pull", "sync", "--notify"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def log_file() -> Path:
|
|
76
|
+
if sys.platform == "darwin":
|
|
77
|
+
return Path.home() / "Library/Logs/canvas-pull.log"
|
|
78
|
+
state = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state"))
|
|
79
|
+
return state / "canvas-pull/canvas-pull.log"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# -- macOS ---------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def plist_path() -> Path:
|
|
86
|
+
return Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_plist(spec: Spec, cmd: list[str], workdir: Path, log: Path) -> dict:
|
|
90
|
+
plist: dict = {
|
|
91
|
+
"Label": LABEL,
|
|
92
|
+
"ProgramArguments": cmd,
|
|
93
|
+
"WorkingDirectory": str(workdir),
|
|
94
|
+
"StandardOutPath": str(log),
|
|
95
|
+
"StandardErrorPath": str(log),
|
|
96
|
+
"EnvironmentVariables": {"PATH": DEFAULT_PATH, "PYTHONUNBUFFERED": "1"},
|
|
97
|
+
"ProcessType": "Background",
|
|
98
|
+
"RunAtLoad": False,
|
|
99
|
+
}
|
|
100
|
+
if spec.every:
|
|
101
|
+
plist["StartInterval"] = spec.every
|
|
102
|
+
else:
|
|
103
|
+
plist["StartCalendarInterval"] = [{"Hour": h, "Minute": m} for h, m in spec.times]
|
|
104
|
+
return plist
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# -- Linux ---------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def systemd_dir() -> Path:
|
|
111
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
112
|
+
return base / "systemd/user"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_systemd(spec: Spec, cmd: list[str], workdir: Path, log: Path) -> tuple[str, str]:
|
|
116
|
+
service = (
|
|
117
|
+
"[Unit]\nDescription=Sync Canvas course files\n"
|
|
118
|
+
"Wants=network-online.target\nAfter=network-online.target\n\n"
|
|
119
|
+
"[Service]\nType=oneshot\n"
|
|
120
|
+
f"WorkingDirectory={workdir}\n"
|
|
121
|
+
f"ExecStart={shlex.join(cmd)}\n"
|
|
122
|
+
f"StandardOutput=append:{log}\nStandardError=append:{log}\n"
|
|
123
|
+
)
|
|
124
|
+
if spec.every:
|
|
125
|
+
when = f"OnBootSec=5min\nOnUnitActiveSec={spec.every}s\n"
|
|
126
|
+
else:
|
|
127
|
+
when = "".join(f"OnCalendar=*-*-* {h:02d}:{m:02d}:00\n" for h, m in spec.times)
|
|
128
|
+
timer = (
|
|
129
|
+
f"[Unit]\nDescription=Run canvas-pull ({spec.describe()})\n\n"
|
|
130
|
+
f"[Timer]\n{when}Persistent=true\n\n[Install]\nWantedBy=timers.target\n"
|
|
131
|
+
)
|
|
132
|
+
return service, timer
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# -- public --------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _check(proc: subprocess.CompletedProcess[str], what: str) -> None:
|
|
139
|
+
if proc.returncode != 0:
|
|
140
|
+
raise ScheduleError(f"{what} failed: {(proc.stderr or proc.stdout).strip()}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def enable(spec: Spec, *, workdir: Path, run: Runner = _run) -> Path:
|
|
144
|
+
"""Install (or replace) the schedule. Returns the file written."""
|
|
145
|
+
cmd, log = command(), log_file()
|
|
146
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
if sys.platform == "darwin":
|
|
148
|
+
path = plist_path()
|
|
149
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
domain = f"gui/{os.getuid()}"
|
|
151
|
+
run(["launchctl", "bootout", domain, str(path)]) # ignore "not loaded"
|
|
152
|
+
path.write_bytes(plistlib.dumps(build_plist(spec, cmd, workdir, log)))
|
|
153
|
+
_check(run(["launchctl", "bootstrap", domain, str(path)]), "launchctl bootstrap")
|
|
154
|
+
return path
|
|
155
|
+
if sys.platform.startswith("linux"):
|
|
156
|
+
d = systemd_dir()
|
|
157
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
service, timer = build_systemd(spec, cmd, workdir, log)
|
|
159
|
+
(d / f"{UNIT}.service").write_text(service, encoding="utf-8")
|
|
160
|
+
(d / f"{UNIT}.timer").write_text(timer, encoding="utf-8")
|
|
161
|
+
_check(run(["systemctl", "--user", "daemon-reload"]), "systemctl daemon-reload")
|
|
162
|
+
_check(run(["systemctl", "--user", "enable", "--now", f"{UNIT}.timer"]), "systemctl enable")
|
|
163
|
+
return d / f"{UNIT}.timer"
|
|
164
|
+
raise ScheduleError(
|
|
165
|
+
"Scheduling is supported on macOS and Linux. On Windows use Task Scheduler."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def disable(*, run: Runner = _run) -> bool:
|
|
170
|
+
"""Remove the schedule. Returns False if none was installed."""
|
|
171
|
+
if sys.platform == "darwin":
|
|
172
|
+
path = plist_path()
|
|
173
|
+
if not path.exists():
|
|
174
|
+
return False
|
|
175
|
+
run(["launchctl", "bootout", f"gui/{os.getuid()}", str(path)])
|
|
176
|
+
path.unlink()
|
|
177
|
+
return True
|
|
178
|
+
if sys.platform.startswith("linux"):
|
|
179
|
+
d = systemd_dir()
|
|
180
|
+
timer = d / f"{UNIT}.timer"
|
|
181
|
+
if not timer.exists():
|
|
182
|
+
return False
|
|
183
|
+
run(["systemctl", "--user", "disable", "--now", f"{UNIT}.timer"])
|
|
184
|
+
timer.unlink()
|
|
185
|
+
(d / f"{UNIT}.service").unlink(missing_ok=True)
|
|
186
|
+
run(["systemctl", "--user", "daemon-reload"])
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def current() -> str | None:
|
|
192
|
+
"""Human-readable description of the installed schedule, or None."""
|
|
193
|
+
if sys.platform == "darwin" and plist_path().exists():
|
|
194
|
+
data = plistlib.loads(plist_path().read_bytes())
|
|
195
|
+
if "StartInterval" in data:
|
|
196
|
+
return Spec(times=[], every=int(data["StartInterval"])).describe()
|
|
197
|
+
cal = data.get("StartCalendarInterval", [])
|
|
198
|
+
cal = cal if isinstance(cal, list) else [cal]
|
|
199
|
+
return Spec(times=[(c.get("Hour", 0), c.get("Minute", 0)) for c in cal]).describe()
|
|
200
|
+
timer = systemd_dir() / f"{UNIT}.timer"
|
|
201
|
+
if sys.platform.startswith("linux") and timer.exists():
|
|
202
|
+
m = re.search(r"Description=Run canvas-pull \((.*)\)", timer.read_text())
|
|
203
|
+
return m[1] if m else "installed"
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def run_now(*, run: Runner = _run) -> None:
|
|
208
|
+
"""Trigger the scheduled job once, as the scheduler would."""
|
|
209
|
+
if sys.platform == "darwin":
|
|
210
|
+
target = f"gui/{os.getuid()}/{LABEL}"
|
|
211
|
+
_check(run(["launchctl", "kickstart", target]), "launchctl kickstart")
|
|
212
|
+
elif sys.platform.startswith("linux"):
|
|
213
|
+
_check(run(["systemctl", "--user", "start", f"{UNIT}.service"]), "systemctl start")
|
|
214
|
+
else:
|
|
215
|
+
raise ScheduleError("No schedule support on this platform.")
|
canvas_pull/summary.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Merge many sync records into one net summary ("what changed this week")."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
|
|
9
|
+
_UNITS = {"h": "hours", "d": "days", "w": "weeks"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_since(value: str, now: datetime | None = None) -> datetime:
|
|
13
|
+
"""'7d', '24h', '2w', 'today' or a date like '2026-10-01' -> aware datetime."""
|
|
14
|
+
now = now or datetime.now(UTC)
|
|
15
|
+
text = value.strip().lower()
|
|
16
|
+
if text == "today":
|
|
17
|
+
local = datetime.now().astimezone()
|
|
18
|
+
return local.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(UTC)
|
|
19
|
+
m = re.fullmatch(r"(\d+)\s*([hdw])", text)
|
|
20
|
+
if m:
|
|
21
|
+
return now - timedelta(**{_UNITS[m[2]]: int(m[1])})
|
|
22
|
+
try:
|
|
23
|
+
dt = datetime.fromisoformat(value.strip())
|
|
24
|
+
except ValueError:
|
|
25
|
+
raise ValueError(f"Can't read {value!r}; try 7d, 24h, 2w, today or 2026-10-01.") from None
|
|
26
|
+
return (
|
|
27
|
+
dt.astimezone(UTC) if dt.tzinfo else dt.replace(tzinfo=datetime.now().astimezone().tzinfo)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class NetChange:
|
|
33
|
+
kind: str # new | modified | moved | deleted
|
|
34
|
+
course: str
|
|
35
|
+
path: str
|
|
36
|
+
old_path: str | None = None
|
|
37
|
+
time: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def summarize(records: list[dict], since: datetime) -> list[NetChange]:
|
|
41
|
+
"""Net effect of all changes after `since`, oldest record first in `records`.
|
|
42
|
+
|
|
43
|
+
new→modified stays new, new→deleted disappears, a moved file keeps its first path.
|
|
44
|
+
"""
|
|
45
|
+
state: dict[str, NetChange] = {}
|
|
46
|
+
for rec in records:
|
|
47
|
+
if datetime.fromisoformat(rec["time"]) < since:
|
|
48
|
+
continue
|
|
49
|
+
for c in rec["changes"]:
|
|
50
|
+
kind, path, course, t = c["kind"], c["path"], c["course"], rec["time"]
|
|
51
|
+
if kind == "restored": # a local event, not a Canvas change
|
|
52
|
+
continue
|
|
53
|
+
if kind == "new":
|
|
54
|
+
state[path] = NetChange("new", course, path, time=t)
|
|
55
|
+
continue
|
|
56
|
+
old = c.get("old_path") or path
|
|
57
|
+
prev = state.pop(old, None)
|
|
58
|
+
if kind == "deleted":
|
|
59
|
+
if not (prev and prev.kind == "new"): # added then removed: no net change
|
|
60
|
+
state[path] = NetChange("deleted", course, path, time=t)
|
|
61
|
+
continue
|
|
62
|
+
# modified or moved
|
|
63
|
+
origin = prev.old_path if prev and prev.old_path else old
|
|
64
|
+
origin = origin if origin != path else None
|
|
65
|
+
if prev and prev.kind == "new":
|
|
66
|
+
state[path] = NetChange("new", course, path, time=t)
|
|
67
|
+
elif kind == "modified" or (prev and prev.kind == "modified"):
|
|
68
|
+
state[path] = NetChange("modified", course, path, origin, t)
|
|
69
|
+
elif origin: # moved, and not back to where it started
|
|
70
|
+
state[path] = NetChange("moved", course, path, origin, t)
|
|
71
|
+
order = {"new": 0, "modified": 1, "moved": 2, "deleted": 3}
|
|
72
|
+
return sorted(state.values(), key=lambda n: (n.course.lower(), order[n.kind], n.path.lower()))
|