uw-course 1.0.1__py3-none-any.whl → 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.
- uw_course/ClassSchedule/SearchInfo.py +2 -3
- uw_course/ClassSchedule/runner.py +28 -17
- uw_course/DB/dbClass.py +20 -0
- uw_course/Utiles/manageDBClass.py +5 -0
- uw_course/Utiles/randomColor.py +22 -10
- uw_course/main.py +8 -77
- uw_course/pdfschedule.py +385 -0
- uw_course/setting.py +7 -2
- uw_course/ui/__init__.py +1 -0
- uw_course/ui/app.py +730 -0
- uw_course/ui/components.py +99 -0
- uw_course/ui/constants.py +9 -0
- uw_course/ui/schedule_view.py +87 -0
- uw_course-2.0.0.dist-info/METADATA +111 -0
- uw_course-2.0.0.dist-info/RECORD +23 -0
- {uw_course-1.0.1.dist-info → uw_course-2.0.0.dist-info}/WHEEL +1 -1
- uw_course/Utiles/colorMessage.py +0 -9
- uw_course-1.0.1.dist-info/METADATA +0 -95
- uw_course-1.0.1.dist-info/RECORD +0 -18
- {uw_course-1.0.1.dist-info → uw_course-2.0.0.dist-info}/entry_points.txt +0 -0
- {uw_course-1.0.1.dist-info → uw_course-2.0.0.dist-info/licenses}/LICENSE +0 -0
- {uw_course-1.0.1.dist-info → uw_course-2.0.0.dist-info}/top_level.txt +0 -0
uw_course/ui/app.py
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from textual import on
|
|
4
|
+
from textual import events
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.containers import Horizontal, Vertical
|
|
7
|
+
from textual.coordinate import Coordinate
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import (
|
|
10
|
+
Button,
|
|
11
|
+
Checkbox,
|
|
12
|
+
DataTable,
|
|
13
|
+
Footer,
|
|
14
|
+
Header,
|
|
15
|
+
Input,
|
|
16
|
+
Select,
|
|
17
|
+
Static,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from uw_course.ui.components import (
|
|
21
|
+
detail_form_widgets,
|
|
22
|
+
schedule_tabs_with_options,
|
|
23
|
+
)
|
|
24
|
+
from uw_course.ui.constants import (
|
|
25
|
+
ACTION_DETAIL,
|
|
26
|
+
ACTION_QUIT,
|
|
27
|
+
ACTION_SCHEDULE,
|
|
28
|
+
DETAIL_PLACEHOLDER,
|
|
29
|
+
SCHEDULE_PLACEHOLDER,
|
|
30
|
+
SIDEBAR_TITLE_DETAIL,
|
|
31
|
+
SIDEBAR_TITLE_SCHEDULE,
|
|
32
|
+
)
|
|
33
|
+
from uw_course.ui.schedule_view import render_weekly_schedule
|
|
34
|
+
from uw_course.pdfschedule import generate_pdf
|
|
35
|
+
|
|
36
|
+
from uw_course.ClassSchedule.runner import SearchAvalibleInTerm, get_course_detail, makeSchedule
|
|
37
|
+
from uw_course.DB.dbClass import dbClass
|
|
38
|
+
from uw_course.setting import Setting
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _label_collection(name):
|
|
42
|
+
match = re.match(r"^Class(\d{4})([A-Za-z]+)$", name)
|
|
43
|
+
if match:
|
|
44
|
+
year, term = match.groups()
|
|
45
|
+
return f"{year} {term}"
|
|
46
|
+
return name
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _vertical_label(label):
|
|
50
|
+
cleaned = re.sub(r"\\s+", "", label)
|
|
51
|
+
return "\\n".join(cleaned) if cleaned else label
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_collection_file(path, db):
|
|
55
|
+
course_wish_list = []
|
|
56
|
+
with open(path, "r") as handle:
|
|
57
|
+
collection = handle.readline().strip().split("#")[0].strip()
|
|
58
|
+
next(handle, None)
|
|
59
|
+
for line in handle:
|
|
60
|
+
if line.startswith("#") or not line.strip():
|
|
61
|
+
continue
|
|
62
|
+
info = line.strip().split(",")
|
|
63
|
+
course = info[0].strip()
|
|
64
|
+
if len(info) > 1 and info[1].strip():
|
|
65
|
+
course_wish_list.append(
|
|
66
|
+
SearchAvalibleInTerm(db, course, int(info[1].strip()), quiet=True)
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
course_wish_list.append(SearchAvalibleInTerm(db, course, quiet=True))
|
|
70
|
+
return collection, [c for c in course_wish_list if c is not None]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CourseApp(App):
|
|
76
|
+
BINDINGS = [
|
|
77
|
+
("j", "focus_next", "Next"),
|
|
78
|
+
("k", "focus_previous", "Previous"),
|
|
79
|
+
("h", "focus_left", "Left"),
|
|
80
|
+
("l", "focus_right", "Right"),
|
|
81
|
+
("enter", "edit_cell", "Edit Cell"),
|
|
82
|
+
("q", "app.quit", "Quit"),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
CSS = """
|
|
86
|
+
Screen {
|
|
87
|
+
background: #1a1b26;
|
|
88
|
+
color: #c0caf5;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#app-body { padding: 1 2; }
|
|
92
|
+
|
|
93
|
+
#layout {
|
|
94
|
+
height: 1fr;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#main {
|
|
98
|
+
width: 3fr;
|
|
99
|
+
height: 1fr;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#sidebar {
|
|
103
|
+
width: 2fr;
|
|
104
|
+
height: 1fr;
|
|
105
|
+
margin: 0 0 0 2;
|
|
106
|
+
padding: 1;
|
|
107
|
+
background: $panel;
|
|
108
|
+
border: round $secondary;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#title {
|
|
112
|
+
margin: 1 0 0 0;
|
|
113
|
+
color: #7aa2f7;
|
|
114
|
+
text-style: bold;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#subtitle {
|
|
118
|
+
margin: 0 0 1 0;
|
|
119
|
+
color: #9aa5ce;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#sidebar-title {
|
|
123
|
+
color: #7aa2f7;
|
|
124
|
+
text-style: bold;
|
|
125
|
+
margin: 0 0 1 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#actions {
|
|
129
|
+
height: auto;
|
|
130
|
+
margin: 0 0 1 0;
|
|
131
|
+
padding: 1;
|
|
132
|
+
background: #1f2335;
|
|
133
|
+
border: round #3b4261;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#status {
|
|
137
|
+
height: auto;
|
|
138
|
+
color: #9aa5ce;
|
|
139
|
+
margin: 0 0 1 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#form {
|
|
143
|
+
height: 1fr;
|
|
144
|
+
overflow-y: auto;
|
|
145
|
+
padding: 1;
|
|
146
|
+
background: #24283b;
|
|
147
|
+
border: round #3b4261;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#output {
|
|
151
|
+
height: 1fr;
|
|
152
|
+
border: round #7aa2f7;
|
|
153
|
+
padding: 1;
|
|
154
|
+
margin: 1 0 0 0;
|
|
155
|
+
background: #1a1b26;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#schedule-tabs { height: auto; }
|
|
159
|
+
#manual-pane, #load-pane { height: auto; }
|
|
160
|
+
|
|
161
|
+
#actions Button {
|
|
162
|
+
margin: 0 1 0 0;
|
|
163
|
+
width: 1fr;
|
|
164
|
+
min-width: 18;
|
|
165
|
+
height: 3;
|
|
166
|
+
padding: 0 2;
|
|
167
|
+
text-style: bold;
|
|
168
|
+
border: round #3b4261;
|
|
169
|
+
background: #1f2335;
|
|
170
|
+
color: #c0caf5;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#actions Button:hover {
|
|
174
|
+
background: #24283b;
|
|
175
|
+
border: round #7aa2f7;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#actions Button:focus {
|
|
179
|
+
outline: none;
|
|
180
|
+
border: round #7aa2f7;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#action-schedule {
|
|
184
|
+
background: #7aa2f7;
|
|
185
|
+
color: #1a1b26;
|
|
186
|
+
border: round #7aa2f7;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#action-schedule:hover {
|
|
190
|
+
background: #89b4fa;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#action-detail {
|
|
194
|
+
background: #1f2335;
|
|
195
|
+
color: #c0caf5;
|
|
196
|
+
border: round #3b4261;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#action-detail:hover {
|
|
200
|
+
background: #24283b;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#action-quit {
|
|
204
|
+
background: #f7768e;
|
|
205
|
+
color: #1a1b26;
|
|
206
|
+
border: round #f7768e;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#action-quit:hover {
|
|
210
|
+
background: #ff7a93;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#schedule-buttons {
|
|
214
|
+
height: auto;
|
|
215
|
+
margin: 1 0 0 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#schedule-buttons Button {
|
|
219
|
+
width: auto;
|
|
220
|
+
min-width: 16;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#schedule-edit-buttons {
|
|
224
|
+
height: auto;
|
|
225
|
+
margin: 1 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#schedule-edit-buttons Button {
|
|
229
|
+
width: auto;
|
|
230
|
+
min-width: 16;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.cell-editor-screen {
|
|
234
|
+
background: #1a1b26;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#cell-editor {
|
|
238
|
+
height: 1fr;
|
|
239
|
+
padding: 2;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#cell-editor-title {
|
|
243
|
+
color: #7aa2f7;
|
|
244
|
+
text-style: bold;
|
|
245
|
+
margin: 0 0 1 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#cell-editor-input {
|
|
249
|
+
height: auto;
|
|
250
|
+
border: round #3b4261;
|
|
251
|
+
background: #1f2335;
|
|
252
|
+
color: #c0caf5;
|
|
253
|
+
padding: 0 1;
|
|
254
|
+
text-style: bold;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#cell-editor-buttons {
|
|
258
|
+
margin: 1 0 0 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#cell-editor-buttons Button {
|
|
262
|
+
width: 1fr;
|
|
263
|
+
min-width: 16;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#manual-actions {
|
|
267
|
+
height: auto;
|
|
268
|
+
margin: 1 0 0 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#manual-actions Button {
|
|
272
|
+
width: auto;
|
|
273
|
+
min-width: 16;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#manual-actions Checkbox {
|
|
277
|
+
margin: 0 1;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#manual-options {
|
|
281
|
+
height: auto;
|
|
282
|
+
margin: 1 0 0 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#manual-options Button {
|
|
286
|
+
width: auto;
|
|
287
|
+
min-width: 16;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#manual-options Checkbox {
|
|
291
|
+
margin: 0 1 0 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#load-pane {
|
|
295
|
+
height: auto;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#load-options {
|
|
299
|
+
height: auto;
|
|
300
|
+
margin: 1 0 0 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#load-options Button {
|
|
304
|
+
width: auto;
|
|
305
|
+
min-width: 16;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#load-options Checkbox {
|
|
309
|
+
margin: 0 1 0 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
Button.-primary {
|
|
313
|
+
background: #7aa2f7;
|
|
314
|
+
color: #1a1b26;
|
|
315
|
+
text-style: bold;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
Button.-error {
|
|
319
|
+
background: #f7768e;
|
|
320
|
+
color: #1a1b26;
|
|
321
|
+
text-style: bold;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
Input, Select {
|
|
325
|
+
margin: 0 0 1 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
DataTable {
|
|
329
|
+
margin: 1 0;
|
|
330
|
+
height: 6;
|
|
331
|
+
border: round #3b4261;
|
|
332
|
+
}
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
def __init__(self):
|
|
336
|
+
super().__init__()
|
|
337
|
+
self.db = dbClass()
|
|
338
|
+
self.setting = Setting()
|
|
339
|
+
self.schedule_entries = []
|
|
340
|
+
self.output_placeholder = SCHEDULE_PLACEHOLDER
|
|
341
|
+
self._editing_cell = None
|
|
342
|
+
|
|
343
|
+
def on_mount(self) -> None:
|
|
344
|
+
self._set_sidebar(SIDEBAR_TITLE_SCHEDULE, SCHEDULE_PLACEHOLDER)
|
|
345
|
+
self._mount_schedule_form()
|
|
346
|
+
|
|
347
|
+
def compose(self) -> ComposeResult:
|
|
348
|
+
yield Header()
|
|
349
|
+
with Vertical(id="app-body"):
|
|
350
|
+
yield Static("UW Course Helper", id="title")
|
|
351
|
+
yield Static("Search courses, build schedules, export PDFs.", id="subtitle")
|
|
352
|
+
with Horizontal(id="actions"):
|
|
353
|
+
yield Button("Build Schedule", id="action-schedule", variant="primary")
|
|
354
|
+
yield Button("Check Course Detail", id="action-detail")
|
|
355
|
+
yield Button("Quit", id="action-quit", variant="error")
|
|
356
|
+
yield Static("Choose an action to begin.", id="status")
|
|
357
|
+
with Horizontal(id="layout"):
|
|
358
|
+
with Vertical(id="main"):
|
|
359
|
+
yield Vertical(id="form")
|
|
360
|
+
with Vertical(id="sidebar"):
|
|
361
|
+
yield Static(SIDEBAR_TITLE_SCHEDULE, id="sidebar-title")
|
|
362
|
+
output = Static(SCHEDULE_PLACEHOLDER, id="output")
|
|
363
|
+
output.display = True
|
|
364
|
+
yield output
|
|
365
|
+
yield Footer()
|
|
366
|
+
|
|
367
|
+
def _set_status(self, message):
|
|
368
|
+
self.query_one("#status", Static).update(message)
|
|
369
|
+
|
|
370
|
+
def _set_output(self, message):
|
|
371
|
+
output = self.query_one("#output", Static)
|
|
372
|
+
output.update(message or self.output_placeholder)
|
|
373
|
+
output.display = True
|
|
374
|
+
|
|
375
|
+
def _set_sidebar_title(self, title):
|
|
376
|
+
self.query_one("#sidebar-title", Static).update(title)
|
|
377
|
+
|
|
378
|
+
def _reset_form(self):
|
|
379
|
+
form = self.query_one("#form", Vertical)
|
|
380
|
+
form.remove_children()
|
|
381
|
+
for widget in self.query("#schedule-collection, #schedule-tabs, #schedule-term-hint"):
|
|
382
|
+
widget.remove()
|
|
383
|
+
self._set_output("")
|
|
384
|
+
|
|
385
|
+
def _set_sidebar(self, title, placeholder):
|
|
386
|
+
self.output_placeholder = placeholder
|
|
387
|
+
self._set_sidebar_title(title)
|
|
388
|
+
self._set_output("")
|
|
389
|
+
|
|
390
|
+
def _mount_detail_form(self):
|
|
391
|
+
if self.query("#detail-input"):
|
|
392
|
+
self.query_one("#detail-input", Input).focus()
|
|
393
|
+
return
|
|
394
|
+
form = self.query_one("#form", Vertical)
|
|
395
|
+
for widget in detail_form_widgets():
|
|
396
|
+
form.mount(widget)
|
|
397
|
+
self._set_status("Enter a course code to view description.")
|
|
398
|
+
|
|
399
|
+
def _mount_schedule_form(self):
|
|
400
|
+
if self.query("#schedule-collection"):
|
|
401
|
+
self.query_one("#schedule-collection", Select).focus()
|
|
402
|
+
return
|
|
403
|
+
form = self.query_one("#form", Vertical)
|
|
404
|
+
collections = self.db.listClassCollections()
|
|
405
|
+
if not collections:
|
|
406
|
+
self._set_status("No term collections found in the database.")
|
|
407
|
+
options = [(_label_collection(name), name) for name in collections]
|
|
408
|
+
form.mount(schedule_tabs_with_options(options))
|
|
409
|
+
if collections:
|
|
410
|
+
self._set_status("Add courses manually or load a config file, then generate the schedule.")
|
|
411
|
+
self._update_schedule_preview()
|
|
412
|
+
|
|
413
|
+
def _update_schedule_preview(self):
|
|
414
|
+
if not self.query("#schedule-table"):
|
|
415
|
+
return
|
|
416
|
+
table = self.query_one("#schedule-table", DataTable)
|
|
417
|
+
table.clear(columns=True)
|
|
418
|
+
table.show_row_labels = True
|
|
419
|
+
if not self.schedule_entries:
|
|
420
|
+
table.add_columns("No Courses")
|
|
421
|
+
table.add_row("", label="Course")
|
|
422
|
+
table.add_row("", label="Class ID")
|
|
423
|
+
return
|
|
424
|
+
table.show_header = False
|
|
425
|
+
table.add_columns(*[entry["course"] for entry in self.schedule_entries])
|
|
426
|
+
courses = [entry["course"] for entry in self.schedule_entries]
|
|
427
|
+
class_ids = [
|
|
428
|
+
"" if entry.get("class_id") is None else str(entry.get("class_id"))
|
|
429
|
+
for entry in self.schedule_entries
|
|
430
|
+
]
|
|
431
|
+
table.add_row(*courses, label="Course")
|
|
432
|
+
table.add_row(*class_ids, label="Class ID")
|
|
433
|
+
|
|
434
|
+
def on_button_pressed(self, event):
|
|
435
|
+
button_id = event.button.id
|
|
436
|
+
if button_id is None or not button_id.startswith("action-"):
|
|
437
|
+
return
|
|
438
|
+
if button_id == ACTION_QUIT:
|
|
439
|
+
self.exit()
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
self._reset_form()
|
|
443
|
+
|
|
444
|
+
if button_id == ACTION_DETAIL:
|
|
445
|
+
self._set_sidebar(SIDEBAR_TITLE_DETAIL, DETAIL_PLACEHOLDER)
|
|
446
|
+
self._mount_detail_form()
|
|
447
|
+
elif button_id == ACTION_SCHEDULE:
|
|
448
|
+
self._set_sidebar(SIDEBAR_TITLE_SCHEDULE, SCHEDULE_PLACEHOLDER)
|
|
449
|
+
self._mount_schedule_form()
|
|
450
|
+
|
|
451
|
+
@on(Button.Pressed, "#detail-run")
|
|
452
|
+
def on_detail_run(self) -> None:
|
|
453
|
+
course = self.query_one("#detail-input", Input).value.strip()
|
|
454
|
+
if not course:
|
|
455
|
+
self._set_status("Please enter a course code.")
|
|
456
|
+
return
|
|
457
|
+
course_info = get_course_detail(self.db, course)
|
|
458
|
+
if not course_info:
|
|
459
|
+
self._set_output(f"Course not found: {course}")
|
|
460
|
+
return
|
|
461
|
+
detail = [
|
|
462
|
+
f"{course} — {course_info.get('courseDescription') or 'No description'}",
|
|
463
|
+
f"Credit: {course_info.get('courseCredit') or 'N/A'}",
|
|
464
|
+
]
|
|
465
|
+
requirements = course_info.get("requirementsDescription")
|
|
466
|
+
if requirements:
|
|
467
|
+
detail.append(f"Requirements: {requirements}")
|
|
468
|
+
self._set_output("\n".join(detail))
|
|
469
|
+
|
|
470
|
+
@on(Button.Pressed, "#schedule-run")
|
|
471
|
+
def on_schedule_run(self) -> None:
|
|
472
|
+
gray = self.query_one("#schedule-gray", Checkbox).value
|
|
473
|
+
self._build_schedule(gray)
|
|
474
|
+
|
|
475
|
+
def _build_schedule(self, gray: bool, collection_override: str | None = None) -> None:
|
|
476
|
+
try:
|
|
477
|
+
if collection_override:
|
|
478
|
+
collection = collection_override
|
|
479
|
+
if self.query("#schedule-collection"):
|
|
480
|
+
self.query_one("#schedule-collection", Select).value = collection
|
|
481
|
+
else:
|
|
482
|
+
collection = self.query_one("#schedule-collection", Select).value
|
|
483
|
+
if not isinstance(collection, str) or not collection:
|
|
484
|
+
self._set_status("Please select a collection.")
|
|
485
|
+
return
|
|
486
|
+
self.db.switchCollection(collection)
|
|
487
|
+
course_wish_list = []
|
|
488
|
+
for entry in self.schedule_entries:
|
|
489
|
+
course = entry["course"]
|
|
490
|
+
class_id = entry.get("class_id")
|
|
491
|
+
if class_id is not None:
|
|
492
|
+
course_wish_list.append(SearchAvalibleInTerm(self.db, course, class_id, quiet=True))
|
|
493
|
+
else:
|
|
494
|
+
course_wish_list.append(SearchAvalibleInTerm(self.db, course, quiet=True))
|
|
495
|
+
course_wish_list = [c for c in course_wish_list if c is not None]
|
|
496
|
+
makeSchedule(self.db, courseWishList=course_wish_list, gray=gray)
|
|
497
|
+
self._set_output(render_weekly_schedule(self.setting.outDir))
|
|
498
|
+
self._set_status("Schedule generated.")
|
|
499
|
+
except Exception as exc:
|
|
500
|
+
self._set_output(f"Failed to build schedule: {exc}")
|
|
501
|
+
|
|
502
|
+
@on(Select.Changed, "#schedule-collection")
|
|
503
|
+
def on_schedule_collection_changed(self) -> None:
|
|
504
|
+
collection = self.query_one("#schedule-collection", Select).value
|
|
505
|
+
if not collection:
|
|
506
|
+
return
|
|
507
|
+
if self.query("#schedule-term-hint"):
|
|
508
|
+
hint = self.query_one("#schedule-term-hint", Static)
|
|
509
|
+
hint.remove()
|
|
510
|
+
self._update_schedule_preview()
|
|
511
|
+
|
|
512
|
+
@on(Button.Pressed, "#schedule-export")
|
|
513
|
+
def on_export_run(self) -> None:
|
|
514
|
+
try:
|
|
515
|
+
generate_pdf(self.setting.outDir)
|
|
516
|
+
self._set_output("PDF export completed.")
|
|
517
|
+
except Exception as exc:
|
|
518
|
+
self._set_output(f"Export failed: {exc}")
|
|
519
|
+
|
|
520
|
+
@on(Button.Pressed, "#schedule-add")
|
|
521
|
+
def on_schedule_add(self) -> None:
|
|
522
|
+
course = self.query_one("#schedule-course", Input).value.strip()
|
|
523
|
+
class_id_raw = self.query_one("#schedule-class-id", Input).value.strip()
|
|
524
|
+
if not course:
|
|
525
|
+
self._set_status("Please enter a course code.")
|
|
526
|
+
return
|
|
527
|
+
if class_id_raw:
|
|
528
|
+
try:
|
|
529
|
+
class_id = int(class_id_raw)
|
|
530
|
+
except ValueError:
|
|
531
|
+
self._set_status("Class ID must be a number.")
|
|
532
|
+
return
|
|
533
|
+
else:
|
|
534
|
+
class_id = None
|
|
535
|
+
self.schedule_entries.append({"course": course, "class_id": class_id})
|
|
536
|
+
self.query_one("#schedule-course", Input).value = ""
|
|
537
|
+
self.query_one("#schedule-class-id", Input).value = ""
|
|
538
|
+
self._update_schedule_preview()
|
|
539
|
+
self._set_status(f"Added {course} successfully.")
|
|
540
|
+
|
|
541
|
+
@on(Button.Pressed, "#schedule-clear")
|
|
542
|
+
def on_schedule_clear(self) -> None:
|
|
543
|
+
self.schedule_entries = []
|
|
544
|
+
self._update_schedule_preview()
|
|
545
|
+
self._set_status("Cleared schedule list.")
|
|
546
|
+
|
|
547
|
+
@on(Button.Pressed, "#schedule-remove")
|
|
548
|
+
def on_schedule_remove(self) -> None:
|
|
549
|
+
if not self.query("#schedule-table"):
|
|
550
|
+
return
|
|
551
|
+
if not self.schedule_entries:
|
|
552
|
+
self._set_status("No entries to remove.")
|
|
553
|
+
return
|
|
554
|
+
table = self.query_one("#schedule-table", DataTable)
|
|
555
|
+
col_index = table.cursor_column
|
|
556
|
+
if col_index is None:
|
|
557
|
+
self._set_status("Select a column to remove.")
|
|
558
|
+
return
|
|
559
|
+
if col_index >= len(self.schedule_entries):
|
|
560
|
+
self._set_status("Selected row is out of range. Refreshing list.")
|
|
561
|
+
self._update_schedule_preview()
|
|
562
|
+
return
|
|
563
|
+
self.schedule_entries.pop(col_index)
|
|
564
|
+
self._update_schedule_preview()
|
|
565
|
+
self._set_status("Removed selected entry.")
|
|
566
|
+
|
|
567
|
+
@on(DataTable.CellSelected, "#schedule-table")
|
|
568
|
+
def on_schedule_cell_selected(self, event: DataTable.CellSelected) -> None:
|
|
569
|
+
if not self.schedule_entries:
|
|
570
|
+
return
|
|
571
|
+
self._editing_cell = (event.coordinate.row, event.coordinate.column)
|
|
572
|
+
self._set_status("Press Enter to edit the selected cell.")
|
|
573
|
+
|
|
574
|
+
def on_key(self, event: events.Key) -> None:
|
|
575
|
+
if event.key not in ("enter", "e"):
|
|
576
|
+
return
|
|
577
|
+
if not self.query("#schedule-table"):
|
|
578
|
+
return
|
|
579
|
+
table = self.query_one("#schedule-table", DataTable)
|
|
580
|
+
if not table.has_focus:
|
|
581
|
+
return
|
|
582
|
+
self.action_edit_cell()
|
|
583
|
+
event.stop()
|
|
584
|
+
|
|
585
|
+
def action_edit_cell(self) -> None:
|
|
586
|
+
if not self.query("#schedule-table"):
|
|
587
|
+
return
|
|
588
|
+
table = self.query_one("#schedule-table", DataTable)
|
|
589
|
+
row_index = table.cursor_row
|
|
590
|
+
col_index = table.cursor_column
|
|
591
|
+
if row_index is None or col_index is None:
|
|
592
|
+
self._set_status("Select a cell to edit.")
|
|
593
|
+
return
|
|
594
|
+
if col_index >= len(self.schedule_entries):
|
|
595
|
+
self._set_status("Selected column is out of range. Refreshing list.")
|
|
596
|
+
self._update_schedule_preview()
|
|
597
|
+
return
|
|
598
|
+
self._editing_cell = (row_index, col_index)
|
|
599
|
+
self._show_cell_editor(row_index, col_index)
|
|
600
|
+
|
|
601
|
+
def _show_cell_editor(self, row_index, col_index):
|
|
602
|
+
if not self.query("#schedule-table"):
|
|
603
|
+
return
|
|
604
|
+
table = self.query_one("#schedule-table", DataTable)
|
|
605
|
+
cell_value = table.get_cell_at(Coordinate(row_index, col_index))
|
|
606
|
+
self._editing_cell = (row_index, col_index)
|
|
607
|
+
screen = _CellEditorScreen(
|
|
608
|
+
f"Edit cell ({row_index}, {col_index})",
|
|
609
|
+
"" if cell_value is None else str(cell_value),
|
|
610
|
+
)
|
|
611
|
+
self._set_status("Editing cell: press Enter to save and refresh.")
|
|
612
|
+
self.push_screen(screen, self._apply_cell_edit)
|
|
613
|
+
|
|
614
|
+
def _apply_cell_edit(self, value: str | None) -> None:
|
|
615
|
+
if value is None:
|
|
616
|
+
self._set_status("Edit canceled.")
|
|
617
|
+
return
|
|
618
|
+
if self._editing_cell is None:
|
|
619
|
+
return
|
|
620
|
+
row_index, col_index = self._editing_cell
|
|
621
|
+
if col_index >= len(self.schedule_entries):
|
|
622
|
+
self._set_status("Selected column is out of range. Refreshing list.")
|
|
623
|
+
self._update_schedule_preview()
|
|
624
|
+
return
|
|
625
|
+
new_value = value.strip()
|
|
626
|
+
if row_index == 0:
|
|
627
|
+
if not new_value:
|
|
628
|
+
self._set_status("Course code cannot be empty.")
|
|
629
|
+
return
|
|
630
|
+
self.schedule_entries[col_index]["course"] = new_value
|
|
631
|
+
else:
|
|
632
|
+
if new_value:
|
|
633
|
+
try:
|
|
634
|
+
self.schedule_entries[col_index]["class_id"] = int(new_value)
|
|
635
|
+
except ValueError:
|
|
636
|
+
self._set_status("Class ID must be a number.")
|
|
637
|
+
return
|
|
638
|
+
else:
|
|
639
|
+
self.schedule_entries[col_index]["class_id"] = None
|
|
640
|
+
self._update_schedule_preview()
|
|
641
|
+
self._set_status("Updated entry.")
|
|
642
|
+
|
|
643
|
+
@on(Button.Pressed, "#schedule-load")
|
|
644
|
+
def on_schedule_load(self) -> None:
|
|
645
|
+
if not self.query("#schedule-load-file"):
|
|
646
|
+
self._set_status("Switch to Load Config tab first.")
|
|
647
|
+
return
|
|
648
|
+
path = (
|
|
649
|
+
self.query_one("#schedule-load-file", Input).value.strip()
|
|
650
|
+
or f"{self.setting.dataName}/schema.txt"
|
|
651
|
+
)
|
|
652
|
+
if not path:
|
|
653
|
+
self._set_status("Please enter a config file path to load.")
|
|
654
|
+
return
|
|
655
|
+
self._set_status(f"Loading config from {path} ...")
|
|
656
|
+
self.refresh()
|
|
657
|
+
self.call_later(self._load_config, path)
|
|
658
|
+
|
|
659
|
+
def _load_config(self, path: str) -> None:
|
|
660
|
+
try:
|
|
661
|
+
self._set_status(f"Reading {path} ...")
|
|
662
|
+
collection, course_wish_list = _parse_collection_file(path, self.db)
|
|
663
|
+
self.schedule_entries = []
|
|
664
|
+
self.query_one("#schedule-collection", Select).value = collection
|
|
665
|
+
for entry in course_wish_list:
|
|
666
|
+
if len(entry) == 2:
|
|
667
|
+
self.schedule_entries.append({"course": entry[0], "class_id": entry[1]})
|
|
668
|
+
else:
|
|
669
|
+
self.schedule_entries.append({"course": entry[0], "class_id": None})
|
|
670
|
+
self._update_schedule_preview()
|
|
671
|
+
gray = self.query_one("#schedule-gray", Checkbox).value
|
|
672
|
+
self._build_schedule(gray, collection_override=collection)
|
|
673
|
+
self._set_status("Config loaded and schedule generated.")
|
|
674
|
+
except Exception as exc:
|
|
675
|
+
self._set_status(f"Failed to load config: {exc}")
|
|
676
|
+
self._set_output(f"Failed to load config: {exc}")
|
|
677
|
+
|
|
678
|
+
@on(Button.Pressed, "#schedule-save")
|
|
679
|
+
def on_schedule_save(self) -> None:
|
|
680
|
+
path = (
|
|
681
|
+
self.query_one("#schedule-save-file", Input).value.strip()
|
|
682
|
+
or f"{self.setting.dataName}/schema.txt"
|
|
683
|
+
)
|
|
684
|
+
collection = self.query_one("#schedule-collection", Select).value
|
|
685
|
+
if not collection:
|
|
686
|
+
self._set_status("Please enter a collection name before saving.")
|
|
687
|
+
return
|
|
688
|
+
try:
|
|
689
|
+
with open(path, "w") as handle:
|
|
690
|
+
handle.write(f"{collection}\n\n")
|
|
691
|
+
for entry in self.schedule_entries:
|
|
692
|
+
if entry.get("class_id") is not None:
|
|
693
|
+
handle.write(f"{entry['course']}, {entry['class_id']}\n")
|
|
694
|
+
else:
|
|
695
|
+
handle.write(f"{entry['course']}\n")
|
|
696
|
+
self._set_output(f"Config saved to: {path}")
|
|
697
|
+
except Exception as exc:
|
|
698
|
+
self._set_output(f"Failed to save config: {exc}")
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
class _CellEditorScreen(ModalScreen[str]):
|
|
702
|
+
def __init__(self, title, value):
|
|
703
|
+
super().__init__()
|
|
704
|
+
self._title = title
|
|
705
|
+
self._value = value
|
|
706
|
+
|
|
707
|
+
def on_mount(self) -> None:
|
|
708
|
+
self.add_class("cell-editor-screen")
|
|
709
|
+
|
|
710
|
+
def compose(self) -> ComposeResult:
|
|
711
|
+
with Vertical(id="cell-editor"):
|
|
712
|
+
yield Static(self._title, id="cell-editor-title")
|
|
713
|
+
yield Input(value=self._value, id="cell-editor-input")
|
|
714
|
+
with Horizontal(id="cell-editor-buttons"):
|
|
715
|
+
yield Button("Save", id="cell-editor-save", variant="primary")
|
|
716
|
+
yield Button("Cancel", id="cell-editor-cancel")
|
|
717
|
+
|
|
718
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
719
|
+
if event.button.id == "cell-editor-save":
|
|
720
|
+
value = self.query_one("#cell-editor-input", Input).value
|
|
721
|
+
self.dismiss(value)
|
|
722
|
+
elif event.button.id == "cell-editor-cancel":
|
|
723
|
+
self.dismiss(None)
|
|
724
|
+
|
|
725
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
726
|
+
self.dismiss(event.value)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def run_app():
|
|
730
|
+
CourseApp().run()
|