studyctl 2.0.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.
- studyctl/__init__.py +3 -0
- studyctl/calendar.py +140 -0
- studyctl/cli/__init__.py +56 -0
- studyctl/cli/_config.py +128 -0
- studyctl/cli/_content.py +462 -0
- studyctl/cli/_lazy.py +35 -0
- studyctl/cli/_review.py +491 -0
- studyctl/cli/_schedule.py +125 -0
- studyctl/cli/_setup.py +164 -0
- studyctl/cli/_shared.py +83 -0
- studyctl/cli/_state.py +69 -0
- studyctl/cli/_sync.py +156 -0
- studyctl/cli/_web.py +228 -0
- studyctl/content/__init__.py +5 -0
- studyctl/content/markdown_converter.py +271 -0
- studyctl/content/models.py +31 -0
- studyctl/content/notebooklm_client.py +434 -0
- studyctl/content/splitter.py +159 -0
- studyctl/content/storage.py +105 -0
- studyctl/content/syllabus.py +416 -0
- studyctl/history.py +982 -0
- studyctl/maintenance.py +69 -0
- studyctl/mcp/__init__.py +1 -0
- studyctl/mcp/server.py +58 -0
- studyctl/mcp/tools.py +234 -0
- studyctl/pdf.py +89 -0
- studyctl/review_db.py +277 -0
- studyctl/review_loader.py +375 -0
- studyctl/scheduler.py +242 -0
- studyctl/services/__init__.py +6 -0
- studyctl/services/content.py +39 -0
- studyctl/services/review.py +127 -0
- studyctl/settings.py +367 -0
- studyctl/shared.py +425 -0
- studyctl/state.py +120 -0
- studyctl/sync.py +229 -0
- studyctl/tui/__main__.py +33 -0
- studyctl/tui/app.py +395 -0
- studyctl/tui/study_cards.py +396 -0
- studyctl/web/__init__.py +1 -0
- studyctl/web/app.py +68 -0
- studyctl/web/routes/__init__.py +1 -0
- studyctl/web/routes/artefacts.py +57 -0
- studyctl/web/routes/cards.py +86 -0
- studyctl/web/routes/courses.py +91 -0
- studyctl/web/routes/history.py +69 -0
- studyctl/web/server.py +260 -0
- studyctl/web/static/app.js +853 -0
- studyctl/web/static/icon-192.svg +4 -0
- studyctl/web/static/icon-512.svg +4 -0
- studyctl/web/static/index.html +50 -0
- studyctl/web/static/manifest.json +21 -0
- studyctl/web/static/style.css +657 -0
- studyctl/web/static/sw.js +14 -0
- studyctl-2.0.0.dist-info/METADATA +49 -0
- studyctl-2.0.0.dist-info/RECORD +58 -0
- studyctl-2.0.0.dist-info/WHEEL +4 -0
- studyctl-2.0.0.dist-info/entry_points.txt +3 -0
studyctl/scheduler.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Cross-platform scheduled job management (macOS launchd + Linux systemd/cron)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from textwrap import dedent
|
|
10
|
+
|
|
11
|
+
JOBS_DIR = Path.home() / ".config" / "studyctl" / "jobs"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Job:
|
|
16
|
+
name: str
|
|
17
|
+
command: str # Uses ~ for portability
|
|
18
|
+
schedule: str # Human-readable: "every 2h", "daily 7am"
|
|
19
|
+
description: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEFAULT_JOBS = [
|
|
23
|
+
Job(
|
|
24
|
+
"session-export", "~/.local/bin/session-export", "every 2h", "Extract agent sessions to DB"
|
|
25
|
+
),
|
|
26
|
+
Job(
|
|
27
|
+
"studyctl-sync", "~/.local/bin/studyctl sync --all", "daily 7am", "Sync notes to NotebookLM"
|
|
28
|
+
),
|
|
29
|
+
Job("studyctl-push", "~/.local/bin/studyctl state push", "every 4h", "Push state to hub"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_macos() -> bool:
|
|
34
|
+
return platform.system() == "Darwin"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── macOS launchd ──────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _launchd_plist(job: Job, username: str) -> str:
|
|
41
|
+
home = f"/Users/{username}" if _is_macos() else f"/home/{username}"
|
|
42
|
+
cmd_parts = job.command.replace("~", home).split()
|
|
43
|
+
args_xml = "\n ".join(f"<string>{a}</string>" for a in cmd_parts)
|
|
44
|
+
|
|
45
|
+
hour_tpl = "<key>Hour</key><integer>{h}</integer>"
|
|
46
|
+
min_tpl = "<key>Minute</key><integer>{m}</integer>"
|
|
47
|
+
|
|
48
|
+
def _cal_dict(h: int, m: int = 0) -> str:
|
|
49
|
+
return f" <dict>{hour_tpl.format(h=h)}{min_tpl.format(m=m)}</dict>"
|
|
50
|
+
|
|
51
|
+
cal_key = " <key>StartCalendarInterval</key>\n"
|
|
52
|
+
if "every 2h" in job.schedule:
|
|
53
|
+
entries = "\n".join(_cal_dict(h) for h in range(8, 23, 2))
|
|
54
|
+
schedule = f"{cal_key} <array>\n{entries}\n </array>"
|
|
55
|
+
elif "every 4h" in job.schedule:
|
|
56
|
+
entries = "\n".join(_cal_dict(h, 30) for h in range(8, 23, 4))
|
|
57
|
+
schedule = f"{cal_key} <array>\n{entries}\n </array>"
|
|
58
|
+
elif "daily" in job.schedule:
|
|
59
|
+
hour = 7
|
|
60
|
+
if "am" in job.schedule:
|
|
61
|
+
hour = int(job.schedule.split("daily")[1].strip().replace("am", "").strip())
|
|
62
|
+
schedule = (
|
|
63
|
+
f"{cal_key}"
|
|
64
|
+
" <dict>\n"
|
|
65
|
+
f" {hour_tpl.format(h=hour)}\n"
|
|
66
|
+
f" {min_tpl.format(m=0)}\n"
|
|
67
|
+
" </dict>"
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
schedule = " <key>StartInterval</key>\n <integer>3600</integer>"
|
|
71
|
+
|
|
72
|
+
return dedent(f"""\
|
|
73
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
74
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
75
|
+
<plist version="1.0">
|
|
76
|
+
<dict>
|
|
77
|
+
<key>Label</key>
|
|
78
|
+
<string>com.studyctl.{job.name}</string>
|
|
79
|
+
<key>ProgramArguments</key>
|
|
80
|
+
<array>
|
|
81
|
+
{args_xml}
|
|
82
|
+
</array>
|
|
83
|
+
{schedule}
|
|
84
|
+
<key>StandardOutPath</key>
|
|
85
|
+
<string>{home}/.local/share/studyctl/logs/{job.name}.log</string>
|
|
86
|
+
<key>StandardErrorPath</key>
|
|
87
|
+
<string>{home}/.local/share/studyctl/logs/{job.name}.err</string>
|
|
88
|
+
<key>EnvironmentVariables</key>
|
|
89
|
+
<dict>
|
|
90
|
+
<key>PATH</key>
|
|
91
|
+
<string>{home}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
92
|
+
</dict>
|
|
93
|
+
</dict>
|
|
94
|
+
</plist>""")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _launchd_install(job: Job, username: str) -> bool:
|
|
98
|
+
label = f"com.studyctl.{job.name}"
|
|
99
|
+
plist_dir = Path.home() / "Library" / "LaunchAgents"
|
|
100
|
+
plist_dir.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
plist_path = plist_dir / f"{label}.plist"
|
|
102
|
+
|
|
103
|
+
plist_path.write_text(_launchd_plist(job, username))
|
|
104
|
+
subprocess.run(["launchctl", "bootout", f"gui/{_uid()}/{label}"], capture_output=True)
|
|
105
|
+
result = subprocess.run(
|
|
106
|
+
["launchctl", "bootstrap", f"gui/{_uid()}", str(plist_path)], capture_output=True
|
|
107
|
+
)
|
|
108
|
+
return result.returncode == 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _launchd_remove(job: Job) -> bool:
|
|
112
|
+
label = f"com.studyctl.{job.name}"
|
|
113
|
+
subprocess.run(["launchctl", "bootout", f"gui/{_uid()}/{label}"], capture_output=True)
|
|
114
|
+
plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
|
115
|
+
if plist.exists():
|
|
116
|
+
plist.unlink()
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _launchd_list() -> list[dict]:
|
|
121
|
+
result = subprocess.run(["launchctl", "list"], capture_output=True, text=True)
|
|
122
|
+
jobs = []
|
|
123
|
+
for line in result.stdout.splitlines():
|
|
124
|
+
if "com.studyctl." in line:
|
|
125
|
+
parts = line.split()
|
|
126
|
+
jobs.append(
|
|
127
|
+
{
|
|
128
|
+
"name": parts[2].replace("com.studyctl.", ""),
|
|
129
|
+
"status": parts[0],
|
|
130
|
+
"label": parts[2],
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
return jobs
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _uid() -> int:
|
|
137
|
+
import os
|
|
138
|
+
|
|
139
|
+
return os.getuid()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── Linux cron ─────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cron_expression(job: Job) -> str:
|
|
146
|
+
if "every 2h" in job.schedule:
|
|
147
|
+
return "0 8-22/2 * * *"
|
|
148
|
+
elif "every 4h" in job.schedule:
|
|
149
|
+
return "30 8-22/4 * * *"
|
|
150
|
+
elif "daily" in job.schedule:
|
|
151
|
+
hour = 7
|
|
152
|
+
if "am" in job.schedule:
|
|
153
|
+
hour = int(job.schedule.split("daily")[1].strip().replace("am", "").strip())
|
|
154
|
+
return f"0 {hour} * * *"
|
|
155
|
+
return "0 * * * *"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _cron_line(job: Job) -> str:
|
|
159
|
+
home = str(Path.home())
|
|
160
|
+
cmd = job.command.replace("~", home)
|
|
161
|
+
log = f"{home}/.local/share/studyctl/logs/{job.name}.log"
|
|
162
|
+
path_dirs = [
|
|
163
|
+
f"{home}/.local/bin",
|
|
164
|
+
"/opt/homebrew/bin",
|
|
165
|
+
"/usr/local/bin",
|
|
166
|
+
"/usr/bin",
|
|
167
|
+
]
|
|
168
|
+
path = "PATH=" + ":".join(path_dirs)
|
|
169
|
+
cron = _cron_expression(job)
|
|
170
|
+
return f"{cron} {path} {cmd} >> {log} 2>&1 # studyctl:{job.name}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _cron_install(job: Job) -> bool:
|
|
174
|
+
marker = f"# studyctl:{job.name}"
|
|
175
|
+
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
|
176
|
+
existing = result.stdout if result.returncode == 0 else ""
|
|
177
|
+
# Remove old entry
|
|
178
|
+
lines = [line for line in existing.splitlines() if marker not in line]
|
|
179
|
+
lines.append(_cron_line(job))
|
|
180
|
+
subprocess.run(["crontab", "-"], input="\n".join(lines) + "\n", text=True, check=True)
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _cron_remove(job: Job) -> bool:
|
|
185
|
+
marker = f"# studyctl:{job.name}"
|
|
186
|
+
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
|
187
|
+
if result.returncode != 0:
|
|
188
|
+
return True
|
|
189
|
+
lines = [line for line in result.stdout.splitlines() if marker not in line]
|
|
190
|
+
subprocess.run(["crontab", "-"], input="\n".join(lines) + "\n", text=True, check=True)
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _cron_list() -> list[dict]:
|
|
195
|
+
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
|
196
|
+
if result.returncode != 0:
|
|
197
|
+
return []
|
|
198
|
+
jobs = []
|
|
199
|
+
for line in result.stdout.splitlines():
|
|
200
|
+
if "# studyctl:" in line:
|
|
201
|
+
name = line.split("# studyctl:")[1].strip()
|
|
202
|
+
jobs.append({"name": name, "cron": line.split("#")[0].strip()})
|
|
203
|
+
return jobs
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── Public API ─────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def install_job(job: Job, username: str | None = None) -> bool:
|
|
210
|
+
Path.home().joinpath(".local/share/studyctl/logs").mkdir(parents=True, exist_ok=True)
|
|
211
|
+
username = username or Path.home().name
|
|
212
|
+
if _is_macos():
|
|
213
|
+
return _launchd_install(job, username)
|
|
214
|
+
return _cron_install(job)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def remove_job(job: Job) -> bool:
|
|
218
|
+
if _is_macos():
|
|
219
|
+
return _launchd_remove(job)
|
|
220
|
+
return _cron_remove(job)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def list_jobs() -> list[dict]:
|
|
224
|
+
if _is_macos():
|
|
225
|
+
return _launchd_list()
|
|
226
|
+
return _cron_list()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def install_all(username: str | None = None) -> list[str]:
|
|
230
|
+
installed = []
|
|
231
|
+
for job in DEFAULT_JOBS:
|
|
232
|
+
if install_job(job, username):
|
|
233
|
+
installed.append(job.name)
|
|
234
|
+
return installed
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def remove_all() -> list[str]:
|
|
238
|
+
removed = []
|
|
239
|
+
for job in DEFAULT_JOBS:
|
|
240
|
+
if remove_job(job):
|
|
241
|
+
removed.append(job.name)
|
|
242
|
+
return removed
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Content pipeline service layer.
|
|
2
|
+
|
|
3
|
+
Framework-agnostic wrappers for content pipeline operations. Used by
|
|
4
|
+
CLI, future FastAPI web UI, and MCP server.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from studyctl.content import storage
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def list_courses(base_path: Path) -> list[dict]:
|
|
18
|
+
"""List all courses under the content base path."""
|
|
19
|
+
return storage.list_courses(base_path)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_course(base_path: Path, slug: str) -> Path:
|
|
23
|
+
"""Get or create a course directory with standard subdirs."""
|
|
24
|
+
return storage.get_course_dir(base_path, slug)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def slugify_title(title: str) -> str:
|
|
28
|
+
"""Convert a book/course title to a filesystem-safe slug."""
|
|
29
|
+
return storage.slugify(title)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_metadata(course_dir: Path) -> dict:
|
|
33
|
+
"""Load course metadata (notebook IDs, syllabus state, generation history)."""
|
|
34
|
+
return storage.load_course_metadata(course_dir)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_metadata(course_dir: Path, metadata: dict) -> None:
|
|
38
|
+
"""Save course metadata atomically."""
|
|
39
|
+
storage.save_course_metadata(course_dir, metadata)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Service layer for review operations.
|
|
2
|
+
|
|
3
|
+
Thin wrapper functions that delegate to :mod:`studyctl.review_db` and
|
|
4
|
+
:mod:`studyctl.review_loader`. This module is the bridge between
|
|
5
|
+
consumer interfaces (CLI, web, MCP) and the data layer.
|
|
6
|
+
|
|
7
|
+
Rules enforced by design:
|
|
8
|
+
- NO framework imports (no click, no fastapi, no textual).
|
|
9
|
+
- All functions are pure delegation with minimal orchestration.
|
|
10
|
+
- Type annotations on every public function.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from studyctl import review_db, review_loader
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from studyctl.review_db import CardProgress
|
|
23
|
+
from studyctl.review_loader import Flashcard, QuizQuestion
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_cards(
|
|
27
|
+
course: str,
|
|
28
|
+
directory: Path,
|
|
29
|
+
) -> tuple[list[Flashcard], list[QuizQuestion]]:
|
|
30
|
+
"""Load flashcards and quiz questions for a course directory.
|
|
31
|
+
|
|
32
|
+
Uses :func:`review_loader.find_content_dirs` to locate the
|
|
33
|
+
``flashcards/`` and ``quizzes/`` sub-directories, then delegates
|
|
34
|
+
to :func:`review_loader.load_flashcards` and
|
|
35
|
+
:func:`review_loader.load_quizzes`.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
course: Course identifier (used for logging context, not filtering).
|
|
39
|
+
directory: Root directory that contains flashcard/quiz content.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
A tuple of (flashcards, quiz_questions). Either list may be
|
|
43
|
+
empty if no content is found.
|
|
44
|
+
"""
|
|
45
|
+
fc_dir, quiz_dir = review_loader.find_content_dirs(directory)
|
|
46
|
+
|
|
47
|
+
flashcards: list[Flashcard] = []
|
|
48
|
+
quizzes: list[QuizQuestion] = []
|
|
49
|
+
|
|
50
|
+
if fc_dir is not None:
|
|
51
|
+
flashcards = review_loader.load_flashcards(fc_dir)
|
|
52
|
+
if quiz_dir is not None:
|
|
53
|
+
quizzes = review_loader.load_quizzes(quiz_dir)
|
|
54
|
+
|
|
55
|
+
return flashcards, quizzes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def record_review(
|
|
59
|
+
course: str,
|
|
60
|
+
card_type: str,
|
|
61
|
+
card_hash: str,
|
|
62
|
+
correct: bool,
|
|
63
|
+
response_time_ms: int | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Record a single card review result.
|
|
66
|
+
|
|
67
|
+
Delegates to :func:`review_db.record_card_review` which handles
|
|
68
|
+
SM-2 interval calculation and persistence.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
course: Course identifier.
|
|
72
|
+
card_type: Either ``"flashcard"`` or ``"quiz"``.
|
|
73
|
+
card_hash: Stable hash identifying the card.
|
|
74
|
+
correct: Whether the answer was correct.
|
|
75
|
+
response_time_ms: Optional response time in milliseconds.
|
|
76
|
+
"""
|
|
77
|
+
review_db.record_card_review(
|
|
78
|
+
course=course,
|
|
79
|
+
card_type=card_type,
|
|
80
|
+
card_hash=card_hash,
|
|
81
|
+
correct=correct,
|
|
82
|
+
response_time_ms=response_time_ms,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_stats(course: str) -> dict:
|
|
87
|
+
"""Get summary statistics for a course.
|
|
88
|
+
|
|
89
|
+
Returns a dict with keys: ``total_reviews``, ``unique_cards``,
|
|
90
|
+
``due_today``, ``mastered``.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
course: Course identifier.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Statistics dictionary. Returns zeroed stats if no reviews exist.
|
|
97
|
+
"""
|
|
98
|
+
return review_db.get_course_stats(course=course)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_due(course: str) -> list[CardProgress]:
|
|
102
|
+
"""Get cards that are due for spaced-repetition review.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
course: Course identifier.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of :class:`~studyctl.review_db.CardProgress` entries
|
|
109
|
+
whose ``next_review`` date is today or earlier, ordered by
|
|
110
|
+
earliest due first.
|
|
111
|
+
"""
|
|
112
|
+
return review_db.get_due_cards(course=course)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_wrong(course: str) -> set[str]:
|
|
116
|
+
"""Get card hashes that were answered incorrectly in the most recent session.
|
|
117
|
+
|
|
118
|
+
Useful for "retry wrong answers" flows where the consumer wants to
|
|
119
|
+
re-present only the cards the learner got wrong.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
course: Course identifier.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Set of card hash strings. Empty set if no sessions exist.
|
|
126
|
+
"""
|
|
127
|
+
return review_db.get_wrong_hashes(course=course)
|