CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/course/util.ts
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
import { SizeType } from "antd/lib/config-provider/SizeContext";
6
import { Map } from "immutable";
7
import { IntlShape } from "react-intl";
8
9
import {
10
TypedMap,
11
useEffect,
12
useState,
13
useWindowDimensions,
14
} from "@cocalc/frontend/app-framework";
15
import { IconName } from "@cocalc/frontend/components/icon";
16
import { labels } from "@cocalc/frontend/i18n";
17
import { KUCALC_COCALC_COM } from "@cocalc/util/db-schema/site-defaults";
18
import {
19
cmp,
20
defaults,
21
merge,
22
required,
23
search_match,
24
search_split,
25
separate_file_extension,
26
} from "@cocalc/util/misc";
27
import { ProjectsStore } from "../projects/store";
28
import { UserMap } from "../todo-types";
29
import { StudentsMap } from "./store";
30
import { AssignmentCopyStep } from "./types";
31
32
// Pure functions used in the course manager
33
export function STEPS(peer: boolean): AssignmentCopyStep[] {
34
if (peer) {
35
return [
36
"assignment",
37
"collect",
38
"peer_assignment",
39
"peer_collect",
40
"return_graded",
41
];
42
} else {
43
return ["assignment", "collect", "return_graded"];
44
}
45
}
46
47
export function previous_step(
48
step: AssignmentCopyStep,
49
peer: boolean,
50
): AssignmentCopyStep {
51
let prev: AssignmentCopyStep | undefined;
52
for (const s of STEPS(peer)) {
53
if (step === s) {
54
if (prev === undefined) break;
55
return prev;
56
}
57
prev = s;
58
}
59
throw Error(`BUG! previous_step('${step}, ${peer}')`);
60
}
61
62
export function step_direction(step: AssignmentCopyStep): "to" | "from" {
63
switch (step) {
64
case "assignment":
65
return "to";
66
case "collect":
67
return "from";
68
case "return_graded":
69
return "to";
70
case "peer_assignment":
71
return "to";
72
case "peer_collect":
73
return "from";
74
default:
75
throw Error(`BUG! step_direction('${step}')`);
76
}
77
}
78
79
export function step_verb(step: AssignmentCopyStep) {
80
switch (step) {
81
case "assignment":
82
return "assign";
83
case "collect":
84
return "collect";
85
case "return_graded":
86
return "return";
87
case "peer_assignment":
88
return "assign";
89
case "peer_collect":
90
return "collect";
91
default:
92
throw Error(`BUG! step_verb('${step}')`);
93
}
94
}
95
96
export function step_ready(step: AssignmentCopyStep, n) {
97
switch (step) {
98
case "assignment":
99
return "";
100
case "collect":
101
if (n > 1) {
102
return " who have already received it";
103
} else {
104
return " who has already received it";
105
}
106
case "return_graded":
107
return " whose work you have graded";
108
case "peer_assignment":
109
return " for peer grading";
110
case "peer_collect":
111
return " who should have peer graded it";
112
}
113
}
114
115
// Takes a student immutable.Map with key 'student_id'
116
// Returns a list of students `x` shaped like:
117
// {
118
// first_name : string
119
// last_name : string
120
// last_active : integer
121
// hosting : string
122
// email_address : string
123
// }
124
export function parse_students(
125
student_map: StudentsMap,
126
user_map: UserMap,
127
redux,
128
intl: IntlShape,
129
) {
130
const v = immutable_to_list(student_map, "student_id");
131
for (const x of v) {
132
if (x.account_id != null) {
133
const user = user_map.get(x.account_id);
134
if (x.first_name == null) {
135
x.first_name = user == null ? "" : user.get("first_name", "");
136
}
137
if (x.last_name == null) {
138
x.last_name = user == null ? "" : user.get("last_name", "");
139
}
140
if (x.project_id != null) {
141
const projects_store = redux.getStore("projects");
142
if (projects_store != null) {
143
const last_active = projects_store.get_last_active(x.project_id);
144
if (last_active != null) {
145
x.last_active = last_active.get(x.account_id);
146
}
147
}
148
}
149
}
150
const { description, state } = projectStatus(x.project_id, redux, intl);
151
x.hosting = description + state;
152
153
if (x.first_name == null) {
154
x.first_name = "";
155
}
156
if (x.last_name == null) {
157
x.last_name = "";
158
}
159
if (x.last_active == null) {
160
x.last_active = 0;
161
}
162
if (x.email_address == null) {
163
x.email_address = "";
164
}
165
}
166
return v;
167
}
168
169
// Transforms Iterable<K, M<i, m>> to [M<i + primary_key, m + K>] where primary_key maps to K
170
// Dunno if either of these is readable...
171
// Turns Map(Keys -> Objects{...}) into [Objects{primary_key : Key, ...}]
172
// TODO: Type return array better
173
export function immutable_to_list(x: undefined): undefined;
174
export function immutable_to_list<T, P>(
175
x: Map<string, T>,
176
primary_key: P,
177
): T extends TypedMap<infer S>
178
? S[]
179
: T extends Map<string, infer S>
180
? S[]
181
: any;
182
export function immutable_to_list(x: any, primary_key?): any {
183
if (x == null || x == undefined) {
184
return;
185
}
186
const v: any[] = [];
187
x.map((val, key) => v.push(merge(val.toJS(), { [primary_key]: key })));
188
return v;
189
}
190
191
// Returns a list of matched objects and the number of objects
192
// which were in the original list but omitted in the returned list
193
export function compute_match_list(opts: {
194
list: any[];
195
search_key: string;
196
search: string;
197
}) {
198
opts = defaults(opts, {
199
list: required, // list of objects<M>
200
search_key: required, // M.search_key property to match over
201
search: required, // matches to M.search_key
202
});
203
let { list, search, search_key } = opts;
204
if (!search) {
205
// why are you even calling this..
206
return { list, num_omitted: 0 };
207
}
208
209
const words = search_split(search);
210
const matches = (x) =>
211
search_match(x[search_key]?.toLowerCase?.() ?? "", words);
212
const n = list.length;
213
list = list.filter(matches);
214
const num_omitted = n - list.length;
215
return { list, num_omitted };
216
}
217
218
// Returns
219
// `list` partitioned into [not deleted, deleted]
220
// where each partition is sorted based on the given `compare_function`
221
// deleted is not included by default
222
export function order_list<T extends { deleted: boolean }>(opts: {
223
list: T[];
224
compare_function: (a: T, b: T) => number;
225
reverse: boolean;
226
include_deleted: boolean;
227
}) {
228
opts = defaults(opts, {
229
list: required,
230
compare_function: required,
231
reverse: false,
232
include_deleted: false,
233
});
234
let { list, compare_function, include_deleted } = opts;
235
236
const x = list.filter((x) => x.deleted);
237
const sorted_deleted = x.sort(compare_function);
238
239
const y = list.filter((x) => !x.deleted);
240
list = y.sort(compare_function);
241
242
if (opts.reverse) {
243
list.reverse();
244
}
245
246
if (include_deleted) {
247
list = list.concat(sorted_deleted);
248
}
249
250
return { list, deleted: x, num_deleted: sorted_deleted.length };
251
}
252
253
const cmp_strings = (a, b, field) => {
254
return cmp(a[field]?.toLowerCase() ?? "", b[field]?.toLowerCase() ?? "");
255
};
256
257
// first sort by domain, then address at that domain... since there will be many students
258
// at same domain, and '[email protected]' > '[email protected]' > '[email protected]' is true but not helpful
259
const cmp_email = (a, b) => {
260
const v = a.split("@");
261
const w = b.split("@");
262
const c = cmp(v[1], w[1]);
263
if (c) {
264
return c;
265
}
266
return cmp(v[0], w[0]);
267
};
268
269
const sort_on_string_field = (field, field2) => (a, b) => {
270
const c =
271
field == "email_address"
272
? cmp_email(a[field], b[field])
273
: cmp_strings(a, b, field);
274
return c != 0 ? c : cmp_strings(a, b, field2);
275
};
276
277
const sort_on_numerical_field = (field, field2) => (a, b) => {
278
const c = cmp((a[field] ?? 0) * -1, (b[field] ?? 0) * -1);
279
return c != 0 ? c : cmp_strings(a, b, field2);
280
};
281
282
type StudentField =
283
| "email"
284
| "first_name"
285
| "last_name"
286
| "last_active"
287
| "hosting";
288
289
export function pick_student_sorter<T extends { column_name: StudentField }>(
290
sort: T,
291
) {
292
switch (sort.column_name) {
293
case "email":
294
return sort_on_string_field("email_address", "last_name");
295
case "first_name":
296
return sort_on_string_field("first_name", "last_name");
297
case "last_name":
298
return sort_on_string_field("last_name", "first_name");
299
case "last_active":
300
return sort_on_numerical_field("last_active", "last_name");
301
case "hosting":
302
return sort_on_string_field("hosting", "email_address");
303
}
304
}
305
306
export function assignment_identifier(
307
assignment_id: string,
308
student_id: string,
309
): string {
310
return assignment_id + student_id;
311
}
312
313
export function autograded_filename(filename: string): string {
314
const { name, ext } = separate_file_extension(filename);
315
return name + "_autograded." + ext;
316
}
317
318
interface ProjectStatus {
319
description: string;
320
icon: IconName;
321
state: string;
322
tip?: string;
323
}
324
325
export function projectStatus(
326
project_id: string | undefined,
327
redux,
328
intl: IntlShape,
329
): ProjectStatus {
330
if (!project_id) {
331
return { description: "(not created)", icon: "hourglass-half", state: "" };
332
}
333
const store = redux.getStore("projects");
334
const state = ` (${store.get_state(project_id)})`;
335
const kucalc = redux.getStore("customize").get("kucalc");
336
if (kucalc === KUCALC_COCALC_COM) {
337
return projectStatusCoCalcCom({ project_id, state, store, intl });
338
} else {
339
const tip = intl.formatMessage({
340
id: "course.util.project_status.ready",
341
defaultMessage: "Project exists and is ready.",
342
});
343
return {
344
icon: "exclamation-triangle",
345
description: intl.formatMessage(labels.ready),
346
tip,
347
state,
348
};
349
}
350
}
351
352
function projectStatusCoCalcCom({
353
project_id,
354
state,
355
store,
356
intl,
357
}: {
358
project_id: string;
359
state: string;
360
store: ProjectsStore;
361
intl: IntlShape;
362
}): ProjectStatus {
363
const upgrades = store.get_total_project_quotas(project_id);
364
if (upgrades == null) {
365
// user opening the course, but isn't a collaborator on
366
// this student project for some reason. This will get fixed
367
// when configure all projects runs.
368
const description = intl.formatMessage({
369
id: "course.util.status-cocalc-com.project_not_available",
370
defaultMessage: "(not available)",
371
});
372
return {
373
description,
374
icon: "question-circle",
375
state: "",
376
};
377
}
378
379
if (upgrades.member_host) {
380
return {
381
icon: "check",
382
description: "Members-only hosting",
383
tip: "Projects is on a members-only server, which is much more robust and has priority support.",
384
state,
385
};
386
}
387
const licenses = store.get_site_license_ids(project_id);
388
if (licenses.length > 0) {
389
const description = intl.formatMessage({
390
id: "course.util.status-cocalc-com.licensed.description",
391
defaultMessage: "Licensed",
392
});
393
const tip = intl.formatMessage({
394
id: "course.util.status-cocalc-com.licensed.tooltip",
395
defaultMessage:
396
"Project is properly licensed and should work well. Thank you!",
397
});
398
return { description, icon: "check", state, tip };
399
} else {
400
const description = intl.formatMessage({
401
id: "course.util.status-cocalc-com.free.description",
402
defaultMessage: "Free Trial",
403
});
404
const tip = intl.formatMessage({
405
id: "course.util.status-cocalc-com.free.tooltip",
406
defaultMessage: `Project is a trial project hosted on a free server,
407
so it may be overloaded and will be rebooted frequently.
408
Please upgrade in course configuration.`,
409
});
410
return {
411
description,
412
icon: "exclamation-triangle",
413
state,
414
tip,
415
};
416
}
417
}
418
419
// the list of assignments, in particular with peer grading, has a large number of buttons
420
// in a single row. We mitigate this by rendering the buttons smaller if the screen is narrower.
421
export function useButtonSize(): SizeType {
422
const [size, setSize] = useState<SizeType>("small");
423
const { width } = useWindowDimensions();
424
useEffect(() => {
425
const next = width < 1024 ? "small" : "middle";
426
if (next != size) {
427
setSize(next);
428
}
429
});
430
return size;
431
}
432
433