From 1e5c88b5c2601d37f533c4d31588a880f0d06bf9 Mon Sep 17 00:00:00 2001 From: Heliozoa Date: Wed, 26 Feb 2025 08:19:24 +0200 Subject: [PATCH] temp --- .eslintrc.js | 4 +- .vscode/tasks.json | 2 +- CONTRIBUTING.md | 4 + backend/controllers/oauth.ts | 11 +- config.js | 18 +- shared/lib.ts | 923 ++++++++++-------- src/actions/addNewMoocCourse.ts | 37 + .../{addNewCourse.ts => addNewTmcCourse.ts} | 10 +- src/actions/checkForExerciseUpdates.ts | 27 +- ...ts => downloadNewExercisesForTmcCourse.ts} | 8 +- ...ses.ts => downloadOrUpdateTmcExercises.ts} | 4 +- src/actions/index.ts | 8 +- src/actions/refreshLocalExercises.ts | 64 +- src/actions/types.ts | 2 +- .../{updateCourse.ts => updateTmcCourse.ts} | 27 +- src/actions/user.ts | 38 +- src/actions/webview.ts | 11 +- src/actions/workspace.ts | 14 +- src/api/exerciseDecorationProvider.ts | 5 +- src/api/{tmc.ts => langs.ts} | 57 +- src/api/storage.ts | 33 +- src/commands/addNewCourse.ts | 2 +- src/commands/closeExercise.ts | 2 +- src/commands/downloadNewExercises.ts | 8 +- src/commands/downloadOldSubmission.ts | 6 +- src/commands/resetExercise.ts | 2 +- src/commands/submitExercise.ts | 2 +- src/commands/switchWorkspace.ts | 6 +- src/commands/updateExercises.ts | 8 +- src/config/constants.ts | 6 +- src/config/userdata.ts | 140 ++- src/extension.ts | 10 +- src/init/commands.ts | 79 +- src/init/ui.ts | 58 +- src/migrate/index.ts | 2 +- src/migrate/migrateExerciseData.ts | 4 +- src/migrate/migrateUserData.ts | 66 +- src/panels/TmcPanel.ts | 385 +++++--- src/test-integration/tmc_langs_cli.spec.ts | 12 +- .../actions/checkForExerciseUpdates.test.ts | 2 +- .../actions/downloadOrUpdateExercises.test.ts | 44 +- .../actions/moveExtensionDataPath.test.ts | 2 +- .../actions/refreshLocalExercises.test.ts | 2 +- .../api/exerciseDecorationProvider.test.ts | 2 +- src/test/commands/cleanExercise.test.ts | 2 +- src/test/fixtures/userData.ts | 4 +- src/test/migrate/migrate.test.ts | 2 +- src/test/migrate/migrateExerciseData.test.ts | 2 +- src/test/mocks/actionContext.ts | 2 +- src/test/mocks/tmc.ts | 4 +- src/test/mocks/userdata.ts | 6 +- src/ui/treeview/treeview.ts | 2 +- src/ui/types.ts | 2 +- src/utilities/apiData.ts | 6 +- webpack.config.js | 9 +- webview-ui/src/App.svelte | 6 + webview-ui/src/components/ExercisePart.svelte | 24 +- webview-ui/src/panels/CourseDetails.svelte | 235 +++-- webview-ui/src/panels/Login.svelte | 12 +- webview-ui/src/panels/MyCourses.svelte | 37 +- webview-ui/src/panels/SelectCourse.svelte | 12 +- webview-ui/src/panels/SelectMoocCourse.svelte | 137 +++ .../src/panels/SelectOrganization.svelte | 127 +-- webview-ui/src/panels/SelectPlatform.svelte | 37 + webview-ui/src/utilities/script.ts | 33 +- 65 files changed, 1892 insertions(+), 966 deletions(-) create mode 100644 src/actions/addNewMoocCourse.ts rename src/actions/{addNewCourse.ts => addNewTmcCourse.ts} (88%) rename src/actions/{downloadNewExercisesForCourse.ts => downloadNewExercisesForTmcCourse.ts} (84%) rename src/actions/{downloadOrUpdateExercises.ts => downloadOrUpdateTmcExercises.ts} (95%) rename src/actions/{updateCourse.ts => updateTmcCourse.ts} (81%) rename src/api/{tmc.ts => langs.ts} (94%) create mode 100644 webview-ui/src/panels/SelectMoocCourse.svelte create mode 100644 webview-ui/src/panels/SelectPlatform.svelte diff --git a/.eslintrc.js b/.eslintrc.js index 53c259a0..cc4c2805 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,7 +10,7 @@ module.exports = { "plugin:import/errors", "plugin:import/warnings", "plugin:import/typescript", - "plugin:prettier/recommended", + "prettier", ], globals: { Atomics: "readonly", @@ -21,7 +21,7 @@ module.exports = { ecmaVersion: 6, sourceType: "module", }, - plugins: ["@typescript-eslint", "import", "prettier", "sort-class-members"], + plugins: ["@typescript-eslint", "import", "sort-class-members"], settings: { "import/core-modules": ["vscode"], }, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0d391ab9..f2075bc5 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -44,7 +44,7 @@ "type": "pickString", "id": "zBackend", "description": "Select backend mode", - "options": ["mockBackend", "localTMCServer", "production"], + "options": ["mockTmcLocalMooc", "mockBackend", "production"], "default": "production" } ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aea5ec7e..27db069e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,10 @@ cd backend && npm run setup You will need to rerun the setup when langs is updated, as this step will download the appropriate version of the CLI for the integration tests. +### MOOC backend + +To run the MOOC backend locally, see https://github.com/rage/secret-project-331. + ## Formatting This project uses [prettier](https://prettier.io/) for code formatting. You can run prettier across the code by calling `npm run prettier` from a terminal. diff --git a/backend/controllers/oauth.ts b/backend/controllers/oauth.ts index 11c9380f..b2d1979b 100644 --- a/backend/controllers/oauth.ts +++ b/backend/controllers/oauth.ts @@ -20,11 +20,20 @@ oauthRouter.post("/token", (req, res: Response) => { const { username, password } = req.body; console.log("Username:" + username, "Password:", password); + if (username === password) { + return res.json({ + access_token: username, + token_type: "bearer", + scope: "public", + created_at: 1234567890, + }); + } + const isTestUser = username === USER.username && password === USER.password; const isStudent = username === "student" && password === "student"; if (isTestUser || isStudent) { return res.json({ - access_token: "1234", + access_token: "token", token_type: "bearer", scope: "public", created_at: 1234567890, diff --git a/config.js b/config.js index de3126ac..a5a46971 100644 --- a/config.js +++ b/config.js @@ -5,25 +5,29 @@ const path = require("path"); const TMC_LANGS_RUST_VERSION = "0.36.1"; -const localTMCServer = { - __TMC_BACKEND__URL__: JSON.stringify("http://localhost:3000"), - __TMC_LANGS_CONFIG_DIR__: JSON.stringify(null), - __TMC_LANGS_DL_URL__: JSON.stringify("https://download.mooc.fi/tmc-langs-rust/"), +const mockTmcLocalMooc = { + __TMC_BACKEND_URL__: JSON.stringify("http://localhost:4001"), + __TMC_LANGS_CONFIG_DIR__: JSON.stringify(path.join(__dirname, "backend", "cli")), + __TMC_LANGS_DL_URL__: JSON.stringify("http://localhost:4001/langs/"), __TMC_LANGS_VERSION__: JSON.stringify(TMC_LANGS_RUST_VERSION), + __MOOC_BACKEND_URL__: JSON.stringify("http://project-331.local"), }; const mockBackend = { - __TMC_BACKEND__URL__: JSON.stringify("http://localhost:4001"), + __TMC_BACKEND_URL__: JSON.stringify("http://localhost:4001"), __TMC_LANGS_CONFIG_DIR__: JSON.stringify(path.join(__dirname, "backend", "cli")), __TMC_LANGS_DL_URL__: JSON.stringify("http://localhost:4001/langs/"), __TMC_LANGS_VERSION__: JSON.stringify(TMC_LANGS_RUST_VERSION), + // no mock mooc backend yet + __MOOC_BACKEND_URL__: JSON.stringify("https://courses.mooc.fi"), }; const productionApi = { - __TMC_BACKEND__URL__: JSON.stringify("https://tmc.mooc.fi"), + __TMC_BACKEND_URL__: JSON.stringify("https://tmc.mooc.fi"), __TMC_LANGS_CONFIG_DIR__: JSON.stringify(null), __TMC_LANGS_DL_URL__: JSON.stringify("https://download.mooc.fi/tmc-langs-rust/"), __TMC_LANGS_VERSION__: JSON.stringify(TMC_LANGS_RUST_VERSION), + __MOOC_BACKEND_URL__: JSON.stringify("https://courses.mooc.fi"), }; -module.exports = { mockBackend, localTMCServer, productionApi }; +module.exports = { mockTmcLocalMooc, mockBackend, productionApi }; diff --git a/shared/lib.ts b/shared/lib.ts index 30a5b80c..f16736e0 100644 --- a/shared/lib.ts +++ b/shared/lib.ts @@ -4,13 +4,13 @@ import { Uri } from "vscode"; -import { Course, Organization, RunResult, SubmissionFinished } from "./langsSchema"; +import { Course, CourseInstance, Organization, RunResult, SubmissionFinished } from "./langsSchema"; /** * Contains the state of the webview. */ export type State = { - panel: Panel; + panel: Panel; }; /* @@ -23,15 +23,17 @@ export type State = { * `id`: used to make sure messages are delivered to the correct panels */ export type Panel = - | AppPanel - | WelcomePanel - | LoginPanel - | MyCoursesPanel - | CourseDetailsPanel - | SelectOrganizationPanel - | SelectCoursePanel - | ExerciseTestsPanel - | ExerciseSubmissionPanel; + | AppPanel + | WelcomePanel + | LoginPanel + | MyCoursesPanel + | CourseDetailsPanel + | SelectOrganizationPanel + | SelectCoursePanel + | ExerciseTestsPanel + | ExerciseSubmissionPanel + | SelectPlatformPanel + | SelectMoocCoursePanel; export type PanelType = Panel["type"]; @@ -45,73 +47,99 @@ export type TargetPanel = Pick = Pick, "type">; export type AppPanel = { - id: number; - type: "App"; + id: number; + type: "App"; }; export type WelcomePanel = { - id: number; - type: "Welcome"; - version?: string; + id: number; + type: "Welcome"; + version?: string; }; export type LoginPanel = { - id: number; - type: "Login"; + id: number; + type: "Login"; }; export type MyCoursesPanel = { - id: number; - type: "MyCourses"; - courses?: Array; - tmcDataPath?: string; - tmcDataSize?: string; - courseDeadlines: Record; + id: number; + type: "MyCourses"; + courses?: Array; + moocCourses?: Array; + tmcDataPath?: string; + tmcDataSize?: string; + courseDeadlines: Record; }; export type CourseDetailsPanel = { - id: number; - type: "CourseDetails"; - courseId: number; - course?: CourseData; - offlineMode?: boolean; - exerciseGroups?: Array; - updateableExercises?: Array; - disabled?: boolean; - exerciseStatuses: Record; + id: number; + type: "CourseDetails"; + courseId: CourseIdentifier; + course?: { + // human readable name e.g. "Introduction to Computer Science" + title: string; + // e.g. introduction-to-computer-science + slug: string; + disabled: boolean; + courseData: CourseData; + awardedPoints: number; + availablePoints: number; + materialUrl: string | null; + perhapsExamMode: boolean; + } + offlineMode?: boolean; + updateableExercises?: Array; + exerciseGroups: Array; + exerciseStatuses: { + tmc: Record + mooc: Record + } }; export type SelectOrganizationPanel = { - id: number; - type: "SelectOrganization"; - // the result of the selection is sent back to this panel - requestingPanel: TargetPanel; + id: number; + type: "SelectOrganization"; + // the result of the selection is sent back to this panel + requestingPanel: TargetPanel; }; export type SelectCoursePanel = { - id: number; - type: "SelectCourse"; - organizationSlug: string; - // the result of the selection is sent back to this panel - requestingPanel: TargetPanel; + id: number; + type: "SelectCourse"; + organizationSlug: string; + // the result of the selection is sent back to this panel + requestingPanel: TargetPanel; }; export type ExerciseTestsPanel = { - id: number; - type: "ExerciseTests"; - course: TestCourse; - exercise: TestExercise; - exerciseUri: Uri; - testRunId: number; + id: number; + type: "ExerciseTests"; + course: TestCourse; + exercise: TestExercise; + exerciseUri: Uri; + testRunId: number; }; export type ExerciseSubmissionPanel = { - id: number; - type: "ExerciseSubmission"; - course: TestCourse; - exercise: TestExercise; + id: number; + type: "ExerciseSubmission"; + course: TestCourse; + exercise: TestExercise; }; +export type SelectPlatformPanel = { + id: number; + type: "SelectPlatform"; + requestingPanel: TargetPanel +}; + +export type SelectMoocCoursePanel = { + id: number; + type: "SelectMoocCourse"; + requestingPanel: TargetPanel +} + /* * ======== messages to webview ======== */ @@ -121,149 +149,166 @@ export type ExerciseSubmissionPanel = { * Handled by the Svelte app. */ export type ExtensionToWebview = - | { - type: "setPanel"; - target: TargetPanel; - panel: Panel; - } - | { - type: "setWelcomeData"; - target: TargetPanel; - version: string; - } - | { - type: "setMyCourses"; - target: TargetPanel; - courses: Array; - } - | { - type: "setTmcDataPath"; - target: BroadcastPanel; - tmcDataPath: string; - } - | { - type: "setNextCourseDeadline"; - target: TargetPanel; - courseId: number; - deadline: string; - } - | { - type: "setTmcDataSize"; - target: TargetPanel; - tmcDataSize: string; - } - | { - type: "loginError"; - target: TargetPanel; - error: string; - } - | { - type: "setCourseData"; - target: TargetPanel; - courseData: CourseData; - } - | { - type: "setCourseGroups"; - target: TargetPanel; - offlineMode: boolean; - exerciseGroups: Array; - } - | { - type: "setCourseDisabledStatus"; - target: BroadcastPanel; - courseId: number; - disabled: boolean; - } - | { - type: "exerciseStatusChange"; - target: BroadcastPanel; - exerciseId: number; - status: ExerciseStatus; - } - | { - type: "setUpdateables"; - target: BroadcastPanel; - exerciseIds: Array; - } - | { - type: "setOrganizations"; - target: TargetPanel; - organizations: Array; - } - | { - type: "setTmcBackendUrl"; - target: TargetPanel; - tmcBackendUrl: string; - } - | { - type: "setOrganization"; - target: TargetPanel; - organization: Organization; - } - | { - type: "setSelectableCourses"; - target: TargetPanel; - courses: Array; - } - | { - type: "testResults"; - target: TargetPanel; - testResults: TestResultData; - } - | { - type: "testError"; - target: TargetPanel; - error: Error; - } - | { - type: "pasteResult"; - target: TargetPanel; - pasteLink: string; - } - | { - type: "pasteError"; - target: TargetPanel; - error: string; - } - | { - type: "submissionStatusUrl"; - target: TargetPanel; - url: string; - } - | { - type: "submissionStatusUpdate"; - target: TargetPanel; - progressPercent: number; - message?: string; - } - | { - type: "submissionResult"; - target: TargetPanel; - result: SubmissionFinished; - questions: Array; - } - | { - type: "submissionStatusError"; - target: TargetPanel; - error: Error; - } - | { - type: "setNewExercises"; - target: BroadcastPanel; - courseId: number; - exerciseIds: Array; - } - | { - type: "willNotRunTestsForExam"; - target: TargetPanel; - } - // the last variant exists just to make TypeScript think that every panel type has - // at least two different message types, which makes TS treat them differently than if - // they only had one... - | { - type: never; - target: TargetPanel; - }; + | { + type: "setPanel"; + target: TargetPanel; + panel: Panel; + } + | { + type: "setWelcomeData"; + target: TargetPanel; + version: string; + } + | { + type: "setMyCourses"; + target: TargetPanel; + courses: Array; + } + | { + type: "setTmcDataPath"; + target: BroadcastPanel; + tmcDataPath: string; + } + | { + type: "setNextCourseDeadline"; + target: TargetPanel; + courseId: number; + deadline: string; + } + | { + type: "setTmcDataSize"; + target: TargetPanel; + tmcDataSize: string; + } + | { + type: "loginError"; + target: TargetPanel; + error: string; + } + | { + type: "setCourseData"; + target: TargetPanel; + courseData: CourseData; + } + | { + type: "setCourseGroups"; + target: TargetPanel; + offlineMode: boolean; + exerciseGroups: Array; + } + | { + type: "setCourseDisabledStatus"; + target: BroadcastPanel; + courseId: CourseIdentifier; + disabled: boolean; + } + | { + type: "exerciseStatusChange"; + target: BroadcastPanel; + exerciseId: ExerciseIdentifier; + status: ExerciseStatus; + } + | { + type: "setUpdateables"; + target: BroadcastPanel; + exerciseIds: Array; + } + | { + type: "setOrganizations"; + target: TargetPanel; + organizations: Array; + } + | { + type: "setTmcBackendUrl"; + target: TargetPanel; + tmcBackendUrl: string; + } + | { + type: "setOrganization"; + target: TargetPanel; + organization: Organization; + } + | { + type: "setSelectableCourses"; + target: TargetPanel; + courses: Array; + } + | { + type: "testResults"; + target: TargetPanel; + testResults: TestResultData; + } + | { + type: "testError"; + target: TargetPanel; + error: Error; + } + | { + type: "pasteResult"; + target: TargetPanel; + pasteLink: string; + } + | { + type: "pasteError"; + target: TargetPanel; + error: string; + } + | { + type: "submissionStatusUrl"; + target: TargetPanel; + url: string; + } + | { + type: "submissionStatusUpdate"; + target: TargetPanel; + progressPercent: number; + message?: string; + } + | { + type: "submissionResult"; + target: TargetPanel; + result: SubmissionFinished; + questions: Array; + } + | { + type: "submissionStatusError"; + target: TargetPanel; + error: Error; + } + | { + type: "setNewExercises"; + target: BroadcastPanel; + courseId: CourseIdentifier; + exerciseIds: Array; + } + | { + type: "willNotRunTestsForExam"; + target: TargetPanel; + } + | { + type: "setSelectMoocCourseData"; + target: BroadcastPanel; + courseInstances: Array + } | { + type: "requestSelectCourseDataError"; + target: TargetPanel; + error: string; + } | { + type: "requestSelectOrganizationDataError"; + target: TargetPanel; + error: string; + } | { + type: "requestSelectMoocCourseDataError"; + target: TargetPanel; + error: string; + } + // the last variant exists just to make TypeScript think that every panel type has + // at least two different message types, which makes TS treat them differently than if + // they only had one... + | { + type: never; + target: never; + }; // helper type for messages from the extension to a specific panel export type TargetedExtensionToWebview = Targeted; @@ -280,230 +325,261 @@ export type BroadcastExtensionToWebview = Broadcast; - } - | { - type: "removeCourse"; - id: number; - } - | { - type: "openCourseWorkspace"; - courseName: string; - } - | { - type: "downloadExercises"; - ids: Array; - courseName: string; - organizationSlug: string; - courseId: number; - mode: "download" | "update"; - } - | { - type: "clearNewExercises"; - courseId: number; - } - | { - type: "changeTmcDataPath"; - } - | { - type: "openCourseDetails"; - courseId: number; - } - | { - type: "openMyCourses"; - } - | { - type: "refreshCourseDetails"; - id: number; - useCache: boolean; - } - | { - type: "openExercises"; - ids: Array; - courseName: string; - } - | { - type: "closeExercises"; - ids: Array; - courseName: string; - } - | { - type: "refreshCourseDetails"; - id: number; - useCache: boolean; - } - | { - type: "selectCourse"; - sourcePanel: TargetPanel; - slug: string; - } - | { - type: "addCourse"; - organizationSlug: string; - courseId: number; - requestingPanel: TargetPanel; - } - | { - type: "relayToWebview"; - // the message type is handled by the webview - message: unknown; - } - | { - type: "closeSidePanel"; - } - | { - type: "cancelTests"; - testRunId: number; - } - | { - type: "submitExercise"; - course: TestCourse; - exercise: TestExercise; - exerciseUri: Uri; - } - | { - type: "pasteExercise"; - course: TestCourse; - exercise: TestExercise; - requestingPanel: TargetPanel; - } - | { - type: "openLinkInBrowser"; - url: string; - }; + | { + type: "requestCourseDetailsData"; + sourcePanel: CourseDetailsPanel; + } + | { + type: "requestExerciseSubmissionData"; + sourcePanel: ExerciseSubmissionPanel; + } + | { + type: "requestExerciseTestsData"; + sourcePanel: ExerciseTestsPanel; + } + | { + type: "requestLoginData"; + sourcePanel: LoginPanel; + } + | { + type: "requestMyCoursesData"; + sourcePanel: MyCoursesPanel; + } + | { + type: "requestSelectCourseData"; + sourcePanel: SelectCoursePanel; + } + | { + type: "requestSelectOrganizationData"; + sourcePanel: SelectOrganizationPanel; + } + | { + type: "requestWelcomeData"; + sourcePanel: WelcomePanel; + } + | { + type: "login"; + sourcePanel: LoginPanel; + username: string; + password: string; + } + | { + type: "selectOrganization"; + sourcePanel: TargetPanel; + } + | { + type: "removeCourse"; + id: number; + } + | { + type: "openCourseWorkspace"; + courseName: string; + } + | { + type: "downloadExercises"; + ids: Array; + courseId: CourseIdentifier; + mode: "download" | "update"; + } + | { + type: "clearNewExercises"; + courseId: number; + } + | { + type: "changeTmcDataPath"; + } + | { + type: "openCourseDetails"; + courseId: CourseIdentifier; + } + | { + type: "openMyCourses"; + } + | { + type: "refreshCourseDetails"; + id: CourseIdentifier; + useCache: boolean; + } + | { + type: "openExercises"; + ids: Array; + courseId: CourseIdentifier; + } + | { + type: "closeExercises"; + ids: Array; + courseId: CourseIdentifier; + } + | { + type: "refreshCourseDetails"; + id: CourseIdentifier; + useCache: boolean; + } + | { + type: "selectCourse"; + sourcePanel: TargetPanel; + slug: string; + } + | { + type: "addCourse"; + organizationSlug: string; + courseId: number; + requestingPanel: TargetPanel; + } + | { + type: "relayToWebview"; + // the message type is handled by the webview + message: unknown; + } + | { + type: "closeSidePanel"; + } + | { + type: "cancelTests"; + testRunId: number; + } + | { + type: "submitExercise"; + course: TestCourse; + exercise: TestExercise; + exerciseUri: Uri; + } + | { + type: "pasteExercise"; + course: TestCourse; + exercise: TestExercise; + requestingPanel: TargetPanel; + } + | { + type: "openLinkInBrowser"; + url: string; + } + | { + type: "selectPlatform"; + sourcePanel: TargetPanel; + } + | { + type: "selectMoocCourse"; + sourcePanel: TargetPanel; + } + | { + type: "requestSelectMoocCourseData"; + sourcePanel: TargetPanel; + } + | { + type: "addMoocCourse", + courseId: string, + instanceId: string, + courseName: string, + instanceName: string | null, + requestingPanel: TargetPanel; + }; /* * ======== additional types ======== */ -export type CourseData = { - id: number; - name: string; - title: string; - description: string; - organization: string; - awardedPoints: number; - availablePoints: number; - exercises: Array; - newExercises: Array; - disabled: boolean; - materialUrl: string | null; - perhapsExamMode: boolean; +export type CourseData = Enum; + +export type TmcCourseData = { + id: number; + name: string; + title: string; + description: string; + organization: string; + awardedPoints: number; + availablePoints: number; + exercises: Array; + newExercises: Array; + disabled: boolean; + materialUrl: string | null; + perhapsExamMode: boolean; }; +export type MoocCourseData = { + courseId: string, + instanceId: string, + courseName: string; + instanceName: string | null; + description: string, + awardedPoints: number, + availablePoints: number, + materialUrl: string, +} + export type NewExercise = { - id: number; + id: number; }; export type ExerciseGroup = { - name: string; - exercises: Array; - nextDeadlineString: string; + name: string; + exercises: Array; + nextDeadlineString: string; }; export type Exercise = { - id: number; - name: string; - isHard: boolean; - hardDeadlineString: string; - softDeadlineString: string; - passed: boolean; + id: ExerciseIdentifier; + name: string; + isHard: boolean; + hardDeadlineString: string; + softDeadlineString: string; + passed: boolean; }; export type ExerciseStatus = - | "closed" - | "downloading" - | "downloadFailed" - | "expired" - | "missing" - | "new" - | "opened"; + | "closed" + | "downloading" + | "downloadFailed" + | "expired" + | "missing" + | "new" + | "opened"; export type TestExercise = { - id: number; - availablePoints: number; - awardedPoints: number; - /// Equivalent to exercise slug - name: string; - deadline: string | null; - passed: boolean; - softDeadline: string | null; + id: number; + availablePoints: number; + awardedPoints: number; + /// Equivalent to exercise slug + name: string; + deadline: string | null; + passed: boolean; + softDeadline: string | null; }; export type TestResultData = { - testResult: RunResult; - id: number; - courseSlug: string; - exerciseName: string; - tmcLogs: { - stdout?: string; - stderr?: string; - }; - pasteLink?: string; - disabled?: boolean; + testResult: RunResult; + id: number; + courseSlug: string; + exerciseName: string; + tmcLogs: { + stdout?: string; + stderr?: string; + }; + pasteLink?: string; + disabled?: boolean; }; export type TestCourse = { - id: number; - name: string; - title: string; - description: string; - organization: string; - availablePoints: number; - awardedPoints: number; - perhapsExamMode: boolean; - newExercises: number[]; - notifyAfter: number; - disabled: boolean; - materialUrl: string | null; + id: number; + name: string; + title: string; + description: string; + organization: string; + availablePoints: number; + awardedPoints: number; + perhapsExamMode: boolean; + newExercises: number[]; + notifyAfter: number; + disabled: boolean; + materialUrl: string | null; }; export type FeedbackQuestion = { - id: number; - kind: string; - lower?: number; - upper?: number; - question: string; + id: number; + kind: string; + lower?: number; + upper?: number; + question: string; }; /* @@ -514,12 +590,71 @@ export type FeedbackQuestion = { // target type doesn't have the panel type // works...somehow export type Targeted = Exclude< - M, - { target: { type: Exclude } } + M, + { target: { type: Exclude } } >; export type Broadcast = Omit, "target">; export function assertUnreachable(x: never): never { - throw new Error(`Unreachable ${JSON.stringify(x, null, 2)}`); + throw new Error(`Unreachable ${JSON.stringify(x, null, 2)}`); +} + +type TmcKind = { kind: "tmc" } + +type MoocKind = { kind: "mooc" } + +export type Enum = TmcKind & Tmc + | MoocKind & Mooc + + +export type CourseIdentifier = Enum<{ courseId: number }, { instanceId: string }> + +export type TmcExerciseId = number; +export type MoocExerciseId = string; + +export type ExerciseIdentifier = Enum<{ tmcExerciseId: number }, { moocExerciseId: string }> + +// helper to simulate Rust's `match` +export function match>(data: T, tmc: (x: T & TmcKind) => A, mooc: (x: T & MoocKind) => B): A | B { + switch (data.kind) { + case "tmc": { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return tmc(data as any) + } + case "mooc": { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return mooc(data as any) + } + default: { + assertUnreachable(data) + } + } } + +export function matchOption | undefined>(data: T, tmc: (x: T & TmcKind) => A, mooc: (x: T & MoocKind) => B): A | B | undefined { + switch (data?.kind) { + case "tmc": { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return tmc(data as any) + } + case "mooc": { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return mooc(data as any) + } + case undefined: { + return undefined; + } + default: { + assertUnreachable(data) + } + } +} + +export function makeTmcKind(t: T): T & { kind: "tmc" } { + return { ...t, kind: "tmc" } +} + +export function makeMoocKind(t: T): T & { kind: "mooc" } { + return { ...t, kind: "mooc" } +} \ No newline at end of file diff --git a/src/actions/addNewMoocCourse.ts b/src/actions/addNewMoocCourse.ts new file mode 100644 index 00000000..51fd4fff --- /dev/null +++ b/src/actions/addNewMoocCourse.ts @@ -0,0 +1,37 @@ +import { Result } from "ts-results"; + +import { LocalMoocCourseData } from "../api/storage"; +import { Logger } from "../utilities"; + +import { refreshLocalExercises } from "./refreshLocalExercises"; +import { ActionContext } from "./types"; + +/** + * Adds a new MOOC course to user's courses. + */ +export async function addNewMoocCourse( + actionContext: ActionContext, + courseId: string, + instanceId: string, + courseName: string, + instanceName: string | null, +): Promise> { + const { ui, userData, workspaceManager } = actionContext; + Logger.info("Adding new course"); + + const localData: LocalMoocCourseData = { + courseId, + instanceId, + courseName, + instanceName, + }; + userData.addMoocCourse(localData); + ui.treeDP.addChildWithId("myCourses", localData.courseId, localData.courseName, { + command: "tmc.courseDetails", + title: "Go To Course Details", + arguments: [localData.courseId], + }); + workspaceManager.createWorkspaceFile(`${courseId}_${instanceId}`); + //await displayUserCourses(actionContext); + return refreshLocalExercises(actionContext); +} diff --git a/src/actions/addNewCourse.ts b/src/actions/addNewTmcCourse.ts similarity index 88% rename from src/actions/addNewCourse.ts rename to src/actions/addNewTmcCourse.ts index 832b6c3c..54d7d89e 100644 --- a/src/actions/addNewCourse.ts +++ b/src/actions/addNewTmcCourse.ts @@ -1,6 +1,6 @@ import { Result } from "ts-results"; -import { LocalCourseData } from "../api/storage"; +import { LocalTmcCourseData } from "../api/storage"; import { Logger } from "../utilities"; import { combineApiExerciseData } from "../utilities/apiData"; @@ -8,9 +8,9 @@ import { refreshLocalExercises } from "./refreshLocalExercises"; import { ActionContext } from "./types"; /** - * Adds a new course to user's courses. + * Adds a new TMC course to user's courses. */ -export async function addNewCourse( +export async function addNewTmcCourse( actionContext: ActionContext, organization: string, course: number, @@ -31,7 +31,7 @@ export async function addNewCourse( awardedPoints += x.awarded_points.length; }); - const localData: LocalCourseData = { + const localData: LocalTmcCourseData = { description: courseData.details.description || "", exercises: combineApiExerciseData(courseData.details.exercises, courseData.exercises), id: courseData.details.id, @@ -46,7 +46,7 @@ export async function addNewCourse( disabled: courseData.settings.disabled_status === "enabled" ? false : true, materialUrl: courseData.settings.material_url, }; - userData.addCourse(localData); + userData.addCourse({ kind: "tmc", data: localData }); ui.treeDP.addChildWithId("myCourses", localData.id, localData.title, { command: "tmc.courseDetails", title: "Go To Course Details", diff --git a/src/actions/checkForExerciseUpdates.ts b/src/actions/checkForExerciseUpdates.ts index b74c03ad..144ac77b 100644 --- a/src/actions/checkForExerciseUpdates.ts +++ b/src/actions/checkForExerciseUpdates.ts @@ -1,10 +1,12 @@ import { flatten } from "lodash"; import { Ok, Result } from "ts-results"; +import { assertUnreachable } from "../shared/shared"; import { Logger } from "../utilities"; import { ActionContext } from "./types"; + interface Options { forceRefresh?: boolean; } @@ -17,8 +19,8 @@ interface OutdatedExercise { /** * Checks all user's courses for exercise updates. - * @param courseId If given, check only updates for that course. */ +// todo: mooc export async function checkForExerciseUpdates( actionContext: ActionContext, options?: Options, @@ -34,12 +36,23 @@ export async function checkForExerciseUpdates( const updateableExerciseIds = new Set(checkUpdatesResult.val.map((x) => x.id)); const outdatedExercisesByCourse = userData.getCourses().map((course) => { - const outdatedExercises = course.exercises.filter((x) => updateableExerciseIds.has(x.id)); - return outdatedExercises.map((x) => ({ - courseId: course.id, - exerciseId: x.id, - exerciseName: x.name, - })); + switch (course.kind) { + case "tmc": { + const tmcCourse = course.data; + const outdatedExercises = tmcCourse.exercises.filter((x) => updateableExerciseIds.has(x.id)); + return outdatedExercises.map((x) => ({ + courseId: tmcCourse.id, + exerciseId: x.id, + exerciseName: x.name, + })); + } + case "mooc": { + throw new Error("todo") + } + default: { + assertUnreachable(course) + } + } }); const outdatedExercises = flatten(outdatedExercisesByCourse); Logger.info(`Update check found ${outdatedExercises.length} outdated exercises`); diff --git a/src/actions/downloadNewExercisesForCourse.ts b/src/actions/downloadNewExercisesForTmcCourse.ts similarity index 84% rename from src/actions/downloadNewExercisesForCourse.ts rename to src/actions/downloadNewExercisesForTmcCourse.ts index 35494476..c1161e23 100644 --- a/src/actions/downloadNewExercisesForCourse.ts +++ b/src/actions/downloadNewExercisesForTmcCourse.ts @@ -3,7 +3,7 @@ import { Ok, Result } from "ts-results"; import { TmcPanel } from "../panels/TmcPanel"; import { Logger } from "../utilities"; -import { downloadOrUpdateExercises } from "./downloadOrUpdateExercises"; +import { downloadOrUpdateTmcExercises } from "./downloadOrUpdateTmcExercises"; import { refreshLocalExercises } from "./refreshLocalExercises"; import { ActionContext } from "./types"; @@ -13,12 +13,12 @@ import { ActionContext } from "./types"; * * @param courseId Course to update. */ -export async function downloadNewExercisesForCourse( +export async function downloadNewExercisesForTmcCourse( actionContext: ActionContext, courseId: number, ): Promise> { const { userData } = actionContext; - const course = userData.getCourse(courseId); + const course = userData.getTmcCourse(courseId); Logger.info(`Downloading new exercises for course: ${course.title}`); const postNewExercises = async (exerciseIds: number[]): Promise => @@ -33,7 +33,7 @@ export async function downloadNewExercisesForCourse( postNewExercises([]); - const downloadResult = await downloadOrUpdateExercises(actionContext, course.newExercises); + const downloadResult = await downloadOrUpdateTmcExercises(actionContext, course.newExercises); if (downloadResult.err) { Logger.error("Failed to download new exercises.", downloadResult.val); postNewExercises(course.newExercises); diff --git a/src/actions/downloadOrUpdateExercises.ts b/src/actions/downloadOrUpdateTmcExercises.ts similarity index 95% rename from src/actions/downloadOrUpdateExercises.ts rename to src/actions/downloadOrUpdateTmcExercises.ts index 45b0b06b..11ab3825 100644 --- a/src/actions/downloadOrUpdateExercises.ts +++ b/src/actions/downloadOrUpdateTmcExercises.ts @@ -18,7 +18,7 @@ interface DownloadResults { * @param exerciseIds Exercises to download. * @returns Exercise ids for successful downloads. */ -export async function downloadOrUpdateExercises( +export async function downloadOrUpdateTmcExercises( actionContext: ActionContext, exerciseIds: number[], ): Promise> { @@ -36,7 +36,7 @@ export async function downloadOrUpdateExercises( const downloadResult = await dialog.progressNotification( "Downloading exercises...", (progress) => { - return tmc.downloadExercises(exerciseIds, downloadTemplate, (download) => { + return tmc.downloadTmcExercises(exerciseIds, downloadTemplate, (download) => { progress.report(download); statuses.set(download.id, "closed"); TmcPanel.postMessage(wrapToMessage(download.id, "closed")); diff --git a/src/actions/index.ts b/src/actions/index.ts index 26a3e863..9a015712 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,10 +1,10 @@ -export * from "./addNewCourse"; +export * from "./addNewTmcCourse"; export * from "./checkForExerciseUpdates"; -export * from "./downloadNewExercisesForCourse"; -export * from "./downloadOrUpdateExercises"; +export * from "./downloadNewExercisesForTmcCourse"; +export * from "./downloadOrUpdateTmcExercises"; export * from "./moveExtensionDataPath"; export * from "./refreshLocalExercises"; export * from "./user"; -export * from "./updateCourse"; +export * from "./updateTmcCourse"; export * from "./webview"; export * from "./workspace"; diff --git a/src/actions/refreshLocalExercises.ts b/src/actions/refreshLocalExercises.ts index 885b306d..08f70d3e 100644 --- a/src/actions/refreshLocalExercises.ts +++ b/src/actions/refreshLocalExercises.ts @@ -3,6 +3,7 @@ import { createIs } from "typia"; import * as vscode from "vscode"; import { ExerciseStatus, WorkspaceExercise } from "../api/workspaceManager"; +import { assertUnreachable } from "../shared/shared"; import { Logger } from "../utilities"; import { ActionContext } from "./types"; @@ -18,33 +19,46 @@ export async function refreshLocalExercises( const workspaceExercises: WorkspaceExercise[] = []; for (const course of userData.getCourses()) { - const exercisesResult = await tmc.listLocalCourseExercises(course.name); - if (exercisesResult.err) { - Logger.warn( - `Failed to get exercises for course: ${JSON.stringify(course, null, 2)}`, - exercisesResult.val, - ); - continue; + switch (course.kind) { + case "tmc": { + const tmcCourse = course.data; + const exercisesResult = await tmc.listLocalCourseExercises(tmcCourse.name); + if (exercisesResult.err) { + Logger.warn( + `Failed to get exercises for course: ${JSON.stringify(course, null, 2)}`, + exercisesResult.val, + ); + continue; + } + + const closedExercisesResult = ( + await tmc.getSetting(`closed-exercises-for:${tmcCourse.name}`, createIs()) + ).mapErr((e) => { + Logger.warn("Failed to determine closed status for exercises, defaulting to open.", e); + return []; + }); + + const closedExercises = new Set(closedExercisesResult.val ?? []); + workspaceExercises.push( + ...exercisesResult.val.map((x) => ({ + courseSlug: tmcCourse.name, + exerciseSlug: x["exercise-slug"], + status: closedExercises.has(x["exercise-slug"]) + ? ExerciseStatus.Closed + : ExerciseStatus.Open, + uri: vscode.Uri.file(x["exercise-path"]), + })), + ); + break; + } + case "mooc": { + throw new Error("todo") + } + default: { + assertUnreachable(course) + } } - const closedExercisesResult = ( - await tmc.getSetting(`closed-exercises-for:${course.name}`, createIs()) - ).mapErr((e) => { - Logger.warn("Failed to determine closed status for exercises, defaulting to open.", e); - return []; - }); - - const closedExercises = new Set(closedExercisesResult.val ?? []); - workspaceExercises.push( - ...exercisesResult.val.map((x) => ({ - courseSlug: course.name, - exerciseSlug: x["exercise-slug"], - status: closedExercises.has(x["exercise-slug"]) - ? ExerciseStatus.Closed - : ExerciseStatus.Open, - uri: vscode.Uri.file(x["exercise-path"]), - })), - ); } return workspaceManager.setExercises(workspaceExercises); diff --git a/src/actions/types.ts b/src/actions/types.ts index 443bd7d6..6ef35e40 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -1,6 +1,6 @@ import Dialog from "../api/dialog"; import ExerciseDecorationProvider from "../api/exerciseDecorationProvider"; -import TMC from "../api/tmc"; +import TMC from "../api/langs"; import WorkspaceManager from "../api/workspaceManager"; import Resources from "../config/resources"; import Settings from "../config/settings"; diff --git a/src/actions/updateCourse.ts b/src/actions/updateTmcCourse.ts similarity index 81% rename from src/actions/updateCourse.ts rename to src/actions/updateTmcCourse.ts index 1cc8ea1c..ba169d29 100644 --- a/src/actions/updateCourse.ts +++ b/src/actions/updateTmcCourse.ts @@ -2,6 +2,7 @@ import { Ok, Result } from "ts-results"; import { ConnectionError, ForbiddenError } from "../errors"; import { TmcPanel } from "../panels/TmcPanel"; +import { CourseIdentifier, ExerciseIdentifier } from "../shared/shared"; import { Logger } from "../utilities"; import { combineApiExerciseData } from "../utilities/apiData"; @@ -17,12 +18,12 @@ import { ActionContext } from "./types"; */ export async function updateCourse( actionContext: ActionContext, - courseId: number, + courseId: CourseIdentifier, ): Promise> { const { exerciseDecorationProvider, tmc, userData, workspaceManager } = actionContext; Logger.info("Updating course"); - const postMessage = (courseId: number, disabled: boolean, exerciseIds: number[]): void => { + const postMessage = (courseId: CourseIdentifier, disabled: boolean, exerciseIds: ExerciseIdentifier[]): void => { TmcPanel.postMessage( { type: "setNewExercises", @@ -50,8 +51,8 @@ export async function updateCourse( Logger.warn( `Failed to access information for course ${courseData.name}. Marking as disabled.`, ); - const course = userData.getCourse(courseId); - await userData.updateCourse({ ...course, disabled: true }); + const course = userData.getTmcCourse(courseId); + await userData.updateCourse({ kind: "tmc", data: { ...course, disabled: true } }); postMessage(course.id, true, []); } else { Logger.warn( @@ -75,13 +76,15 @@ export async function updateCourse( ); await userData.updateCourse({ - ...courseData, - availablePoints, - awardedPoints, - description: details.description || "", - disabled: settings.disabled_status !== "enabled", - materialUrl: settings.material_url, - perhapsExamMode: settings.hide_submission_results, + kind: "tmc", data: { + ...courseData, + availablePoints, + awardedPoints, + description: details.description || "", + disabled: settings.disabled_status !== "enabled", + materialUrl: settings.material_url, + perhapsExamMode: settings.hide_submission_results, + } }); const updateExercisesResult = await userData.updateExercises( @@ -101,7 +104,7 @@ export async function updateCourse( // refresh local exercises to ensure deleted exercises don't appear open etc. await refreshLocalExercises(actionContext); - const course = userData.getCourse(courseId); + const course = userData.getTmcCourse(courseId); postMessage(course.id, course.disabled, course.newExercises); return Ok(true); diff --git a/src/actions/user.ts b/src/actions/user.ts index ea25719c..70064b89 100644 --- a/src/actions/user.ts +++ b/src/actions/user.ts @@ -9,8 +9,8 @@ import * as _ from "lodash"; import { Err, Ok, Result } from "ts-results"; import * as vscode from "vscode"; -import { LocalCourseData } from "../api/storage"; -import { WorkspaceExercise } from "../api/workspaceManager"; +import { LocalTmcCourseData } from "../api/storage"; +import { WorkspaceExercise as WorkspaceTmcExercise } from "../api/workspaceManager"; import { EXAM_TEST_RESULT, NOTIFICATION_DELAY } from "../config/constants"; import { BottleneckError } from "../errors"; import { randomPanelId, TmcPanel } from "../panels/TmcPanel"; @@ -18,9 +18,9 @@ import { ExerciseSubmissionPanel, ExerciseTestsPanel, TestResultData } from "../ import { Logger, parseFeedbackQuestion } from "../utilities/"; import { getActiveEditorExecutablePath } from "../window"; -import { downloadNewExercisesForCourse } from "./downloadNewExercisesForCourse"; +import { downloadNewExercisesForTmcCourse } from "./downloadNewExercisesForTmcCourse"; import { ActionContext } from "./types"; -import { updateCourse } from "./updateCourse"; +import { updateCourse } from "./updateTmcCourse"; export const testInterrupts: Map void> = new Map(); @@ -67,11 +67,11 @@ export async function logout(actionContext: ActionContext): Promise> { const { tmc, userData } = actionContext; - const course = userData.getCourseByName(exercise.courseSlug); + const course = userData.getTmcCourseByName(exercise.courseSlug); const courseExercise = course.exercises.find((x) => x.name === exercise.exerciseSlug); if (!courseExercise) { return Err( @@ -152,15 +152,15 @@ export async function testExercise( * Submits an exercise while keeping the user informed * @param tempView Existing TemporaryWebview to use if any */ -export async function submitExercise( +export async function submitTmcExercise( context: vscode.ExtensionContext, actionContext: ActionContext, - exercise: WorkspaceExercise, + exercise: WorkspaceTmcExercise, ): Promise> { const { exerciseDecorationProvider, tmc, userData } = actionContext; Logger.info(`Submitting exercise ${exercise.exerciseSlug} to server`); - const course = userData.getCourseByName(exercise.courseSlug); + const course = userData.getTmcCourseByName(exercise.courseSlug); const courseExercise = course.exercises.find((x) => x.name === exercise.exerciseSlug); if (!courseExercise) { return Err( @@ -230,8 +230,8 @@ export async function submitExercise( questions, }); - const courseData = userData.getCourseByName(exercise.courseSlug) as Readonly; - await checkForCourseUpdates(actionContext, courseData.id); + const courseData = userData.getTmcCourseByName(exercise.courseSlug) as Readonly; + await checkForTmcCourseUpdates(actionContext, courseData.id); vscode.commands.executeCommand("tmc.updateExercises", "silent"); return Ok.EMPTY; @@ -249,7 +249,7 @@ export async function pasteExercise( ): Promise> { const { tmc, userData, workspaceManager } = actionContext; - const exerciseId = userData.getExerciseByName(courseSlug, exerciseName)?.id; + const exerciseId = userData.getTmcExerciseByName(courseSlug, exerciseName)?.id; const exercisePath = workspaceManager.getExerciseBySlug(courseSlug, exerciseName)?.uri.fsPath; if (!exerciseId || !exercisePath) { return Err(new Error("Failed to resolve exercise id")); @@ -273,22 +273,22 @@ export async function pasteExercise( * Check for course updates. * @param courseId If given, check only updates for that course. */ -export async function checkForCourseUpdates( +export async function checkForTmcCourseUpdates( actionContext: ActionContext, courseId?: number, ): Promise { const { dialog, userData } = actionContext; - const courses = courseId ? [userData.getCourse(courseId)] : userData.getCourses(); + const courses = courseId ? [userData.getTmcCourse(courseId)] : userData.getTmcCourses(); const filteredCourses = courses.filter((c) => c.notifyAfter <= Date.now()); Logger.info(`Checking for course updates for courses ${filteredCourses.map((c) => c.name)}`); - const updatedCourses: LocalCourseData[] = []; + const updatedCourses: LocalTmcCourseData[] = []; for (const course of filteredCourses) { await updateCourse(actionContext, course.id); - updatedCourses.push(userData.getCourse(course.id)); + updatedCourses.push(userData.getTmcCourse(course.id)); } - const handleDownload = async (course: LocalCourseData): Promise => { - const downloadResult = await downloadNewExercisesForCourse(actionContext, course.id); + const handleDownload = async (course: LocalTmcCourseData): Promise => { + const downloadResult = await downloadNewExercisesForTmcCourse(actionContext, course.id); if (downloadResult.err) { dialog.errorNotification( `Failed to download new exercises for course "${course.title}."`, @@ -372,7 +372,7 @@ export async function openWorkspace(actionContext: ActionContext, name: string): */ export async function removeCourse(actionContext: ActionContext, id: number): Promise { const { tmc, ui, userData, workspaceManager } = actionContext; - const course = userData.getCourse(id); + const course = userData.getTmcCourse(id); Logger.info(`Closing exercises for ${course.name} and removing course data from userData`); const unsetResult = await tmc.unsetSetting(`closed-exercises-for:${course.name}`); diff --git a/src/actions/webview.ts b/src/actions/webview.ts index 5a3f64c0..be17c744 100644 --- a/src/actions/webview.ts +++ b/src/actions/webview.ts @@ -32,7 +32,7 @@ export async function displayUserCourses( courseDeadlines: {}, }; - const courses = userData.getCourses(); + const courses = userData.getTmcCourses(); const newExercisesCourses: ExtensionToWebview[] = courses.map((c) => ({ type: "setNewExercises", target: panel, @@ -89,7 +89,7 @@ export async function displayLocalCourseDetails( courseId: number, ): Promise { const { userData, workspaceManager } = actionContext; - const course = userData.getCourse(courseId); + const course = userData.getTmcCourse(courseId); Logger.info(`Display course view for ${course.name}`); const mapStatus = ( @@ -155,8 +155,11 @@ export async function displayLocalCourseDetails( const panel: Panel = { type: "CourseDetails", id: randomPanelId(), - courseId: course.id, - exerciseStatuses: {}, + course: { + kind: "tmc", + courseId: course.id, + exerciseStatuses: {}, + } }; TmcPanel.renderMain(context.extensionUri, context, actionContext, panel); diff --git a/src/actions/workspace.ts b/src/actions/workspace.ts index 2dc2fcb7..eed072ca 100644 --- a/src/actions/workspace.ts +++ b/src/actions/workspace.ts @@ -9,7 +9,7 @@ import { Ok, Result } from "ts-results"; import { ExerciseStatus } from "../api/workspaceManager"; import { TmcPanel } from "../panels/TmcPanel"; -import { ExtensionToWebview } from "../shared/shared"; +import { CourseIdentifier, ExerciseIdentifier, ExtensionToWebview } from "../shared/shared"; import { Logger } from "../utilities"; import { ActionContext } from "./types"; @@ -20,14 +20,14 @@ import { ActionContext } from "./types"; */ export async function openExercises( actionContext: ActionContext, - exerciseIdsToOpen: number[], - courseName: string, + exerciseIdsToOpen: ExerciseIdentifier[], + courseId: CourseIdentifier, ): Promise> { Logger.info("Opening exercises", exerciseIdsToOpen); const { workspaceManager, userData, tmc } = actionContext; - const course = userData.getCourseByName(courseName); + const course = userData.getTmcCourse(courseId); const courseExercises = new Map(course.exercises.map((x) => [x.id, x])); const exercisesToOpen = compact(exerciseIdsToOpen.map((x) => courseExercises.get(x))); @@ -71,12 +71,12 @@ export async function openExercises( */ export async function closeExercises( actionContext: ActionContext, - ids: number[], - courseName: string, + ids: Array, + courseId: CourseIdentifier, ): Promise> { const { workspaceManager, userData, tmc } = actionContext; - const course = userData.getCourseByName(courseName); + const course = userData.getTmcCourseByName(courseName); const exercises = new Map(course.exercises.map((x) => [x.id, x])); const exerciseSlugs = compact(ids.map((x) => exercises.get(x)?.name)); diff --git a/src/api/exerciseDecorationProvider.ts b/src/api/exerciseDecorationProvider.ts index 3284f6bf..b5f4da04 100644 --- a/src/api/exerciseDecorationProvider.ts +++ b/src/api/exerciseDecorationProvider.ts @@ -7,8 +7,7 @@ import { UserData } from "../config/userdata"; * Class that adds decorations like completion icons for exercises. */ export default class ExerciseDecorationProvider - implements vscode.Disposable, vscode.FileDecorationProvider -{ + implements vscode.Disposable, vscode.FileDecorationProvider { public onDidChangeFileDecorations: vscode.Event; private static _passedExercise = new vscode.FileDecoration( @@ -54,7 +53,7 @@ export default class ExerciseDecorationProvider return; } - const apiExercise = this.userData.getExerciseByName( + const apiExercise = this.userData.getTmcExerciseByName( exercise.courseSlug, exercise.exerciseSlug, ); diff --git a/src/api/tmc.ts b/src/api/langs.ts similarity index 94% rename from src/api/tmc.ts rename to src/api/langs.ts index 44169d17..d209a8a2 100644 --- a/src/api/tmc.ts +++ b/src/api/langs.ts @@ -7,6 +7,7 @@ import { API_CACHE_LIFETIME, CLI_PROCESS_TIMEOUT, MINIMUM_SUBMISSION_INTERVAL, + MOOC_BACKEND_URL, TMC_BACKEND_URL, } from "../config/constants"; import { @@ -27,6 +28,7 @@ import { CourseData, CourseDetails, CourseExercise, + CourseInstance, DataKind, DownloadOrUpdateCourseExercisesResult, ExerciseDetails, @@ -40,6 +42,7 @@ import { SubmissionFinished, UpdatedExercise, } from "../shared/langsSchema"; +import { assertUnreachable, CourseIdentifier, Enum } from "../shared/shared"; import { Logger } from "../utilities/logger"; import { SubmissionFeedback } from "./types"; @@ -232,6 +235,7 @@ export default class TMC { * @param courseSlug Course which's exercises should be listed. */ public async listLocalCourseExercises( + courseKind: "tmc" | "mooc", courseSlug: string, ): Promise> { const res = await this._executeLangsCommand( @@ -242,6 +246,8 @@ export default class TMC { this.clientName, "--course-slug", courseSlug, + "--course-type", + courseKind, ], }, "local-exercises", @@ -437,7 +443,7 @@ export default class TMC { * @param ids Ids of the exercises to download. * @param downloadTemplate Flag for downloading exercise template instead of latest submission. */ - public async downloadExercises( + public async downloadTmcExercises( ids: number[], downloadTemplate: boolean, onDownloaded: (value: { id: number; percent: number; message?: string }) => void, @@ -538,7 +544,7 @@ export default class TMC { * @returns A combination of getCourseDetails, getCourseExercises, getCourseSettings. */ public async getCourseData( - courseId: number, + courseId: CourseIdentifier, options?: CacheOptions, ): Promise> { const remapper: CacheConfig["remapper"] = (response) => { @@ -865,6 +871,26 @@ export default class TMC { return res.map((r) => r.data["output-data"]); } + public async getEnrolledMoocCourseInstances(): Promise, Error>> { + const res = await this._executeLangsCommand({ args: this._moocCmd("course-instances"), }, "mooc-course-instances"); + return res.map(r => r.data["output-data"]) + } + + /** + * Constructs the base arguments for all `mooc` subcommands. + * + * @param rest The rest of the arguments. + * @returns The complete arguments. + */ + private _moocCmd(...rest: Array): Array { + return [ + "mooc", + "--client-name", + this.clientName, + ].concat(rest); + } + + /** * Constructs the base arguments for all `tmc` subcommands. * @@ -955,14 +981,16 @@ export default class TMC { return Err(new RuntimeError("Langs process crashed.")); } if (langsResponse.result !== "error") { + Logger.debug("langs response", langsResponse.message, JSON.stringify(langsResponse, null, 2)); return Ok(langsResponse); } if (langsResponse.data?.["output-data-kind"] !== "error") { - Logger.error("Unexpected data in error response.", langsResponse); + Logger.error("Unexpected data in error response.", JSON.stringify(langsResponse, null, 2)); return Err(new Error("Unexpected data in error response")); } // after this point, we know we have an error + Logger.error("langs response", JSON.stringify(langsResponse, null, 2)); const data = langsResponse.data; const message = langsResponse.message; const traceString = data["output-data"].trace.join("\n"); @@ -977,15 +1005,21 @@ export default class TMC { this._onLogout?.(); return Err(new InvalidTokenError(message)); case "not-logged-in": - this._responseCache.clear(); - this._onLogout?.(); + // the server has told us that we are not logged in, + // likely because of expired credentials, + // so we'll logout here + this.deauthenticate().then(res => { + if (res.err) { + Logger.error(`Failed to logout properly. ${res.val}`); + } + }); return Err(new AuthorizationError(message, traceString)); case "obsolete-client": return Err( new ObsoleteClientError( message + - "\nYour TMC Extension is out of date, please update it." + - "\nhttps://code.visualstudio.com/docs/editor/extension-gallery", + "\nYour TMC Extension is out of date, please update it." + + "\nhttps://code.visualstudio.com/docs/editor/extension-gallery", traceString, ), ); @@ -1012,11 +1046,13 @@ export default class TMC { .join(" "); // override settings with environment variables, mainly for testing - const tmcLangsBackendUrl = process.env.TMC_LANGS_TMC_ROOT_URL ?? TMC_BACKEND_URL; + const tmcBackendUrl = process.env.TMC_LANGS_TMC_ROOT_URL ?? TMC_BACKEND_URL; + const moocBackendUrl = process.env.TMC_LANGS_MOOC_ROOT_URL ?? MOOC_BACKEND_URL; const tmcLangsConfigDir = process.env.TMC_LANGS_CONFIG_DIR ?? this._options.cliConfigDir; Logger.info(`Running ${loggableCommand}`); - Logger.debug(`Backend at ${tmcLangsBackendUrl}`); + Logger.debug(`TMC backend at ${tmcBackendUrl}`); + Logger.debug(`MOOC backend at ${moocBackendUrl}`); Logger.debug(`Config dir at ${tmcLangsConfigDir}`); let active = true; @@ -1026,7 +1062,8 @@ export default class TMC { ...process.env, ...env, RUST_LOG: "debug,rustls=warn,reqwest=warn", - TMC_LANGS_TMC_ROOT_URL: tmcLangsBackendUrl, + TMC_LANGS_TMC_ROOT_URL: tmcBackendUrl, + TMC_LANGS_MOOC_ROOT_URL: moocBackendUrl, TMC_LANGS_CONFIG_DIR: tmcLangsConfigDir, }, }); diff --git a/src/api/storage.ts b/src/api/storage.ts index 12f98f52..866d3a68 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -1,5 +1,7 @@ import * as vscode from "vscode"; +import { Enum } from "../shared/shared"; + export interface ExtensionSettings { downloadOldSubmission: boolean; hideMetaFiles: boolean; @@ -8,13 +10,15 @@ export interface ExtensionSettings { updateExercisesAutomatically: boolean; } -export interface LocalCourseData { +export type LocalCourseData = Enum; + +export interface LocalTmcCourseData { id: number; name: string; title: string; description: string; organization: string; - exercises: LocalCourseExercise[]; + exercises: LocalTmcCourseExercise[]; availablePoints: number; awardedPoints: number; perhapsExamMode: boolean; @@ -24,7 +28,21 @@ export interface LocalCourseData { materialUrl: string | null; } -export interface LocalCourseExercise { +export interface LocalMoocCourseData { + courseId: string; + instanceId: string; + courseName: string; + instanceName: string | null; + description: string, + awardedPoints: number, + availablePoints: number, + materialUrl: string, + exercises: LocalMoocCourseExercise[]; +} + +export type LocalCourseExercise = Enum; + +export interface LocalTmcCourseExercise { id: number; availablePoints: number; awardedPoints: number; @@ -35,8 +53,13 @@ export interface LocalCourseExercise { softDeadline: string | null; } +export interface LocalMoocCourseExercise { + id: string +} + export interface UserData { - courses: LocalCourseData[]; + courses: LocalTmcCourseData[]; + moocCourses: LocalMoocCourseData[]; } export interface SessionState { @@ -48,7 +71,7 @@ export interface SessionState { */ export default class Storage { private static readonly _extensionSettingsKey = "extension-settings-v1"; - private static readonly _userDataKey = "user-data-v1"; + private static readonly _userDataKey = "user-data-v2"; private static readonly _sessionStateKey = "session-state-v1"; private _context: vscode.ExtensionContext; diff --git a/src/commands/addNewCourse.ts b/src/commands/addNewCourse.ts index c7c4ad42..61d5dd0f 100644 --- a/src/commands/addNewCourse.ts +++ b/src/commands/addNewCourse.ts @@ -33,7 +33,7 @@ export async function addNewCourse(actionContext: ActionContext): Promise return; } - const result = await actions.addNewCourse(actionContext, chosenOrg, chosenCourse); + const result = await actions.addNewTmcCourse(actionContext, chosenOrg, chosenCourse); if (result.err) { dialog.errorNotification("Failed to add course.", result.val); } diff --git a/src/commands/closeExercise.ts b/src/commands/closeExercise.ts index e41b305a..9222268c 100644 --- a/src/commands/closeExercise.ts +++ b/src/commands/closeExercise.ts @@ -19,7 +19,7 @@ export async function closeExercise( return; } - const exerciseId = userData.getExerciseByName(exercise.courseSlug, exercise.exerciseSlug)?.id; + const exerciseId = userData.getTmcExerciseByName(exercise.courseSlug, exercise.exerciseSlug)?.id; if ( exerciseId && (userData.getPassed(exerciseId) || diff --git a/src/commands/downloadNewExercises.ts b/src/commands/downloadNewExercises.ts index 6be5871a..dbf50fe0 100644 --- a/src/commands/downloadNewExercises.ts +++ b/src/commands/downloadNewExercises.ts @@ -6,7 +6,7 @@ export async function downloadNewExercises(actionContext: ActionContext): Promis const { dialog, userData } = actionContext; Logger.info("Downloading new exercises"); - const courses = userData.getCourses(); + const courses = userData.getTmcCourses(); const courseId = await dialog.selectItem( "Download new exercises for course?", ...courses.map<[string, number]>((course) => [course.title, course.id]), @@ -15,16 +15,16 @@ export async function downloadNewExercises(actionContext: ActionContext): Promis return; } - const course = userData.getCourse(courseId); + const course = userData.getTmcCourse(courseId); if (course.newExercises.length === 0) { dialog.notification(`There are no new exercises for the course ${course.title}.`, [ "OK", - (): void => {}, + (): void => { }, ]); return; } - const downloadResult = await actions.downloadNewExercisesForCourse(actionContext, courseId); + const downloadResult = await actions.downloadNewExercisesForTmcCourse(actionContext, courseId); if (downloadResult.err) { dialog.errorNotification( `Failed to download new exercises for course "${course.title}."`, diff --git a/src/commands/downloadOldSubmission.ts b/src/commands/downloadOldSubmission.ts index 995ea2a2..1e44f59c 100644 --- a/src/commands/downloadOldSubmission.ts +++ b/src/commands/downloadOldSubmission.ts @@ -25,7 +25,7 @@ export async function downloadOldSubmission( return; } - const exerciseId = userData.getExerciseByName(exercise.courseSlug, exercise.exerciseSlug)?.id; + const exerciseId = userData.getTmcExerciseByName(exercise.courseSlug, exercise.exerciseSlug)?.id; if (!exerciseId) { dialog.errorNotification("Failed to resolve exercise id."); return; @@ -47,8 +47,8 @@ export async function downloadOldSubmission( exercise.exerciseSlug + ": Select a submission", ...submissionsResult.val.map<[string, OldSubmission]>((a) => [ dateToString(parseDate(a.processing_attempts_started_at)) + - "| " + - (a.all_tests_passed ? "Passed" : "Not passed"), + "| " + + (a.all_tests_passed ? "Passed" : "Not passed"), a, ]), ); diff --git a/src/commands/resetExercise.ts b/src/commands/resetExercise.ts index d4cde824..76022be0 100644 --- a/src/commands/resetExercise.ts +++ b/src/commands/resetExercise.ts @@ -24,7 +24,7 @@ export async function resetExercise( return; } - const exerciseDetails = userData.getExerciseByName(exercise.courseSlug, exercise.exerciseSlug); + const exerciseDetails = userData.getTmcExerciseByName(exercise.courseSlug, exercise.exerciseSlug); if (!exerciseDetails) { dialog.errorNotification(`Missing exercise data for ${exercise.exerciseSlug}.`); return; diff --git a/src/commands/submitExercise.ts b/src/commands/submitExercise.ts index 560d64f6..b19fd706 100644 --- a/src/commands/submitExercise.ts +++ b/src/commands/submitExercise.ts @@ -21,7 +21,7 @@ export async function submitExercise( return; } - const result = await actions.submitExercise(context, actionContext, exercise); + const result = await actions.submitTmcExercise(context, actionContext, exercise); if (result.err) { if (result.val instanceof BottleneckError) { Logger.warn(`Submission was cancelled: ${result.val.message}.`); diff --git a/src/commands/switchWorkspace.ts b/src/commands/switchWorkspace.ts index 01abbf9d..a8b3501b 100644 --- a/src/commands/switchWorkspace.ts +++ b/src/commands/switchWorkspace.ts @@ -2,18 +2,18 @@ import * as vscode from "vscode"; import * as actions from "../actions"; import { ActionContext } from "../actions/types"; -import { LocalCourseData } from "../api/storage"; +import { LocalCourseData, LocalTmcCourseData } from "../api/storage"; import { Logger } from "../utilities"; export async function switchWorkspace(actionContext: ActionContext): Promise { const { dialog, userData } = actionContext; Logger.info("Switching workspace"); - const courses = userData.getCourses(); + const courses = userData.getTmcCourses(); const currentWorkspace = vscode.workspace.name?.split(" ")[0]; const courseWorkspace = await dialog.selectItem( "Select a course workspace to open", - ...courses.map<[string, LocalCourseData]>((c) => [ + ...courses.map<[string, LocalTmcCourseData]>((c) => [ c.name === currentWorkspace ? `${c.name} (Currently open)` : c.name, c, ]), diff --git a/src/commands/updateExercises.ts b/src/commands/updateExercises.ts index d7dfa167..2d867f2b 100644 --- a/src/commands/updateExercises.ts +++ b/src/commands/updateExercises.ts @@ -20,7 +20,7 @@ export async function updateExercises(actionContext: ActionContext, silent: stri const now = Date.now(); const exercisesToUpdate = updateablesResult.val.filter((x) => { - const course = userData.getCourse(x.courseId); + const course = userData.getTmcCourse(x.courseId); return course.notifyAfter <= now && !course.disabled; }); @@ -31,14 +31,14 @@ export async function updateExercises(actionContext: ActionContext, silent: stri const downloadHandler = async (): Promise => { TmcPanel.postMessage( - ...userData.getCourses().map((x) => ({ + ...userData.getTmcCourses().map((x) => ({ type: "setUpdateables", target: { type: "CourseDetails" }, courseId: x.id, exerciseIds: [], })), ); - const downloadResult = await actions.downloadOrUpdateExercises( + const downloadResult = await actions.downloadOrUpdateTmcExercises( actionContext, exercisesToUpdate.map((x) => x.exerciseId), ); @@ -48,7 +48,7 @@ export async function updateExercises(actionContext: ActionContext, silent: stri } TmcPanel.postMessage( - ...userData.getCourses().map((x) => ({ + ...userData.getTmcCourses().map((x) => ({ type: "setUpdateables", target: { type: "CourseDetails" }, courseId: x.id, diff --git a/src/config/constants.ts b/src/config/constants.ts index 5bd56f44..30b845eb 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -4,20 +4,22 @@ // Build time only globals defined in webpack configuration. These values are inlined when // compiling. declare const __DEBUG_MODE__: boolean; -declare const __TMC_BACKEND__URL__: string; +declare const __TMC_BACKEND_URL__: string; declare const __TMC_LANGS_CONFIG_DIR__: string | null; declare const __TMC_LANGS_DL_URL__: string; declare const __TMC_LANGS_VERSION__: string; +declare const __MOOC_BACKEND_URL__: string; // @ts-ignore "No module found" error even though the file exists import FAQ from "../../docs/FAQ.md"; import { TestResultData } from "../shared/shared"; export const DEBUG_MODE = __DEBUG_MODE__; -export const TMC_BACKEND_URL = __TMC_BACKEND__URL__; +export const TMC_BACKEND_URL = __TMC_BACKEND_URL__; export const TMC_LANGS_CONFIG_DIR = __TMC_LANGS_CONFIG_DIR__ || undefined; export const TMC_LANGS_DL_URL = __TMC_LANGS_DL_URL__; export const TMC_LANGS_VERSION = __TMC_LANGS_VERSION__; +export const MOOC_BACKEND_URL = __MOOC_BACKEND_URL__; export const CLIENT_NAME = "vscode_plugin"; export const EXTENSION_ID = "moocfi.test-my-code"; diff --git a/src/config/userdata.ts b/src/config/userdata.ts index d3b6e05b..bacce3e3 100644 --- a/src/config/userdata.ts +++ b/src/config/userdata.ts @@ -1,17 +1,21 @@ import * as _ from "lodash"; import { Err, Ok, Result } from "ts-results"; -import Storage, { LocalCourseData, LocalCourseExercise } from "../api/storage"; +import Storage, { LocalCourseData, LocalMoocCourseData, LocalTmcCourseData, LocalTmcCourseExercise } from "../api/storage"; +import { assertUnreachable, CourseIdentifier, makeMoocKind, makeTmcKind } from "../shared/shared"; import { Logger } from "../utilities/logger"; export class UserData { - private _courses: Map; + private _tmcCourses: Map; + // maps instance ids to course data + private _moocCourses: Map; private _passedExercises: Set = new Set(); private _storage: Storage; constructor(storage: Storage) { const persistentData = storage.getUserData(); if (persistentData) { - this._courses = new Map(persistentData.courses.map((x) => [x.id, x])); + this._tmcCourses = new Map(persistentData.courses.map((x) => [x.id, x])); + this._moocCourses = new Map(persistentData.moocCourses.map((x) => [x.instanceId, x])); persistentData.courses.forEach((x) => x.exercises.forEach((y) => { @@ -21,29 +25,56 @@ export class UserData { }), ); } else { - this._courses = new Map(); + this._tmcCourses = new Map(); + this._moocCourses = new Map(); } this._storage = storage; } public getCourses(): LocalCourseData[] { - return Array.from(this._courses.values()); + const tmc = this.getTmcCourses().map(makeTmcKind); + const mooc = this.getMoocCourses().map(makeMoocKind); + return tmc.concat(mooc); } - public getCourse(id: number): Readonly { - const course = this._courses.get(id); - return course as LocalCourseData; + public getTmcCourses(): LocalTmcCourseData[] { + return Array.from(this._tmcCourses.values()); } - public getCourseByName(name: string): Readonly { - return this.getCourses().filter((x) => x.name === name)[0]; + public getMoocCourses(): LocalMoocCourseData[] { + return Array.from(this._moocCourses.values()); } - public getExerciseByName( + public getCourse(id: CourseIdentifier): Readonly { + switch (id.kind) { + case "tmc": { + const course = this._tmcCourses.get(id.courseId); + return makeTmcKind(course); + } + case "mooc": { + const course = this._moocCourses.get(id.instanceId); + return makeMoocKind(course); + } + default: { + assertUnreachable(id) + } + } + } + + public getTmcCourse(id: number): Readonly { + const course = this._tmcCourses.get(id); + return course as LocalTmcCourseData; + } + + public getTmcCourseByName(name: string): Readonly { + return this.getTmcCourses().filter((x) => x.name === name)[0]; + } + + public getTmcExerciseByName( courseSlug: string, exerciseName: string, - ): Readonly | undefined { - for (const course of this._courses.values()) { + ): Readonly | undefined { + for (const course of this._tmcCourses.values()) { if (course.name === courseSlug) { return course.exercises.find((x) => x.name === exerciseName); } @@ -51,7 +82,7 @@ export class UserData { } public async setExerciseAsPassed(courseSlug: string, exerciseName: string): Promise { - for (const course of this._courses.values()) { + for (const course of this._tmcCourses.values()) { if (course.name === courseSlug) { const exercise = course.exercises.find((x) => x.name === exerciseName); if (exercise) { @@ -64,32 +95,76 @@ export class UserData { } public addCourse(data: LocalCourseData): void { - if (this._courses.has(data.id)) { + switch (data.kind) { + case "tmc": { + const course = data.data; + if (this._tmcCourses.has(course.id)) { + throw new Error("Trying to add an already existing course"); + } + Logger.info(`Adding course ${course.name} to My Courses`); + this._tmcCourses.set(course.id, course); + break; + } + case "mooc": { + const course = data.data; + if (this._moocCourses.has(course.instanceId)) { + throw new Error("Trying to add an already existing course"); + } + Logger.info(`Adding course ${course.courseName} to My Courses`); + this._moocCourses.set(course.instanceId, course); + break; + } + default: { + assertUnreachable(data) + } + } + this._updatePersistentData(); + } + + public addMoocCourse(data: LocalMoocCourseData): void { + if (this._moocCourses.has(data.instanceId)) { throw new Error("Trying to add an already existing course"); } - Logger.info(`Adding course ${data.name} to My Courses`); - this._courses.set(data.id, data); + Logger.info(`Adding course ${data.courseName} to My Courses`); + this._moocCourses.set(data.instanceId, data); this._updatePersistentData(); } public deleteCourse(id: number): void { - this._courses.delete(id); + this._tmcCourses.delete(id); this._updatePersistentData(); } public async updateCourse(data: LocalCourseData): Promise { - if (!this._courses.has(data.id)) { - throw new Error("Trying to fetch course that doesn't exist."); + switch (data.kind) { + case "tmc": { + const course = data.data; + if (!this._tmcCourses.has(course.id)) { + throw new Error("Trying to fetch course that doesn't exist."); + } + this._tmcCourses.set(course.id, course); + break; + } + case "mooc": { + const course = data.data; + if (!this._moocCourses.has(course.instanceId)) { + throw new Error("Trying to fetch course that doesn't exist."); + } + this._moocCourses.set(course.instanceId, course); + break; + } + default: { + assertUnreachable(data) + } } - this._courses.set(data.id, data); await this._updatePersistentData(); } public async updateExercises( courseId: number, - exercises: LocalCourseExercise[], + exercises: LocalTmcCourseExercise[], ): Promise> { - const courseData = this._courses.get(courseId); + const courseData = this._tmcCourses.get(courseId); if (!courseData) { return new Err(new Error("Data missing")); } @@ -104,14 +179,14 @@ export class UserData { ); courseData.newExercises.length > 0 ? Logger.info( - `Found ${courseData.newExercises.length} new exercises for ${courseData.name}`, - ) + `Found ${courseData.newExercises.length} new exercises for ${courseData.name}`, + ) : {}; courseData.exercises = exercises; courseData.exercises.forEach((x) => x.passed ? this._passedExercises.add(x.id) : this._passedExercises.delete(x.id), ); - this._courses.set(courseId, courseData); + this._tmcCourses.set(courseId, courseData); await this._updatePersistentData(); return Ok.EMPTY; } @@ -121,13 +196,13 @@ export class UserData { awardedPoints: number, availablePoints: number, ): Promise> { - const courseData = this._courses.get(courseId); + const courseData = this._tmcCourses.get(courseId); if (!courseData) { return new Err(new Error("Data missing")); } courseData.awardedPoints = awardedPoints; courseData.availablePoints = availablePoints; - this._courses.set(courseId, courseData); + this._tmcCourses.set(courseId, courseData); await this._updatePersistentData(); return Ok.EMPTY; } @@ -148,7 +223,7 @@ export class UserData { courseId: number, exercisesToClear?: number[], ): Promise> { - const courseData = this._courses.get(courseId); + const courseData = this._tmcCourses.get(courseId); if (!courseData) { return new Err(new Error("Data missing")); } @@ -180,7 +255,7 @@ export class UserData { courseId: number, dateInMillis: number, ): Promise> { - const courseData = this._courses.get(courseId); + const courseData = this._tmcCourses.get(courseId); if (!courseData) { return new Err(new Error("Data missing")); } @@ -202,6 +277,9 @@ export class UserData { } private _updatePersistentData(): Promise { - return this._storage.updateUserData({ courses: Array.from(this._courses.values()) }); + return this._storage.updateUserData({ + courses: Array.from(this._tmcCourses.values()), + moocCourses: Array.from(this._moocCourses.values()), + }); } } diff --git a/src/extension.ts b/src/extension.ts index 7a81a6f2..dbe4e30e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,12 +2,12 @@ import * as path from "path"; import { createIs } from "typia"; import * as vscode from "vscode"; -import { checkForCourseUpdates, refreshLocalExercises } from "./actions"; +import { checkForTmcCourseUpdates, refreshLocalExercises } from "./actions"; import { ActionContext } from "./actions/types"; import Dialog from "./api/dialog"; import ExerciseDecorationProvider from "./api/exerciseDecorationProvider"; import Storage from "./api/storage"; -import TMC from "./api/tmc"; +import TMC from "./api/langs"; import WorkspaceManager from "./api/workspaceManager"; import { CLIENT_NAME, @@ -31,7 +31,7 @@ function throwFatalError(error: Error, cliFolder: string): never { if (error instanceof EmptyLangsResponseError) { Logger.error( "The above error may have been caused by an interfering antivirus program. " + - "Please add an exception for the following folder:", + "Please add an exception for the following folder:", cliFolder, ); } @@ -184,7 +184,7 @@ async function activateInner(context: vscode.ExtensionContext): Promise { if (authenticated) { vscode.commands.executeCommand("tmc.updateExercises", "silent"); - checkForCourseUpdates(actionContext); + checkForTmcCourseUpdates(actionContext); } if (maintenanceInterval) { @@ -197,7 +197,7 @@ async function activateInner(context: vscode.ExtensionContext): Promise { Logger.error("Failed to check if authenticated", authenticated.val.message); } else if (authenticated.val) { vscode.commands.executeCommand("tmc.updateExercises", "silent"); - checkForCourseUpdates(actionContext); + checkForTmcCourseUpdates(actionContext); } await vscode.commands.executeCommand( "setContext", diff --git a/src/init/commands.ts b/src/init/commands.ts index 1a1f8ee0..1a6bb8b1 100644 --- a/src/init/commands.ts +++ b/src/init/commands.ts @@ -1,10 +1,11 @@ import * as vscode from "vscode"; import * as actions from "../actions"; -import { checkForCourseUpdates, displayUserCourses, removeCourse } from "../actions"; +import { checkForTmcCourseUpdates, displayUserCourses, removeCourse } from "../actions"; import { ActionContext } from "../actions/types"; import * as commands from "../commands"; import { randomPanelId, TmcPanel } from "../panels/TmcPanel"; +import { assertUnreachable, CourseIdentifier } from "../shared/shared"; import { TmcTreeNode } from "../ui/treeview/treenode"; import { Logger } from "../utilities/"; @@ -31,7 +32,7 @@ export function registerCommands( }, ), vscode.commands.registerCommand("tmcTreeView.refreshCourses", async () => { - await checkForCourseUpdates(actionContext); + await checkForTmcCourseUpdates(actionContext); await commands.updateExercises(actionContext, "loud"); }), ); @@ -58,23 +59,83 @@ export function registerCommands( commands.closeExercise(actionContext, resource), ), - vscode.commands.registerCommand("tmc.courseDetails", async (courseId?: number) => { + vscode.commands.registerCommand("tmc.courseDetails", async (courseId?: CourseIdentifier) => { const courses = userData.getCourses(); if (courses.length === 0) { return; } - courseId = - courseId ?? + let actualId: CourseIdentifier; + if (courseId === undefined) { + const selected = await dialog.selectItem( + "Which course page do you want to open?", + ...courses.map<[string, CourseIdentifier]>((c) => { + switch (c.kind) { + case "tmc": { + return [c.data.title, { kind: "tmc", courseId: c.data.id }] + } + case "mooc": { + return [c.data.courseName, { kind: "mooc", instanceId: c.data.instanceId }] + } + } + }), + ); + if (selected === undefined) { + // user did not select anything + return; + } + actualId = selected; + } else { + actualId = courseId; + } + switch (actualId.kind) { + case "tmc": { + TmcPanel.renderMain(context.extensionUri, context, actionContext, { + id: randomPanelId(), + type: "CourseDetails", + course: { + kind: "tmc", + courseId: actualId.courseId, + exerciseStatuses: {}, + } + }); + break; + } + case "mooc": { + TmcPanel.renderMain(context.extensionUri, context, actionContext, { + id: randomPanelId(), + type: "CourseDetails", + course: { + kind: "mooc", + courseInstanceId: actualId.instanceId, + } + }); + break; + } + default: { + assertUnreachable(actualId) + } + } + }), + + vscode.commands.registerCommand("tmc.moocCourseDetails", async (courseInstanceId?: string) => { + const courses = userData.getMoocCourses(); + if (courses.length === 0) { + return; + } + courseInstanceId = + courseInstanceId ?? (await dialog.selectItem( "Which course page do you want to open?", - ...courses.map<[string, number]>((c) => [c.title, c.id]), + ...courses.map<[string, string]>((c) => [c.courseName, c.instanceId]), )); - if (courseId) { + if (courseInstanceId) { TmcPanel.renderMain(context.extensionUri, context, actionContext, { id: randomPanelId(), type: "CourseDetails", - courseId, - exerciseStatuses: {}, + course: { + kind: "mooc", + courseInstanceId, + } }); } }), diff --git a/src/init/ui.ts b/src/init/ui.ts index df8dddfa..e8645680 100644 --- a/src/init/ui.ts +++ b/src/init/ui.ts @@ -1,10 +1,10 @@ import { Result } from "ts-results"; import * as vscode from "vscode"; -import { downloadOrUpdateExercises, refreshLocalExercises } from "../actions"; +import { downloadOrUpdateTmcExercises, refreshLocalExercises } from "../actions"; import { ActionContext } from "../actions/types"; import { TmcPanel } from "../panels/TmcPanel"; -import { ExtensionToWebview } from "../shared/shared"; +import { assertUnreachable, CourseIdentifier, ExerciseIdentifier, ExtensionToWebview } from "../shared/shared"; import UI from "../ui/ui"; import { Logger } from "../utilities/"; @@ -25,7 +25,7 @@ export function registerUiActions(actionContext: ActionContext): void { arguments: [], }); - const userCourses = actionContext.userData.getCourses(); + const courses = actionContext.userData.getCourses(); ui.treeDP.registerAction( "My Courses", "myCourses", @@ -34,18 +34,40 @@ export function registerUiActions(actionContext: ActionContext): void { command: "tmc.myCourses", title: "Go to My Courses", }, - userCourses.length !== 0 + courses.length !== 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed, - userCourses.map<{ label: string; id: string; command: vscode.Command }>((course) => ({ - label: course.title, - id: course.id.toString(), - command: { - command: "tmc.courseDetails", - title: "Go to course details", - arguments: [course.id], - }, - })), + courses.map<{ label: string; id: string; command: vscode.Command }>((course) => { + switch (course.kind) { + case "tmc": { + const tmcCourse = course.data; + return { + label: tmcCourse.title, + id: tmcCourse.id.toString(), + command: { + command: "tmc.courseDetails", + title: "Go to course details", + arguments: [tmcCourse.id], + }, + } + } + case "mooc": { + const moocCourse = course.data; + return { + label: moocCourse.courseName, + id: moocCourse.instanceId, + command: { + command: "tmc.courseDetails", + title: "Go to course details", + arguments: [moocCourse.instanceId], + }, + } + } + default: { + assertUnreachable(course) + } + } + }), ); ui.treeDP.registerAction("Settings", "settings", [], { @@ -69,8 +91,8 @@ export async function uiDownloadExercises( ui: UI, actionContext: ActionContext, mode: string, - courseId: number, - exerciseIds: number[], + courseId: CourseIdentifier, + exerciseIds: Array, ): Promise { if (mode === "update") { TmcPanel.postMessage({ @@ -78,7 +100,7 @@ export async function uiDownloadExercises( target: { type: "CourseDetails" }, exerciseIds: [], }); - const downloadResult = await downloadOrUpdateExercises(actionContext, exerciseIds); + const downloadResult = await downloadOrUpdateTmcExercises(actionContext, exerciseIds); if (downloadResult.ok) { TmcPanel.postMessage({ type: "setUpdateables", @@ -98,7 +120,7 @@ export async function uiDownloadExercises( exerciseIds: [], }); - const downloadResult = await downloadOrUpdateExercises(actionContext, exerciseIds); + const downloadResult = await downloadOrUpdateTmcExercises(actionContext, exerciseIds); if (downloadResult.err) { actionContext.dialog.errorNotification( "Failed to download new exercises.", @@ -122,7 +144,7 @@ export async function uiDownloadExercises( type: "setNewExercises", target: { type: "MyCourses" }, courseId: courseId, - exerciseIds: actionContext.userData.getCourse(courseId).newExercises, + exerciseIds: actionContext.userData.getTmcCourse(courseId).newExercises, }); const exerciseStatusChangeMessages = exerciseIds.map((id) => { const message: ExtensionToWebview = { diff --git a/src/migrate/index.ts b/src/migrate/index.ts index c40e1298..d7242400 100644 --- a/src/migrate/index.ts +++ b/src/migrate/index.ts @@ -5,8 +5,8 @@ import { Err, Ok, Result } from "ts-results"; import * as vscode from "vscode"; import Dialog from "../api/dialog"; +import TMC from "../api/langs"; import Storage from "../api/storage"; -import TMC from "../api/tmc"; import { WORKSPACE_ROOT_FILE_NAME, WORKSPACE_ROOT_FILE_TEXT, diff --git a/src/migrate/migrateExerciseData.ts b/src/migrate/migrateExerciseData.ts index dbbc811d..4ebbddc0 100644 --- a/src/migrate/migrateExerciseData.ts +++ b/src/migrate/migrateExerciseData.ts @@ -5,13 +5,13 @@ import { createIs } from "typia"; import * as vscode from "vscode"; import Dialog from "../api/dialog"; -import TMC from "../api/tmc"; +import TMC from "../api/langs"; import { Logger } from "../utilities"; import { MigratedData } from "./types"; import validateData from "./validateData"; -const EXERCISE_DATA_KEY_V0 = "exerciseData"; +export const EXERCISE_DATA_KEY_V0 = "exerciseData"; const UNSTABLE_EXTENSION_SETTINGS_KEY = "extensionSettings"; export enum ExerciseStatusV0 { diff --git a/src/migrate/migrateUserData.ts b/src/migrate/migrateUserData.ts index 61b32a99..bbafd4ba 100644 --- a/src/migrate/migrateUserData.ts +++ b/src/migrate/migrateUserData.ts @@ -1,19 +1,24 @@ import { createIs } from "typia"; import * as vscode from "vscode"; -import { LocalCourseData } from "../api/storage"; +import { LocalTmcCourseData, UserData } from "../api/storage"; import { LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER, LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER, } from "../config/constants"; +import { EXERCISE_DATA_KEY_V0 } from "./migrateExerciseData"; import { MigratedData } from "./types"; import validateData from "./validateData"; -const UNSTABLE_EXERCISE_DATA_KEY = "exerciseData"; const USER_DATA_KEY_V0 = "userData"; const USER_DATA_KEY_V1 = "user-data-v1"; +const USER_DATA_KEY_V2 = "user-data-v2"; + +interface UserDataV0 { + courses: LocalCourseDataV0[], +} export interface LocalCourseDataV0 { id: number; @@ -38,6 +43,10 @@ export interface LocalCourseDataV0 { material_url?: string | null; } +interface UserDataV1 { + courses: LocalCourseDataV1[], +} + export interface LocalCourseDataV1 { id: number; name: string; @@ -62,7 +71,8 @@ export interface LocalCourseDataV1 { materialUrl: string | null; } -function courseDataFromV0ToV1( + +function coursesFromV0ToV1( unstableData: LocalCourseDataV0[], memento: vscode.Memento, ): LocalCourseDataV1[] { @@ -73,7 +83,7 @@ function courseDataFromV0ToV1( softDeadline?: string | undefined; } - const localExerciseData = memento.get(UNSTABLE_EXERCISE_DATA_KEY); + const localExerciseData = memento.get(EXERCISE_DATA_KEY_V0); const courseExercises = localExerciseData && new Map(localExerciseData.map((x) => [x.id, x])); return unstableData.map((x) => { @@ -104,7 +114,15 @@ function courseDataFromV0ToV1( }); } -export function resolveMissingFields(localCourseData: LocalCourseDataV1[]): LocalCourseData[] { +function localCourseDataFromV1ToV2(old: UserDataV1): UserData { + const defined = resolveMissingFieldsInV1(old.courses) + return { + courses: defined, + moocCourses: [] + } +} + +export function resolveMissingFieldsInV1(localCourseData: LocalCourseDataV1[]): LocalTmcCourseData[] { return localCourseData.map((course) => { const exercises = course.exercises.map((x) => { const resolvedAwardedPoints = x.passed @@ -120,23 +138,35 @@ export function resolveMissingFields(localCourseData: LocalCourseDataV1[]): Loca }); } +// migrates stored user data to the latest version export default function migrateUserData( memento: vscode.Memento, -): MigratedData<{ courses: LocalCourseData[] }> { - const obsoleteKeys: string[] = []; - const dataV0 = validateData( - memento.get(USER_DATA_KEY_V0), - createIs<{ courses: LocalCourseDataV0[] }>(), - ); - if (dataV0) { - obsoleteKeys.push(USER_DATA_KEY_V0); +): MigratedData { + // check latest version first, no need to do anything if we're already on the latest userdata ver + const userDataV2 = validateData(memento.get(USER_DATA_KEY_V2), createIs()); + if (userDataV2 !== undefined) { + return { data: userDataV2, obsoleteKeys: [] } } - const dataV1 = dataV0 - ? { courses: courseDataFromV0ToV1(dataV0.courses, memento) } - : validateData(memento.get(USER_DATA_KEY_V1), createIs<{ courses: LocalCourseDataV1[] }>()); + // check v1 + const userDataV1 = validateData(memento.get(USER_DATA_KEY_V1), createIs()); + if (userDataV1 !== undefined) { + // migrate from v1 to v2 + const migratedData = localCourseDataFromV1ToV2(userDataV1) + return { data: migratedData, obsoleteKeys: [USER_DATA_KEY_V1] } + } - const data = dataV1 ? { ...dataV1, courses: resolveMissingFields(dataV1?.courses) } : undefined; + // check v0 + const userDataV0 = validateData(memento.get(USER_DATA_KEY_V0), createIs()); + if (userDataV0 !== undefined) { + // migrate from v0 to v1 + const coursesV1 = coursesFromV0ToV1(userDataV0.courses, memento) + const userDataV1 = { courses: coursesV1 } + // migrate from v1 to v2 + const userDataV2 = localCourseDataFromV1ToV2(userDataV1) + return { data: userDataV2, obsoleteKeys: [USER_DATA_KEY_V0] } + } - return { data, obsoleteKeys }; + // no data + return { data: undefined, obsoleteKeys: [] }; } diff --git a/src/panels/TmcPanel.ts b/src/panels/TmcPanel.ts index e3c205db..c01bf80e 100644 --- a/src/panels/TmcPanel.ts +++ b/src/panels/TmcPanel.ts @@ -4,7 +4,7 @@ import { Disposable, Uri, ViewColumn, Webview, WebviewPanel, window } from "vsco import * as vscode from "vscode"; import { - addNewCourse, + addNewTmcCourse, closeExercises, login, openExercises, @@ -14,12 +14,14 @@ import { testInterrupts, updateCourse, } from "../actions"; +import { addNewMoocCourse } from "../actions/addNewMoocCourse"; import { ActionContext } from "../actions/types"; +import { LocalCourseExercise, LocalTmcCourseExercise } from "../api/storage"; import { ExerciseStatus } from "../api/workspaceManager"; import * as commands from "../commands"; import { TMC_BACKEND_URL } from "../config/constants"; import { uiDownloadExercises } from "../init"; -import { ExtensionToWebview, Panel, WebviewToExtension } from "../shared/shared"; +import { Enum, ExerciseGroup, ExerciseIdentifier, ExtensionToWebview, makeMoocKind, makeTmcKind, Panel, WebviewToExtension } from "../shared/shared"; import * as UITypes from "../ui/types"; import { dateToString, @@ -248,116 +250,130 @@ export class TmcPanel { case "requestCourseDetailsData": { const { tmc, userData, workspaceManager } = actionContext; - const course = userData.getCourse(message.sourcePanel.courseId); - postMessageToWebview(webview, { - type: "setCourseData", - target: message.sourcePanel, - courseData: course, - }); + switch (message.sourcePanel.courseId.kind) { + case "tmc": { + const courseId = message.sourcePanel.courseId.courseId - tmc.getCourseDetails(message.sourcePanel.courseId).then((apiCourse) => { - const offlineMode = apiCourse.err; // failed to get course details = offline mode - const exerciseData = new Map< - string, - UITypes.CourseDetailsExerciseGroup - >(); - - const mapStatus = ( - status: ExerciseStatus, - expired: boolean, - ): UITypes.ExerciseStatus => { - switch (status) { - case ExerciseStatus.Closed: - return "closed"; - case ExerciseStatus.Open: - return "opened"; - default: - return expired ? "expired" : "new"; - } - }; - const currentDate = new Date(); - postMessageToWebview(webview, { - type: "setCourseDisabledStatus", - target: message.sourcePanel, - courseId: course.id, - disabled: course.disabled, - }); - course.exercises.forEach((ex) => { - const nameMatch = ex.name.match(/(\w+)-(.+)/); - const groupName = nameMatch?.[1] || ""; - const group = exerciseData.get(groupName); - const name = nameMatch?.[2] || ""; - const exData = workspaceManager.getExerciseBySlug( - course.name, - ex.name, - ); - const softDeadline = ex.softDeadline - ? parseDate(ex.softDeadline) - : null; - const hardDeadline = ex.deadline ? parseDate(ex.deadline) : null; + const course = userData.getTmcCourse(courseId); postMessageToWebview(webview, { - type: "exerciseStatusChange", + type: "setCourseData", target: message.sourcePanel, - exerciseId: ex.id, - status: mapStatus( - exData?.status ?? ExerciseStatus.Missing, - hardDeadline !== null && currentDate >= hardDeadline, - ), + courseData: { kind: "tmc", ...course }, }); - const entry: UITypes.CourseDetailsExercise = { - id: ex.id, - name, - passed: - course.exercises.find((ce) => ce.id === ex.id)?.passed || - false, - softDeadline, - softDeadlineString: softDeadline - ? dateToString(softDeadline) - : "-", - hardDeadline, - hardDeadlineString: hardDeadline - ? dateToString(hardDeadline) - : "-", - isHard: - softDeadline && hardDeadline - ? hardDeadline <= softDeadline - : true, - }; - exerciseData.set(groupName, { - name: groupName, - nextDeadlineString: "", - exercises: group?.exercises.concat(entry) || [entry], - }); - }); - const exerciseGroups = Array.from(exerciseData.values()) - .sort((a, b) => (a.name > b.name ? 1 : -1)) - .map((e) => { - return { - ...e, - exercises: e.exercises.sort((a, b) => - a.name > b.name ? 1 : -1, - ), - nextDeadlineString: offlineMode - ? "Next deadline: Not available" - : parseNextDeadlineAfter( - currentDate, - e.exercises.map((ex) => ({ - date: ex.isHard - ? ex.hardDeadline - : ex.softDeadline, - active: !ex.passed, - })), - ), + tmc.getCourseDetails(courseId).then((apiCourse) => { + const offlineMode = apiCourse.err; // failed to get course details = offline mode + const exerciseGroupData = new Map< + string, + UITypes.CourseDetailsExerciseGroup + >(); + + const mapStatus = ( + status: ExerciseStatus, + expired: boolean, + ): UITypes.ExerciseStatus => { + switch (status) { + case ExerciseStatus.Closed: + return "closed"; + case ExerciseStatus.Open: + return "opened"; + default: + return expired ? "expired" : "new"; + } }; + const currentDate = new Date(); + postMessageToWebview(webview, { + type: "setCourseDisabledStatus", + target: message.sourcePanel, + courseId: { kind: "tmc", courseId: course.id }, + disabled: course.disabled, + }); + course.exercises.forEach((ex) => { + const nameMatch = ex.name.match(/(\w+)-(.+)/); + const groupName = nameMatch?.[1] || ""; + const group = exerciseGroupData.get(groupName); + const name = nameMatch?.[2] || ""; + const exData = workspaceManager.getExerciseBySlug( + course.name, + ex.name, + ); + const softDeadline = ex.softDeadline + ? parseDate(ex.softDeadline) + : null; + const hardDeadline = ex.deadline ? parseDate(ex.deadline) : null; + postMessageToWebview(webview, { + type: "exerciseStatusChange", + target: message.sourcePanel, + exerciseId: { kind: "tmc", tmcExerciseId: ex.id }, + status: mapStatus( + exData?.status ?? ExerciseStatus.Missing, + hardDeadline !== null && currentDate >= hardDeadline, + ), + }); + const entry: UITypes.CourseDetailsExercise = { + id: ex.id, + name, + passed: + course.exercises.find((ce) => ce.id === ex.id)?.passed || + false, + softDeadline, + softDeadlineString: softDeadline + ? dateToString(softDeadline) + : "-", + hardDeadline, + hardDeadlineString: hardDeadline + ? dateToString(hardDeadline) + : "-", + isHard: + softDeadline && hardDeadline + ? hardDeadline <= softDeadline + : true, + }; + + exerciseGroupData.set(groupName, { + name: groupName, + nextDeadlineString: "", + exercises: group?.exercises.concat(entry) || [entry], + }); + }); + const exerciseGroups: Array = Array.from(exerciseGroupData.values()) + .sort((a, b) => (a.name > b.name ? 1 : -1)) + .map((e) => { + return { + name: e.name, + exercises: e.exercises.sort((a, b) => + a.name > b.name ? 1 : -1, + ).map(e => { return { ...e, id: { kind: "tmc", tmcExerciseId: e.id }, } }), + nextDeadlineString: offlineMode + ? "Next deadline: Not available" + : parseNextDeadlineAfter( + currentDate, + e.exercises.map((ex) => ({ + date: ex.isHard + ? ex.hardDeadline + : ex.softDeadline, + active: !ex.passed, + })), + ), + }; + }); + postMessageToWebview(webview, { + type: "setCourseGroups", + target: message.sourcePanel, + offlineMode, + exerciseGroups, + }); }); - postMessageToWebview(webview, { - type: "setCourseGroups", - target: message.sourcePanel, - offlineMode, - exerciseGroups, - }); - }); + break; + } + case "mooc": { + throw new Error("todo") + break; + } + default: { + assertUnreachable(message.sourcePanel.courseId) + } + } break; } case "requestExerciseSubmissionData": { @@ -373,7 +389,7 @@ export class TmcPanel { postMessageToWebview(webview, { type: "setMyCourses", target: message.sourcePanel, - courses: actionContext.userData.getCourses(), + courses: [...actionContext.userData.getTmcCourses().map(makeTmcKind), ...actionContext.userData.getMoocCourses().map(makeMoocKind)], }); postMessageToWebview(webview, { type: "setTmcDataPath", @@ -398,18 +414,30 @@ export class TmcPanel { const organizations = await actionContext.tmc.getOrganizations(); if (organizations.err) { + const error = `Failed to fetch organizations. ${organizations.val}`; actionContext.dialog.errorNotification( - `Failed to open panel: ${organizations.err}`, + error ); + postMessageToWebview(webview, { + type: "requestSelectCourseDataError", + target: message.sourcePanel, + error + }) return; } const organization = organizations.val.find( (o) => o.slug === message.sourcePanel.organizationSlug, ); if (organization === undefined) { + const error = `Failed to find organization not find organization "${message.sourcePanel.organizationSlug}".`; actionContext.dialog.errorNotification( - `Failed to open panel: could not find organization "${message.sourcePanel.organizationSlug}"`, + error ); + postMessageToWebview(webview, { + type: "requestSelectCourseDataError", + target: message.sourcePanel, + error + }) return; } postMessageToWebview(webview, { @@ -420,9 +448,15 @@ export class TmcPanel { const courses = await actionContext.tmc.getCourses(organization.slug); if (courses.err) { + const error = `Failed to fetch organization courses. ${courses.val}`; actionContext.dialog.errorNotification( - `Failed to open panel: ${courses.err}`, + error ); + postMessageToWebview(webview, { + type: "requestSelectCourseDataError", + target: message.sourcePanel, + error + }) return; } postMessageToWebview(webview, { @@ -441,9 +475,15 @@ export class TmcPanel { const organizations = await actionContext.tmc.getOrganizations(); if (organizations.err) { + const error = `Failed fetch organizations. ${organizations.val}`; actionContext.dialog.errorNotification( - `Failed to open panel: ${organizations.err}`, + error ); + postMessageToWebview(webview, { + type: "requestSelectOrganizationDataError", + target: message.sourcePanel, + error + }) return; } postMessageToWebview(webview, { @@ -492,12 +532,21 @@ export class TmcPanel { id: randomPanelId(), type: "CourseDetails", courseId: message.courseId, - exerciseStatuses: {}, + exerciseGroups: [], + exerciseStatuses: { tmc: {}, mooc: {} } }, webview, ); break; } + case "selectPlatform": { + await TmcPanel.renderSide(extensionUri, extensionContext, actionContext, { + id: randomPanelId(), + type: "SelectPlatform", + requestingPanel: message.sourcePanel, + }); + break; + } case "selectOrganization": { await TmcPanel.renderSide(extensionUri, extensionContext, actionContext, { id: randomPanelId(), @@ -507,7 +556,7 @@ export class TmcPanel { break; } case "removeCourse": { - const course = actionContext.userData.getCourse(message.id); + const course = actionContext.userData.getTmcCourse(message.id); if ( await actionContext.dialog.explicitConfirmation( `Do you want to remove ${course.name} from your courses? \ @@ -552,7 +601,7 @@ export class TmcPanel { const result = await closeExercises( actionContext, message.ids, - message.courseName, + message.courseId, ); if (result.err) { actionContext.dialog.errorNotification( @@ -579,17 +628,45 @@ export class TmcPanel { case "openExercises": { // todo: move to actions // download exercises that don't exist locally - const course = actionContext.userData.getCourseByName(message.courseName); - const courseExercises = new Map(course.exercises.map((x) => [x.id, x])); - const exercisesToOpen = compact( - message.ids.map((x) => courseExercises.get(x)), - ); + const course = actionContext.userData.getCourse(message.courseId); + const exercisesToOpen: Array = []; + switch (course.kind) { + case "tmc": { + for (const mid of message.ids) { + const exercise = course.exercises.find((ex) => { + const id: ExerciseIdentifier = { kind: "tmc", tmcExerciseId: ex.id }; + return mid === id; + }); + if (exercise === undefined) { + continue; + } + exercisesToOpen.push(makeTmcKind(exercise)); + } + break; + } + case "mooc": { + for (const mid of message.ids) { + const exercise = course.exercises.find((ex) => { + const id: ExerciseIdentifier = { kind: "mooc", moocExerciseId: ex.id }; + return mid === id; + }); + if (exercise === undefined) { + continue; + } + exercisesToOpen.push(makeMoocKind(exercise)); + } + break; + } + default: { + assertUnreachable(course); + } + }; const localCourseExercises = - await actionContext.tmc.listLocalCourseExercises(message.courseName); + await actionContext.tmc.listLocalCourseExercises(message.courseId); if (localCourseExercises.err) { actionContext.dialog.errorNotification( - `Error trying to list local exercises while opening selected exercises. \ - ${localCourseExercises.val}`, + "Error trying to list local exercises while opening selected exercwises.", + localCourseExercises.val, ); return; } @@ -613,7 +690,7 @@ export class TmcPanel { const result = await openExercises( actionContext, message.ids, - message.courseName, + message.courseId, ); if (result.err) { actionContext.dialog.errorNotification( @@ -637,11 +714,11 @@ export class TmcPanel { break; } case "refreshCourseDetails": { - const courseId: number = message.id; + const courseId = message.id; const updateResult = await updateCourse(actionContext, courseId); if (updateResult.err) { actionContext.dialog.errorNotification( - `Failed to update course: ${updateResult.val.message}`, + "Failed to update course.", updateResult.val, ); } @@ -649,8 +726,9 @@ export class TmcPanel { { id: randomPanelId(), type: "CourseDetails", - courseId: courseId, - exerciseStatuses: {}, + courseId, + exerciseGroups: [], + exerciseStatuses: { tmc: {}, mooc: {} } }, webview, ); @@ -666,14 +744,14 @@ export class TmcPanel { break; } case "addCourse": { - const result = await addNewCourse( + const result = await addNewTmcCourse( actionContext, message.organizationSlug, message.courseId, ); if (result.err) { actionContext.dialog.errorNotification( - `Failed to add new course: ${result.val.message}`, + "Failed to add new course.", result.val, ); } postMessageToWebview(webview, { @@ -734,7 +812,7 @@ export class TmcPanel { ); if (pasteResult.err) { actionContext.dialog.errorNotification( - `Failed to send to TMC Paste: ${pasteResult.val.message}.`, + "Failed to send to TMC Paste.", pasteResult.val, ); TmcPanel.postMessage({ @@ -756,6 +834,55 @@ export class TmcPanel { vscode.env.openExternal(vscode.Uri.parse(message.url)); break; } + case "selectMoocCourse": { + TmcPanel.renderSide(extensionUri, extensionContext, actionContext, { + id: randomPanelId(), + type: "SelectMoocCourse", + requestingPanel: message.sourcePanel, + }); + break; + } + case "requestSelectMoocCourseData": { + const courseInstances = await actionContext.tmc.getEnrolledMoocCourseInstances(); + if (courseInstances.err) { + const error = `Failed to fetch enrolled course instances. ${courseInstances.val}`; + actionContext.dialog.errorNotification( + error + ); + TmcPanel.postMessage({ + type: "requestSelectMoocCourseDataError", + target: message.sourcePanel, + error + }); + return; + } + TmcPanel.postMessage({ + type: "setSelectMoocCourseData", + target: message.sourcePanel, + courseInstances: courseInstances.val + }); + break; + } + case "addMoocCourse": { + const result = await addNewMoocCourse( + actionContext, + message.courseId, + message.instanceId, + message.courseName, + message.instanceName, + ); + if (result.err) { + actionContext.dialog.errorNotification( + "Failed to add new course.", result.val, + ); + } + postMessageToWebview(webview, { + type: "setMyCourses", + target: message.requestingPanel, + courses: actionContext.userData.getCourses(), + }); + break; + } default: assertUnreachable(message); } diff --git a/src/test-integration/tmc_langs_cli.spec.ts b/src/test-integration/tmc_langs_cli.spec.ts index d546894e..d94919e6 100644 --- a/src/test-integration/tmc_langs_cli.spec.ts +++ b/src/test-integration/tmc_langs_cli.spec.ts @@ -7,7 +7,7 @@ import * as path from "path"; import * as kill from "tree-kill"; import { Result } from "ts-results"; -import TMC from "../api/tmc"; +import TMC from "../api/langs"; import { SubmissionFeedback } from "../api/types"; import { CLIENT_NAME, TMC_LANGS_VERSION } from "../config/constants"; import { AuthenticationError, AuthorizationError, BottleneckError, RuntimeError } from "../errors"; @@ -111,13 +111,13 @@ suite("tmc langs cli spec", function () { }); test("should be able to download an existing exercise", async function () { - const result = await tmc.downloadExercises([1], true, () => {}); + const result = await tmc.downloadTmcExercises([1], true, () => { }); result.err && expect.fail(`Expected operation to succeed: ${result.val.message}`); }).timeout(10000); // Ids missing from the server are missing from the response. test.skip("should not be able to download a non-existent exercise", async function () { - const downloads = (await tmc.downloadExercises([404], true, () => {})).unwrap(); + const downloads = (await tmc.downloadTmcExercises([404], true, () => { })).unwrap(); expect(downloads.failed?.length).to.be.equal(1); }); @@ -195,7 +195,7 @@ suite("tmc langs cli spec", function () { setup(async function () { deleteSync(projectsDir, { force: true }); - const result = (await tmc.downloadExercises([1], true, () => {})).unwrap(); + const result = (await tmc.downloadTmcExercises([1], true, () => { })).unwrap(); exercisePath = result.downloaded[0].path; }); @@ -389,7 +389,7 @@ suite("tmc langs cli spec", function () { }); test("should not be able to download an exercise", async function () { - const result = await tmc.downloadExercises([1], true, () => {}); + const result = await tmc.downloadTmcExercises([1], true, () => { }); expect(result.val).to.be.instanceOf(RuntimeError); }); @@ -447,7 +447,7 @@ suite("tmc langs cli spec", function () { setup(async function () { deleteSync(projectsDir, { force: true }); writeCredentials(configDir); - const result = (await tmc.downloadExercises([1], true, () => {})).unwrap(); + const result = (await tmc.downloadTmcExercises([1], true, () => { })).unwrap(); clearCredentials(configDir); exercisePath = result.downloaded[0].path; }); diff --git a/src/test/actions/checkForExerciseUpdates.test.ts b/src/test/actions/checkForExerciseUpdates.test.ts index 4ced47a8..52029797 100644 --- a/src/test/actions/checkForExerciseUpdates.test.ts +++ b/src/test/actions/checkForExerciseUpdates.test.ts @@ -4,7 +4,7 @@ import { IMock, It, Times } from "typemoq"; import { checkForExerciseUpdates } from "../../actions"; import { ActionContext } from "../../actions/types"; -import TMC from "../../api/tmc"; +import TMC from "../../api/langs"; import { UserData } from "../../config/userdata"; import { createMockActionContext } from "../mocks/actionContext"; import { createTMCMock, TMCMockValues } from "../mocks/tmc"; diff --git a/src/test/actions/downloadOrUpdateExercises.test.ts b/src/test/actions/downloadOrUpdateExercises.test.ts index a9bccf26..8c539ea1 100644 --- a/src/test/actions/downloadOrUpdateExercises.test.ts +++ b/src/test/actions/downloadOrUpdateExercises.test.ts @@ -3,10 +3,10 @@ import { first, last } from "lodash"; import { Err, Ok, Result } from "ts-results"; import { IMock, It, Times } from "typemoq"; -import { downloadOrUpdateExercises } from "../../actions"; +import { downloadOrUpdateTmcExercises } from "../../actions"; import { ActionContext } from "../../actions/types"; import Dialog from "../../api/dialog"; -import TMC from "../../api/tmc"; +import TMC from "../../api/langs"; import Settings from "../../config/settings"; import { DownloadOrUpdateCourseExercisesResult, ExerciseDownload } from "../../shared/langsSchema"; import { ExerciseStatus, WebviewMessage } from "../../ui/types"; @@ -71,16 +71,16 @@ suite("downloadOrUpdateExercises action", function () { }); test("should return empty results if no exercises are given", async function () { - const result = (await downloadOrUpdateExercises(actionContext(), [])).unwrap(); + const result = (await downloadOrUpdateTmcExercises(actionContext(), [])).unwrap(); expect(result.successful.length).to.be.equal(0); expect(result.failed.length).to.be.equal(0); }); test("should not call TMC-langs if no exercises are given", async function () { - await downloadOrUpdateExercises(actionContext(), []); + await downloadOrUpdateTmcExercises(actionContext(), []); expect( tmcMock.verify( - (x) => x.downloadExercises(It.isAny(), It.isAny(), It.isAny()), + (x) => x.downloadTmcExercises(It.isAny(), It.isAny(), It.isAny()), Times.never(), ), ); @@ -89,7 +89,7 @@ suite("downloadOrUpdateExercises action", function () { test("should return error if TMC-langs fails", async function () { const error = new Error(); tmcMockValues.downloadExercises = Err(error); - const result = await downloadOrUpdateExercises(actionContext(), [1, 2]); + const result = await downloadOrUpdateTmcExercises(actionContext(), [1, 2]); expect(result.val).to.be.equal(error); }); @@ -99,7 +99,7 @@ suite("downloadOrUpdateExercises action", function () { [], undefined, ); - const result = (await downloadOrUpdateExercises(actionContext(), [1, 2])).unwrap(); + const result = (await downloadOrUpdateTmcExercises(actionContext(), [1, 2])).unwrap(); expect(result.successful).to.be.deep.equal([1, 2]); }); @@ -109,7 +109,7 @@ suite("downloadOrUpdateExercises action", function () { [helloWorld, otherWorld], undefined, ); - const result = (await downloadOrUpdateExercises(actionContext(), [1, 2])).unwrap(); + const result = (await downloadOrUpdateTmcExercises(actionContext(), [1, 2])).unwrap(); expect(result.successful).to.be.deep.equal([1, 2]); }); @@ -119,7 +119,7 @@ suite("downloadOrUpdateExercises action", function () { [otherWorld], undefined, ); - const result = (await downloadOrUpdateExercises(actionContext(), [1])).unwrap(); + const result = (await downloadOrUpdateTmcExercises(actionContext(), [1])).unwrap(); expect(result.successful).to.be.deep.equal([1, 2]); }); @@ -132,20 +132,20 @@ suite("downloadOrUpdateExercises action", function () { [otherWorld, [""]], ], ); - const result = (await downloadOrUpdateExercises(actionContext(), [1, 2])).unwrap(); + const result = (await downloadOrUpdateTmcExercises(actionContext(), [1, 2])).unwrap(); expect(result.failed).to.be.deep.equal([1, 2]); }); test("should download template if downloadOldSubmission setting is off", async function () { tmcMockValues.downloadExercises = createDownloadResult([helloWorld], [], undefined); settingsMockValues.getDownloadOldSubmission = false; - await downloadOrUpdateExercises(actionContext(), [1]); + await downloadOrUpdateTmcExercises(actionContext(), [1]); tmcMock.verify( - (x) => x.downloadExercises(It.isAny(), It.isValue(true), It.isAny()), + (x) => x.downloadTmcExercises(It.isAny(), It.isValue(true), It.isAny()), Times.once(), ); tmcMock.verify( - (x) => x.downloadExercises(It.isAny(), It.isValue(false), It.isAny()), + (x) => x.downloadTmcExercises(It.isAny(), It.isValue(false), It.isAny()), Times.never(), ); }); @@ -153,13 +153,13 @@ suite("downloadOrUpdateExercises action", function () { test("should not necessarily download template if downloadOldSubmission setting is on", async function () { tmcMockValues.downloadExercises = createDownloadResult([helloWorld], [], undefined); settingsMockValues.getDownloadOldSubmission = true; - await downloadOrUpdateExercises(actionContext(), [1]); + await downloadOrUpdateTmcExercises(actionContext(), [1]); tmcMock.verify( - (x) => x.downloadExercises(It.isAny(), It.isValue(true), It.isAny()), + (x) => x.downloadTmcExercises(It.isAny(), It.isValue(true), It.isAny()), Times.never(), ); tmcMock.verify( - (x) => x.downloadExercises(It.isAny(), It.isValue(false), It.isAny()), + (x) => x.downloadTmcExercises(It.isAny(), It.isValue(false), It.isAny()), Times.once(), ); }); @@ -167,13 +167,13 @@ suite("downloadOrUpdateExercises action", function () { test.skip("should post status updates of succeeding download", async function () { tmcMock.reset(); tmcMock - .setup((x) => x.downloadExercises(It.isAny(), It.isAny(), It.isAny())) + .setup((x) => x.downloadTmcExercises(It.isAny(), It.isAny(), It.isAny())) .returns(async (_1, _2, cb) => { // Callback is only used for successful downloads cb({ id: helloWorld.id, percent: 0.5 }); return createDownloadResult([helloWorld], [], undefined); }); - await downloadOrUpdateExercises(actionContext(), [1]); + await downloadOrUpdateTmcExercises(actionContext(), [1]); expect(webviewMessages.length).to.be.greaterThanOrEqual( 2, "expected at least two status messages", @@ -190,7 +190,7 @@ suite("downloadOrUpdateExercises action", function () { test.skip("should post status updates for skipped download", async function () { tmcMockValues.downloadExercises = createDownloadResult([], [helloWorld], undefined); - await downloadOrUpdateExercises(actionContext(), [1]); + await downloadOrUpdateTmcExercises(actionContext(), [1]); expect(webviewMessages.length).to.be.greaterThanOrEqual( 2, "expected at least two status messages", @@ -207,7 +207,7 @@ suite("downloadOrUpdateExercises action", function () { test.skip("should post status updates for failing download", async function () { tmcMockValues.downloadExercises = createDownloadResult([], [], [[helloWorld, [""]]]); - await downloadOrUpdateExercises(actionContext(), [1]); + await downloadOrUpdateTmcExercises(actionContext(), [1]); expect(webviewMessages.length).to.be.greaterThanOrEqual( 2, "expected at least two status messages", @@ -224,7 +224,7 @@ suite("downloadOrUpdateExercises action", function () { test.skip("should post status updates for exercises missing from langs response", async function () { tmcMockValues.downloadExercises = createDownloadResult([], [], undefined); - await downloadOrUpdateExercises(actionContext(), [1]); + await downloadOrUpdateTmcExercises(actionContext(), [1]); expect(webviewMessages.length).to.be.greaterThanOrEqual( 2, "expected at least two status messages", @@ -242,7 +242,7 @@ suite("downloadOrUpdateExercises action", function () { test.skip("should post status updates when TMC-langs operation fails", async function () { const error = new Error(); tmcMockValues.downloadExercises = Err(error); - await downloadOrUpdateExercises(actionContext(), [1]); + await downloadOrUpdateTmcExercises(actionContext(), [1]); expect(webviewMessages.length).to.be.greaterThanOrEqual( 2, "expected at least two status messages", diff --git a/src/test/actions/moveExtensionDataPath.test.ts b/src/test/actions/moveExtensionDataPath.test.ts index 3696215d..e0a36a4c 100644 --- a/src/test/actions/moveExtensionDataPath.test.ts +++ b/src/test/actions/moveExtensionDataPath.test.ts @@ -7,7 +7,7 @@ import * as vscode from "vscode"; import { moveExtensionDataPath } from "../../actions"; import { ActionContext } from "../../actions/types"; -import TMC from "../../api/tmc"; +import TMC from "../../api/langs"; import WorkspaceManager, { ExerciseStatus } from "../../api/workspaceManager"; import { UserData } from "../../config/userdata"; import { workspaceExercises } from "../fixtures/workspaceManager"; diff --git a/src/test/actions/refreshLocalExercises.test.ts b/src/test/actions/refreshLocalExercises.test.ts index c0934a36..dcdc8c5d 100644 --- a/src/test/actions/refreshLocalExercises.test.ts +++ b/src/test/actions/refreshLocalExercises.test.ts @@ -4,7 +4,7 @@ import { IMock, It, Times } from "typemoq"; import { refreshLocalExercises } from "../../actions/refreshLocalExercises"; import { ActionContext } from "../../actions/types"; -import TMC from "../../api/tmc"; +import TMC from "../../api/langs"; import WorkspaceManager from "../../api/workspaceManager"; import { UserData } from "../../config/userdata"; import { createMockActionContext } from "../mocks/actionContext"; diff --git a/src/test/api/exerciseDecorationProvider.test.ts b/src/test/api/exerciseDecorationProvider.test.ts index b2f81338..da00c0d2 100644 --- a/src/test/api/exerciseDecorationProvider.test.ts +++ b/src/test/api/exerciseDecorationProvider.test.ts @@ -79,6 +79,6 @@ suite("ExerciseDecoratorProvider class", function () { const notExercise = vscode.Uri.file("something.txt"); const decoration = exerciseDecorationProvider.provideFileDecoration(notExercise); expect(decoration).to.be.undefined; - userDataMock.verify((x) => x.getExerciseByName(It.isAny(), It.isAny()), Times.never()); + userDataMock.verify((x) => x.getTmcExerciseByName(It.isAny(), It.isAny()), Times.never()); }); }); diff --git a/src/test/commands/cleanExercise.test.ts b/src/test/commands/cleanExercise.test.ts index 4d173081..d7f64214 100644 --- a/src/test/commands/cleanExercise.test.ts +++ b/src/test/commands/cleanExercise.test.ts @@ -3,7 +3,7 @@ import { IMock, It, Times } from "typemoq"; import * as vscode from "vscode"; import { ActionContext } from "../../actions/types"; -import TMC from "../../api/tmc"; +import TMC from "../../api/langs"; import WorkspaceManager, { ExerciseStatus } from "../../api/workspaceManager"; import { cleanExercise } from "../../commands"; import { createMockActionContext } from "../mocks/actionContext"; diff --git a/src/test/fixtures/userData.ts b/src/test/fixtures/userData.ts index cc16b56c..8e87cb2a 100644 --- a/src/test/fixtures/userData.ts +++ b/src/test/fixtures/userData.ts @@ -1,7 +1,7 @@ -import { LocalCourseExercise, UserData } from "../../api/storage"; +import { LocalTmcCourseExercise, UserData } from "../../api/storage"; import { LocalCourseDataV0, LocalCourseDataV1 } from "../../migrate/migrateUserData"; -export const userDataExerciseHelloWorld: LocalCourseExercise = { +export const userDataExerciseHelloWorld: LocalTmcCourseExercise = { id: 1, availablePoints: 1, awardedPoints: 0, diff --git a/src/test/migrate/migrate.test.ts b/src/test/migrate/migrate.test.ts index 48944d89..0064075a 100644 --- a/src/test/migrate/migrate.test.ts +++ b/src/test/migrate/migrate.test.ts @@ -6,7 +6,7 @@ import * as vscode from "vscode"; import Dialog from "../../api/dialog"; import Storage from "../../api/storage"; -import TMC from "../../api/tmc"; +import TMC from "../../api/langs"; import { migrateExtensionDataFromPreviousVersions } from "../../migrate"; import * as exerciseData from "../fixtures/exerciseData"; import * as extensionSettings from "../fixtures/extensionSettings"; diff --git a/src/test/migrate/migrateExerciseData.test.ts b/src/test/migrate/migrateExerciseData.test.ts index 86bb00e4..42e50f62 100644 --- a/src/test/migrate/migrateExerciseData.test.ts +++ b/src/test/migrate/migrateExerciseData.test.ts @@ -4,7 +4,7 @@ import { IMock, It, Times } from "typemoq"; import * as vscode from "vscode"; import Dialog from "../../api/dialog"; -import TMC from "../../api/tmc"; +import TMC from "../../api/langs"; import migrateExerciseData from "../../migrate/migrateExerciseData"; import * as exerciseData from "../fixtures/exerciseData"; import { createDialogMock } from "../mocks/dialog"; diff --git a/src/test/mocks/actionContext.ts b/src/test/mocks/actionContext.ts index c6af4daa..eb57418e 100644 --- a/src/test/mocks/actionContext.ts +++ b/src/test/mocks/actionContext.ts @@ -3,7 +3,7 @@ import { Mock } from "typemoq"; import { ActionContext } from "../../actions/types"; import Dialog from "../../api/dialog"; import ExerciseDecorationProvider from "../../api/exerciseDecorationProvider"; -import TMC from "../../api/tmc"; +import TMC from "../../api/langs"; import WorkspaceManager from "../../api/workspaceManager"; import Resouces from "../../config/resources"; import Settings from "../../config/settings"; diff --git a/src/test/mocks/tmc.ts b/src/test/mocks/tmc.ts index c31f81c0..82649aa5 100644 --- a/src/test/mocks/tmc.ts +++ b/src/test/mocks/tmc.ts @@ -1,7 +1,7 @@ import { Err, Ok, Result } from "ts-results"; import { IMock, It, Mock } from "typemoq"; -import TMC from "../../api/tmc"; +import TMC from "../../api/langs"; import { DownloadOrUpdateCourseExercisesResult, LocalExercise } from "../../shared/langsSchema"; import { checkExerciseUpdates, @@ -103,7 +103,7 @@ function setupMockValues(values: TMCMockValues): IMock { async () => values.checkExerciseUpdates, ); - mock.setup((x) => x.downloadExercises(It.isAny(), It.isAny(), It.isAny())).returns( + mock.setup((x) => x.downloadTmcExercises(It.isAny(), It.isAny(), It.isAny())).returns( async () => values.downloadExercises, ); diff --git a/src/test/mocks/userdata.ts b/src/test/mocks/userdata.ts index 3a1a246f..f97a57b8 100644 --- a/src/test/mocks/userdata.ts +++ b/src/test/mocks/userdata.ts @@ -1,12 +1,12 @@ import { IMock, It, Mock } from "typemoq"; -import { LocalCourseData, LocalCourseExercise } from "../../api/storage"; +import { LocalCourseData, LocalTmcCourseExercise } from "../../api/storage"; import { UserData } from "../../config/userdata"; import { v2_1_0 as userData } from "../fixtures/userData"; export interface UserDataMockValues { getCourses: LocalCourseData[]; - getExerciseByName: Readonly | undefined; + getExerciseByName: Readonly | undefined; } export function createUserDataMock(): [IMock, UserDataMockValues] { @@ -24,7 +24,7 @@ function setupMockValues(values: UserDataMockValues): IMock { mock.setup((x) => x.getCourses()).returns(() => values.getCourses); - mock.setup((x) => x.getExerciseByName(It.isAny(), It.isAny())).returns( + mock.setup((x) => x.getTmcExerciseByName(It.isAny(), It.isAny())).returns( () => values.getExerciseByName, ); diff --git a/src/ui/treeview/treeview.ts b/src/ui/treeview/treeview.ts index ff15d8f5..8844d12d 100644 --- a/src/ui/treeview/treeview.ts +++ b/src/ui/treeview/treeview.ts @@ -70,7 +70,7 @@ export default class TmcMenuTree { */ public addChildWithId( parentId: string, - childId: number, + childId: number | string, title: string, command: vscode.Command, ): void { diff --git a/src/ui/types.ts b/src/ui/types.ts index 5df32074..39120da4 100644 --- a/src/ui/types.ts +++ b/src/ui/types.ts @@ -1,6 +1,6 @@ import { FeedbackQuestion } from "../actions/types"; import Storage, { LocalCourseData } from "../api/storage"; -import TMC from "../api/tmc"; +import TMC from "../api/langs"; import { Course, Organization } from "../api/types"; import { ExtensionSettings } from "../config/settings"; import { SubmissionFinished } from "../shared/langsSchema"; diff --git a/src/utilities/apiData.ts b/src/utilities/apiData.ts index 128f1f9a..e3026cd3 100644 --- a/src/utilities/apiData.ts +++ b/src/utilities/apiData.ts @@ -1,4 +1,4 @@ -import { LocalCourseExercise } from "../api/storage"; +import { LocalTmcCourseExercise } from "../api/storage"; import { LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER, @@ -14,9 +14,9 @@ import { CourseExercise, Exercise } from "../shared/langsSchema"; export function combineApiExerciseData( exercises: Exercise[], courseExercises: CourseExercise[], -): LocalCourseExercise[] { +): LocalTmcCourseExercise[] { const exercisePointsMap = new Map(courseExercises.map((x) => [x.id, x])); - return exercises.map((x) => { + return exercises.map((x) => { const match = exercisePointsMap.get(x.id); const passed = x.completed; const awardedPointsFallback = passed diff --git a/webpack.config.js b/webpack.config.js index 05232371..61a2d24f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -8,7 +8,7 @@ const TerserPlugin = require("terser-webpack-plugin"); const webpack = require("webpack"); const merge = require("webpack-merge").merge; -const { mockBackend, localTMCServer, productionApi } = require("./config"); +const { mockTmcLocalMooc, mockBackend, productionApi } = require("./config"); /**@type {() => import("webpack").Configuration} */ const config = () => { @@ -16,10 +16,10 @@ const config = () => { const apiConfig = (() => { switch (process.env.BACKEND) { + case "mockTmcLocalMooc": + return mockTmcLocalMooc; case "mockBackend": return mockBackend; - case "localTMCServer": - return localTMCServer; default: return productionApi; } @@ -162,7 +162,8 @@ const config = () => { console.log( `Webpack building in ${isDevelopmentMode ? "development" : "production"} configuration.`, ); - console.log(`Configured backend: ${apiConfig.__TMC_BACKEND__URL__}`); + console.log(`Configured TMC backend: ${apiConfig.__TMC_BACKEND_URL__}`); + console.log(`Configured MOOC backend: ${apiConfig.__MOOC_BACKEND_URL__}`); return merge(commonConfig, isDevelopmentMode ? devConfig() : prodConfig()); }; diff --git a/webview-ui/src/App.svelte b/webview-ui/src/App.svelte index cd331275..e9de4d4c 100644 --- a/webview-ui/src/App.svelte +++ b/webview-ui/src/App.svelte @@ -21,6 +21,8 @@ import ExerciseTests from "./panels/ExerciseTests.svelte"; import ExerciseSubmission from "./panels/ExerciseSubmission.svelte"; import { onMount } from "svelte"; + import SelectPlatform from "./panels/SelectPlatform.svelte"; + import SelectMoocCourse from "./panels/SelectMoocCourse.svelte"; onMount(() => { // we shouldn't have any uncaught errors, but if they happen, this will show the user a simple error message @@ -119,6 +121,10 @@ ${ev.reason.stack} {:else if $state.panel.type === "ExerciseSubmission"} + {:else if $state.panel.type === "SelectPlatform"} + + {:else if $state.panel.type === "SelectMoocCourse"} + {:else if $state.panel.type === "App"}
Loading TestMyCode...
{:else} diff --git a/webview-ui/src/components/ExercisePart.svelte b/webview-ui/src/components/ExercisePart.svelte index 1c99a259..1b23cc27 100644 --- a/webview-ui/src/components/ExercisePart.svelte +++ b/webview-ui/src/components/ExercisePart.svelte @@ -1,15 +1,27 @@ @@ -147,17 +223,25 @@ My Courses / - {panel.course?.title ?? "Loading course..."} + {matchOption( + panel.course?.courseData, + (tmc) => tmc.title, + (_mooc) => "todo", + ) ?? "Loading course..."}
{#if panel.course === undefined}

Loading course...

{:else} -

{panel.course.title} ({panel.course.name})

+

{panel.course.title} ({panel.course.slug})

{/if}
- {panel.course?.description ?? "Loading description..."} + {matchOption( + panel.course?.courseData, + (tmc) => tmc.description, + (mooc) => mooc.description, + ) ?? "Loading description..."}
@@ -166,8 +250,8 @@ tabindex="0" class="refresh" aria-label="Refresh" - on:click={() => panel.course !== undefined && refresh(panel.course.id)} - on:keypress={() => panel.course !== undefined && refresh(panel.course.id)} + on:click={() => refresh(panel.courseId)} + on:keypress={() => refresh(panel.courseId)} disabled={$refreshing || $totalDownloading > 0} appearance="secondary" > @@ -196,8 +280,8 @@ role="button" tabindex="0" aria-label="Open workspace" - on:click={() => panel.course !== undefined && openWorkspace(panel.course.name)} - on:keypress={() => panel.course !== undefined && openWorkspace(panel.course.name)} + on:click={() => openWorkspace(panel)} + on:keypress={() => openWorkspace(panel)} > Open workspace @@ -211,8 +295,8 @@ panel.course !== undefined && updateExercises(panel.course)} - on:keypress={() => panel.course !== undefined && updateExercises(panel.course)} + on:click={() => updateExercises(panel)} + on:keypress={() => updateExercises(panel)} > Update exercises @@ -225,7 +309,7 @@ {#if panel.course?.perhapsExamMode}
This is an exam. Exercise submission results will not be shown.
{/if} - {#if panel.disabled} + {#if panel.course?.disabled}
This course has been disabled. Exercises cannot be downloaded or submitted.
@@ -233,18 +317,15 @@
{#if panel.exerciseGroups !== undefined} - {#each panel.exerciseGroups as exerciseGroup} + {#each Object.values(panel.exerciseGroups.tmc).concat(Object.values(panel.exerciseGroups.mooc)) as exerciseGroup}
- panel.course && downloadExercises(panel.course, exercises)} - onOpenAll={(exercises) => - panel.course && openExercises(panel.course.name, exercises)} - onCloseAll={(exercises) => - panel.course && closeExercises(panel.course.name, exercises)} + onDownloadAll={(exercises) => downloadExercises(panel, exercises)} + onOpenAll={(exercises) => openExercises(panel, exercises)} + onCloseAll={(exercises) => closeExercises(panel, exercises)} />
{/each} @@ -263,12 +344,8 @@ role="button" tabindex="0" class="action-bar-button" - on:click={() => - panel.course !== undefined && - downloadExercises(panel.course, getCheckedExercises())} - on:keypress={() => - panel.course !== undefined && - downloadExercises(panel.course, getCheckedExercises())} + on:click={() => downloadExercises(panel, getCheckedExercises())} + on:keypress={() => downloadExercises(panel, getCheckedExercises())} > Download @@ -276,12 +353,8 @@ role="button" tabindex="0" class="action-bar-button" - on:click={() => - panel.course !== undefined && - openExercises(panel.course.name, getCheckedExercises())} - on:keypress={() => - panel.course !== undefined && - openExercises(panel.course.name, getCheckedExercises())} + on:click={() => openExercises(panel, getCheckedExercises())} + on:keypress={() => openExercises(panel, getCheckedExercises())} > Open @@ -289,12 +362,8 @@ role="button" tabindex="0" class="action-bar-button" - on:click={() => - panel.course !== undefined && - closeExercises(panel.course.name, getCheckedExercises())} - on:keypress={() => - panel.course !== undefined && - closeExercises(panel.course.name, getCheckedExercises())} + on:click={() => closeExercises(panel, getCheckedExercises())} + on:keypress={() => closeExercises(panel, getCheckedExercises())} > Close diff --git a/webview-ui/src/panels/Login.svelte b/webview-ui/src/panels/Login.svelte index 9611a790..e25c208a 100644 --- a/webview-ui/src/panels/Login.svelte +++ b/webview-ui/src/panels/Login.svelte @@ -9,6 +9,7 @@ let usernameField: HTMLInputElement; let passwordField: HTMLInputElement; + const errorTimeout = writable(null); const errorMessage = writable(null); const loggingIn = writable(false); @@ -23,9 +24,14 @@ case "loginError": { loggingIn.set(false); errorMessage.set(message.error); - setTimeout(() => { - errorMessage.set(null); - }, 7500); + errorTimeout.update((val) => { + if (val !== null) { + clearTimeout(val); + } + return setTimeout(() => { + errorMessage.set(null); + }, 7500); + }); break; } default: diff --git a/webview-ui/src/panels/MyCourses.svelte b/webview-ui/src/panels/MyCourses.svelte index 6b3701c6..c4e7e073 100644 --- a/webview-ui/src/panels/MyCourses.svelte +++ b/webview-ui/src/panels/MyCourses.svelte @@ -1,5 +1,10 @@ -{#if $organization && $tmcBackendUrl} +{#if $organization !== undefined && $tmcBackendUrl !== undefined}
-{#if $courses !== undefined} +{#if $error !== undefined} +
Error: {$error}
+{:else if $courses !== undefined}
{#each $courses as course}
+ import { writable } from "svelte/store"; + import { SelectMoocCoursePanel, assertUnreachable } from "../shared/shared"; + import { addMessageListener, loadable, postMessageToWebview } from "../utilities/script"; + import { vscode } from "../utilities/vscode"; + import { CourseInstance } from "../shared/langsSchema"; + import TextField from "../components/TextField.svelte"; + import { onMount } from "svelte"; + + export let panel: SelectMoocCoursePanel; + + const instances = loadable>(); + const error = loadable(); + const filter = writable(""); + + onMount(() => { + vscode.postMessage({ + type: "requestSelectMoocCourseData", + sourcePanel: panel, + }); + }); + addMessageListener(panel, (message) => { + switch (message.type) { + case "setSelectMoocCourseData": { + instances.set(message.courseInstances); + break; + } + case "requestSelectMoocCourseDataError": { + error.set(message.error); + break; + } + default: + assertUnreachable(message); + } + }); + + function filterCourses(query: string) { + filter.set(query.toUpperCase()); + } + function selectCourse( + courseId: string, + instanceId: string, + courseName: string, + instanceName: string | null, + ) { + postMessageToWebview({ + type: "selectedMoocCourse", + target: panel.requestingPanel, + courseId, + instanceId, + courseName, + instanceName, + }); + } + + +{#if $error !== undefined} +
Error: {$error}
+{:else} +

Enrolled courses

+
+ filterCourses(val)} /> +
+ + {#if $instances !== undefined} + {#if $instances.length > 0} +
+ {#each $instances as instance} +
+ selectCourse( + instance.course_id, + instance.id, + instance.course_name, + instance.instance_name, + )} + on:keypress={() => + selectCourse( + instance.course_id, + instance.id, + instance.course_name, + instance.instance_name, + )} + hidden={$filter.length > 0 && + !instance.course_name.toUpperCase().includes($filter) && + !instance.instance_name?.toUpperCase().includes($filter)} + > +
+

+ {instance.course_name} + ({instance.instance_name || "default instance"}) + +

+

{instance.course_description}

+ {#if instance.instance_description} +

{instance.instance_description}

+ {/if} +
+
+ {/each} +
+ {:else} +
+ No enrolled courses found that contain TMC exercises. You can enroll on courses at + https://courses.mooc.fi/. +
+ {/if} + {:else} + + {/if} +{/if} + + diff --git a/webview-ui/src/panels/SelectOrganization.svelte b/webview-ui/src/panels/SelectOrganization.svelte index 94fdad49..257672df 100644 --- a/webview-ui/src/panels/SelectOrganization.svelte +++ b/webview-ui/src/panels/SelectOrganization.svelte @@ -6,12 +6,14 @@ import TextField from "../components/TextField.svelte"; import { onMount } from "svelte"; import { vscode } from "../utilities/vscode"; + import { error } from "console"; export let panel: SelectOrganizationPanel; const organizations = loadable>(); const pinned = loadable>(); const tmcBackendUrl = loadable(); + const error = loadable(); const filter = writable(""); onMount(() => { @@ -32,6 +34,10 @@ tmcBackendUrl.set(message.tmcBackendUrl); break; } + case "requestSelectOrganizationDataError": { + error.set(message.error); + break; + } default: assertUnreachable(message); } @@ -54,67 +60,74 @@ } -

Frequently used organizations

-{#if $pinned !== undefined && $tmcBackendUrl !== undefined} - {#each $pinned as pinned} -
selectOrganization(pinned.slug)} - on:keypress={() => selectOrganization(pinned.slug)} - > -
- {`Logo -
-
-

{pinned.name} ({pinned.slug})

-

{pinned.information}

-
-
- {/each} +{#if $error !== undefined} +
Error: {$error}
{:else} - -{/if} +

Frequently used organizations

+ {#if $pinned !== undefined && $tmcBackendUrl !== undefined} + {#each $pinned as pinned} +
selectOrganization(pinned.slug)} + on:keypress={() => selectOrganization(pinned.slug)} + > +
+ {`Logo +
+
+

{pinned.name} ({pinned.slug})

+

{pinned.information}

+
+
+ {/each} + {:else} + + {/if} -

All organizations

-
- filterOrganizations(val)} /> -
+

All organizations

+
+ filterOrganizations(val)} + /> +
-{#if $organizations !== undefined && $tmcBackendUrl !== undefined} - {#each $organizations ?? [] as organization} -
selectOrganization(organization.slug)} - on:keypress={() => selectOrganization(organization.slug)} - hidden={$filter.length > 0 && - !organization.slug.toUpperCase().includes($filter) && - !organization.name.toUpperCase().includes($filter)} - > -
- {`Logo + {#if $organizations !== undefined && $tmcBackendUrl !== undefined} + {#each $organizations ?? [] as organization} +
selectOrganization(organization.slug)} + on:keypress={() => selectOrganization(organization.slug)} + hidden={$filter.length > 0 && + !organization.slug.toUpperCase().includes($filter) && + !organization.name.toUpperCase().includes($filter)} + > +
+ {`Logo +
+
+

+ {organization.name} ({organization.slug}) +

+

{organization.information}

+
-
-

- {organization.name} ({organization.slug}) -

-

{organization.information}

-
-
- {/each} -{:else} - + {/each} + {:else} + + {/if} {/if}