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