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/configuration/configuration-copying.tsx
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2024 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Configuration copying.
8
9
- Select one or more other course files
10
- explicitly enter file path in current project
11
- also support other projects that you have access to
12
- use the "search all files you edited in the last year" feature (that's in projects)
13
- use find command in specific project: find . -xdev -type f \( -name "*.course" ! -name ".*" \)
14
- a name field (for customizing things)
15
16
- Select which configuration to share (and parameters)
17
18
- Click a button to copy the configuration from this course
19
to the target courses.
20
21
- For title and description, config could be a template based on course name or filename.
22
*/
23
24
import {
25
Alert,
26
Button,
27
Card,
28
Checkbox,
29
Divider,
30
Input,
31
Popconfirm,
32
Space,
33
Spin,
34
Tooltip,
35
} from "antd";
36
import { useMemo, useState } from "react";
37
import { FormattedMessage, useIntl } from "react-intl";
38
39
import { labels } from "@cocalc/frontend/i18n";
40
import {
41
redux,
42
useFrameContext,
43
useTypedRedux,
44
} from "@cocalc/frontend/app-framework";
45
import { Icon } from "@cocalc/frontend/components";
46
import ShowError from "@cocalc/frontend/components/error";
47
import { COMMANDS } from "@cocalc/frontend/course/commands";
48
import { exec } from "@cocalc/frontend/frame-editors/generic/client";
49
import { IntlMessage } from "@cocalc/frontend/i18n";
50
import { pathExists } from "@cocalc/frontend/project/directory-selector";
51
import { ProjectTitle } from "@cocalc/frontend/projects/project-title";
52
import { isIntlMessage } from "@cocalc/util/i18n";
53
import { plural } from "@cocalc/util/misc";
54
import { CONFIGURATION_GROUPS, ConfigurationGroup } from "./actions";
55
import { COLORS } from "@cocalc/util/theme";
56
57
export type CopyConfigurationOptions = {
58
[K in ConfigurationGroup]?: boolean;
59
};
60
61
export interface CopyConfigurationTargets {
62
[project_id_path: string]: boolean | null;
63
}
64
65
interface Props {
66
settings;
67
project_id;
68
actions;
69
close?: Function;
70
}
71
72
export default function ConfigurationCopying({
73
settings,
74
project_id,
75
actions,
76
close,
77
}: Props) {
78
const intl = useIntl();
79
80
const [error, setError] = useState<string>("");
81
const { numTargets, numOptions } = useMemo(() => {
82
const targets = getTargets(settings);
83
const options = getOptions(settings);
84
return { numTargets: numTrue(targets), numOptions: numTrue(options) };
85
}, [settings]);
86
const [copying, setCopying] = useState<boolean>(false);
87
88
const copyConfiguration = async () => {
89
try {
90
setCopying(true);
91
setError("");
92
const targets = getTargets(settings);
93
const options = getOptions(settings);
94
const t: { project_id: string; path: string }[] = [];
95
for (const key in targets) {
96
if (targets[key] === true) {
97
t.push(parseKey(key));
98
}
99
}
100
const g: ConfigurationGroup[] = [];
101
for (const key in options) {
102
if (options[key] === true) {
103
g.push(key as ConfigurationGroup);
104
}
105
}
106
await actions.configuration.copyConfiguration({
107
groups: g,
108
targets: t,
109
});
110
} catch (err) {
111
setError(`${err}`);
112
} finally {
113
setCopying(false);
114
}
115
};
116
117
const title = intl.formatMessage({
118
id: "course.configuration-copying.title",
119
defaultMessage: "Copy Course Configuration",
120
});
121
122
return (
123
<Card
124
title={
125
<>
126
<Icon name="copy" /> {title}
127
</>
128
}
129
>
130
<div style={{ color: COLORS.GRAY_M }}>
131
<FormattedMessage
132
id="course.configuration-copying.info"
133
defaultMessage={`Copy configuration from this course to other courses.
134
If you divide a large course into multiple smaller sections,
135
you can list each of the other .course files below,
136
then easily open any or all of them,
137
and copy configuration from this course to them.`}
138
/>
139
</div>
140
<div style={{ textAlign: "center", margin: "15px 0" }}>
141
<Button
142
size="large"
143
disabled={numTargets == 0 || numOptions == 0 || copying}
144
onClick={copyConfiguration}
145
>
146
<Icon name="copy" />
147
Copy{copying ? "ing" : ""} {numOptions}{" "}
148
{plural(numOptions, "configuration item")} to {numTargets}{" "}
149
{plural(numTargets, "target course")} {copying && <Spin />}
150
</Button>
151
</div>
152
<ShowError style={{ margin: "15px" }} error={error} setError={setError} />
153
<ConfigTargets
154
actions={actions}
155
project_id={project_id}
156
settings={settings}
157
numTargets={numTargets}
158
close={close}
159
/>
160
<ConfigOptions
161
settings={settings}
162
actions={actions}
163
numOptions={numOptions}
164
/>
165
</Card>
166
);
167
}
168
169
function parseKey(project_id_path: string): {
170
project_id: string;
171
path: string;
172
} {
173
return {
174
project_id: project_id_path.slice(0, 36),
175
path: project_id_path.slice(37),
176
};
177
}
178
179
function getTargets(settings) {
180
return (settings.get("copy_config_targets")?.toJS() ??
181
{}) as CopyConfigurationTargets;
182
}
183
184
function ConfigTargets({
185
settings,
186
actions,
187
project_id: course_project_id,
188
numTargets,
189
close,
190
}) {
191
const targets = getTargets(settings);
192
const v: JSX.Element[] = [];
193
const keys = Object.keys(targets);
194
keys.sort();
195
for (const key of keys) {
196
const val = targets[key];
197
if (val == null) {
198
// deleted
199
continue;
200
}
201
const { project_id, path } = parseKey(key);
202
v.push(
203
<div key={key} style={{ display: "flex" }}>
204
<div style={{ flex: 1 }}>
205
<Checkbox
206
checked={val}
207
onChange={(e) => {
208
const copy_config_targets = {
209
...targets,
210
[key]: e.target.checked,
211
};
212
actions.set({ copy_config_targets, table: "settings" });
213
}}
214
>
215
{path}
216
{project_id != course_project_id ? (
217
<>
218
{" "}
219
in <ProjectTitle project_id={project_id} />
220
</>
221
) : undefined}
222
</Checkbox>
223
<Tooltip
224
mouseEnterDelay={1}
225
title={
226
<>Open {path} in a new tab. (Use shift to open in background.)</>
227
}
228
>
229
<Button
230
type="link"
231
size="small"
232
onClick={(e) => {
233
const foreground =
234
!e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
235
redux
236
.getProjectActions(project_id)
237
.open_file({ path, foreground });
238
if (foreground) {
239
close?.();
240
}
241
}}
242
>
243
<Icon name="external-link" />
244
</Button>
245
</Tooltip>
246
</div>
247
<div>
248
<Popconfirm
249
title={<>Remove {path} from copy targets?</>}
250
onConfirm={() => {
251
const copy_config_targets = {
252
...targets,
253
[key]: null,
254
};
255
actions.set({ copy_config_targets, table: "settings" });
256
}}
257
>
258
<Tooltip
259
mouseEnterDelay={1}
260
title={<>Remove {path} from copy targets?</>}
261
>
262
<Button size="small" type="link">
263
<Icon name="trash" />
264
</Button>
265
</Tooltip>
266
</Popconfirm>
267
</div>
268
</div>,
269
);
270
}
271
v.push(
272
<div key="add">
273
<AddTarget
274
settings={settings}
275
actions={actions}
276
project_id={course_project_id}
277
/>
278
</div>,
279
);
280
281
const openAll = () => {
282
for (const key in targets) {
283
if (targets[key] !== true) {
284
continue;
285
}
286
const { project_id, path } = parseKey(key);
287
redux
288
.getProjectActions(project_id)
289
.open_file({ path, foreground: false });
290
}
291
};
292
293
return (
294
<div>
295
<div style={{ display: "flex" }}>
296
<div style={{ flex: 1 }}>
297
<Divider>
298
Courses to Configure{" "}
299
<Tooltip
300
mouseEnterDelay={1}
301
title="Open all selected targets in background tabs."
302
>
303
<a onClick={openAll}>(open all)</a>
304
</Tooltip>
305
</Divider>
306
</div>
307
<Space style={{ margin: "0 15px" }}>
308
<Button
309
disabled={numTargets == 0}
310
size="small"
311
onClick={() => {
312
const copy_config_targets = {} as CopyConfigurationTargets;
313
for (const key of keys) {
314
copy_config_targets[key] = false;
315
}
316
actions.set({ copy_config_targets, table: "settings" });
317
}}
318
>
319
None
320
</Button>
321
<Button
322
disabled={numFalse(targets) == 0}
323
size="small"
324
onClick={() => {
325
const copy_config_targets = {} as CopyConfigurationTargets;
326
for (const key of keys) {
327
copy_config_targets[key] = true;
328
}
329
actions.set({ copy_config_targets, table: "settings" });
330
}}
331
>
332
All
333
</Button>
334
</Space>
335
</div>
336
{v}
337
</div>
338
);
339
}
340
341
function getOptions(settings) {
342
return (settings.get("copy_config_options")?.toJS() ??
343
{}) as CopyConfigurationOptions;
344
}
345
346
function ConfigOptions({ settings, actions, numOptions }) {
347
const intl = useIntl();
348
349
function formatMesg(msg: string | IntlMessage): string {
350
if (isIntlMessage(msg)) {
351
return intl.formatMessage(msg);
352
} else {
353
return msg;
354
}
355
}
356
357
const options = getOptions(settings);
358
const v: JSX.Element[] = [];
359
for (const option of CONFIGURATION_GROUPS) {
360
const { title, label, icon } = COMMANDS[option] ?? {};
361
v.push(
362
<div key={option}>
363
<Tooltip title={formatMesg(title)} mouseEnterDelay={1}>
364
<Checkbox
365
checked={options[option]}
366
onChange={(e) => {
367
const copy_config_options = {
368
...options,
369
[option]: e.target.checked,
370
};
371
actions.set({ copy_config_options, table: "settings" });
372
}}
373
>
374
<Icon name={icon} /> {formatMesg(label)}
375
</Checkbox>
376
</Tooltip>
377
</div>,
378
);
379
}
380
return (
381
<div>
382
<div style={{ display: "flex" }}>
383
<div style={{ flex: 1 }}>
384
<Divider>Configuration to Copy</Divider>
385
</div>
386
<Space style={{ margin: "0 15px" }}>
387
<Button
388
disabled={numOptions == 0}
389
size="small"
390
onClick={() => {
391
const copy_config_options = {} as CopyConfigurationOptions;
392
for (const option of CONFIGURATION_GROUPS) {
393
copy_config_options[option] = false;
394
}
395
actions.set({ copy_config_options, table: "settings" });
396
}}
397
>
398
None
399
</Button>
400
<Button
401
disabled={numOptions == CONFIGURATION_GROUPS.length}
402
size="small"
403
onClick={() => {
404
const copy_config_options = {} as CopyConfigurationOptions;
405
for (const option of CONFIGURATION_GROUPS) {
406
copy_config_options[option] = true;
407
}
408
actions.set({ copy_config_options, table: "settings" });
409
}}
410
>
411
All
412
</Button>
413
</Space>
414
</div>
415
416
{v}
417
</div>
418
);
419
}
420
421
function numTrue(dict) {
422
let n = 0;
423
for (const a in dict) {
424
if (dict[a] === true) {
425
n += 1;
426
}
427
}
428
return n;
429
}
430
431
function numFalse(dict) {
432
let n = 0;
433
for (const a in dict) {
434
if (dict[a] === false) {
435
n += 1;
436
}
437
}
438
return n;
439
}
440
441
function AddTarget({ settings, actions, project_id }) {
442
const intl = useIntl();
443
const { path: course_path } = useFrameContext();
444
const [adding, setAdding] = useState<boolean>(false);
445
const [loading, setLoading] = useState<boolean>(false);
446
const [path, setPath] = useState<string>("");
447
const [error, setError] = useState<string>("");
448
const [create, setCreate] = useState<string>("");
449
const directoryListings = useTypedRedux(
450
{ project_id },
451
"directory_listings",
452
)?.get(0);
453
454
const add = async () => {
455
try {
456
setError("");
457
if (path == course_path) {
458
throw Error(`'${path} is the current course'`);
459
}
460
setLoading(true);
461
const exists = await pathExists(project_id, path, directoryListings);
462
if (!exists) {
463
if (create) {
464
await exec({
465
command: "touch",
466
args: [path],
467
project_id,
468
filesystem: true,
469
});
470
} else {
471
setCreate(path);
472
return;
473
}
474
}
475
const copy_config_targets = getTargets(settings);
476
copy_config_targets[`${project_id}/${path}`] = true;
477
actions.set({ copy_config_targets, table: "settings" });
478
setPath("");
479
setAdding(false);
480
setCreate("");
481
} catch (err) {
482
setError(`${err}`);
483
} finally {
484
setLoading(false);
485
}
486
};
487
488
return (
489
<div>
490
<div style={{ marginTop: "5px", width: "100%", display: "flex" }}>
491
<Button
492
disabled={adding || loading}
493
onClick={() => {
494
setAdding(true);
495
setPath("");
496
}}
497
>
498
<Icon name="plus-circle" /> Add Course...
499
</Button>
500
{adding && (
501
<Space.Compact style={{ width: "100%", flex: 1, margin: "0 15px" }}>
502
<Input
503
autoFocus
504
disabled={loading}
505
allowClear
506
style={{ width: "100%" }}
507
placeholder="Filename of .course file (e.g., 'a.course')"
508
onChange={(e) => setPath(e.target.value)}
509
value={path}
510
onPressEnter={add}
511
/>
512
<Button
513
type="primary"
514
onClick={add}
515
disabled={loading || !path.endsWith(".course")}
516
>
517
<Icon name="save" /> Add
518
{loading && <Spin style={{ marginLeft: "5px" }} />}
519
</Button>
520
</Space.Compact>
521
)}
522
{adding && (
523
<Button
524
disabled={loading}
525
onClick={() => {
526
setAdding(false);
527
setCreate("");
528
setPath("");
529
}}
530
>
531
{intl.formatMessage(labels.cancel)}
532
</Button>
533
)}
534
</div>
535
536
{create && create == path && (
537
<Alert
538
style={{ marginTop: "15px" }}
539
type="warning"
540
message={
541
<div>
542
{path} does not exist.{" "}
543
<Button disabled={loading} onClick={add}>
544
{loading ? (
545
<>
546
Creating... <Spin />
547
</>
548
) : (
549
"Create?"
550
)}
551
</Button>
552
</div>
553
}
554
/>
555
)}
556
<ShowError
557
style={{ marginTop: "15px" }}
558
error={error}
559
setError={setError}
560
/>
561
</div>
562
);
563
}
564
565