diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..cc4c2805 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,151 @@ +module.exports = { + root: true, + env: { + es6: true, + node: true, + }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:import/errors", + "plugin:import/warnings", + "plugin:import/typescript", + "prettier", + ], + globals: { + Atomics: "readonly", + SharedArrayBuffer: "readonly", + }, + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 6, + sourceType: "module", + }, + plugins: ["@typescript-eslint", "import", "sort-class-members"], + settings: { + "import/core-modules": ["vscode"], + }, + rules: { + "no-unused-vars": "off", + // unused vars are allowed if they start with an underscore + "@typescript-eslint/no-unused-vars": [ + "warn", + { + varsIgnorePattern: "^_", + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + }, + ], + + "@typescript-eslint/ban-ts-comment": ["error", { "ts-ignore": "allow-with-description" }], + "import/no-named-as-default": "off", + curly: "error", + "sort-imports": [ + "error", + { + ignoreCase: true, + ignoreDeclarationSort: true, + }, + ], + "import/order": [ + "error", + { + alphabetize: { + order: "asc", + }, + groups: [["builtin", "external"], "parent", "sibling", "index"], + "newlines-between": "always", + }, + ], + "@typescript-eslint/no-var-requires": "off", + // == disabled default configs from svelte template == + /* + "@typescript-eslint/no-use-before-define": [ + "error", + { + classes: false, + functions: false, + }, + ], + quotes: "off", + "@typescript-eslint/quotes": ["error", "double", { avoidEscape: true }], + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/semi": ["error"], + "@typescript-eslint/lines-between-class-members": [ + "error", + "always", + { exceptAfterSingleLine: true }, + ], + "@typescript-eslint/naming-convention": [ + "error", + { + selector: "function", + format: ["camelCase"], + }, + { + selector: "method", + modifiers: ["public"], + format: ["camelCase"], + }, + { + selector: "method", + modifiers: ["private"], + format: ["camelCase"], + leadingUnderscore: "require", + }, + { + selector: "property", + modifiers: ["private"], + format: ["camelCase"], + leadingUnderscore: "require", + }, + { + selector: "typeLike", + format: ["PascalCase"], + }, + ], + "max-len": [ + "warn", + { + code: 130, + comments: 100, + ignoreComments: false, + }, + ], + eqeqeq: ["error"], + "sort-class-members/sort-class-members": [ + 2, + { + order: [ + "[static-properties]", + "[static-methods]", + "[properties]", + "[conventional-private-properties]", + "constructor", + "[methods]", + "[conventional-private-methods]", + "[everything-else]", + ], + accessorPairPositioning: "getThenSet", + }, + ], + "no-throw-literal": "warn", + semi: "off", + */ + }, + ignorePatterns: ["webview-ui/**"], + overrides: [ + { + files: ["*.ts", "*.tsx"], + rules: { + "@typescript-eslint/explicit-function-return-type": ["error"], + "@typescript-eslint/explicit-module-boundary-types": ["error"], + "@typescript-eslint/no-var-requires": ["error"], + }, + }, + ], +}; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e21b8798..f4f003e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,122 +1,126 @@ -# Contributing - -## Where to begin? - -You can start by looking through the issues marked with label [`good first issue`](https://github.com/rage/tmc-vscode/labels/good%20first%20issue). - -## Project structure - -- `./src`: contains the "backend" of the extension - - `./src/actions`: Contains composable actions used by the VSCode commands and other actions - - `./src/commands`: Contains a source file for each VSCode command contributed by the extension -- `./webview-ui`: contains the "frontend" of the extension -- `./shared`: contains types that are shared between the backend and frontend - -## Setup - -### Prerequisites - -- [Git](https://git-scm.com/) -- [NodeJS / npm](https://nodejs.org/) -- [VSCode](https://code.visualstudio.com/) -- [vsce](https://www.npmjs.com/package/vsce) -- Chromium based browser for Playwright (`npx playwright install chromium`) - -### Getting the code - -```bash -git clone https://github.com/rage/tmc-vscode.git -``` - -### Preparing the repository - -From a terminal, where you have cloned the repository, update the `tmc-python-tester` submodule - -```bash -git submodule init && git submodule update -``` - -Then execute the following command to install the required dependencies: - -```bash -npm run ci:all -``` - -Then prepare the backend: - -```bash -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. - -## 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. - -## Linting - -This project uses [ESLint](https://eslint.org/) for code linting. You can run ESLint across the code by calling `npm run eslint` from a terminal. - -## Developing the extension - -From VSCode, the extension can be launched with `F5` by default. -Automatic build task starts the first time that the extension is launched from VSCode. - -You can also build the extension by running `npm run webpack` or `npm run webpack:watch`. - -## Updating dependencies - -The tmc-langs version can be updated by changing the `TMC_LANGS_RUST_VERSION` variable in `config.js`. - -## Testing - -The tests use a mock backend which needs to be initialised. Run `cd backend && npm run setup` to do so. The tests can be run with `npm run test`. If you get a `Connection error: TypeError`, make sure the backend is running. - -1. `npm run webpack:watch` to keep building the extension while writing code while VSCode is closed. - -2. `npm run backend:start` to start the mock backend used by the tests. - -3. `npm run playwright-test` to run the tests, `npm run playwright-test-debug` to debug the tests. - -Playwright integration tests can be written in the `./playwright` directory. - -The Playwright tests start a new instance of VSCode, meaning if you have VSCode open already the tests will fail due to multiple instances of VSCode. For this reason it's best to use another editor when working on the Playwright tests. - -You can set the environment variable `PW_TEST_REPORT_OPEN` to `never` to prevent constantly opening the HTML test report when working on the tests. - -## Bundling - -To generate a VSIX (installation package) run the following from a terminal: - -``` -vsce package -``` - -## Submitting a Pull Request - -Submit a pull request, and if it fixes problems that have an existing issues on GitHub, tag the issues in the body using "Resolves #issue_id" or "Fixes #issue_id". - -## Releasing - -To release, create a release with the tag in the format `vMAJOR.MINOR.PATCH`, for example `v1.2.3`. For a pre-release version, append `-prerelease` to the tag, for example `v1.2.3-prerelease`. - -A script, `./bin/validateRelease.sh`, is ran during the release process to ensure that - -- the `CHANGELOG.md` has an entry for the tagged version -- the `package.json` and `package-lock.json` has the same version number as the tagged version - -You can update the `package-lock.json` version with `npm i --package-lock-only`. - -You can run the script manually by giving the GitHub release tag you're going to use as an argument. For example `./bin/validateRelease.sh v3.0.0-prerelease`. - -The extension is packaged using the script `./bin/package.bash`. Like the validation script, you should install and test the resulting package manually to ensure there's no problems with the packaging. (You can install the extension from the package by selecting `Extensions: Install from VSIX` from the command palette) (TODO: automatically test the actual package somehow?) - -## Other notes - -Running the extension produces the following superfluous warnings: - -- `An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing.`: https://github.com/microsoft/vscode/issues/192853 -- `[Violation] Avoid using document.write(). `: https://github.com/microsoft/vscode/issues/156147 - -Updating langs can be done by changing the version number at `config.js`. +# Contributing + +## Where to begin? + +You can start by looking through the issues marked with label [`good first issue`](https://github.com/rage/tmc-vscode/labels/good%20first%20issue). + +## Project structure + +- `./src`: contains the "backend" of the extension + - `./src/actions`: Contains composable actions used by the VSCode commands and other actions + - `./src/commands`: Contains a source file for each VSCode command contributed by the extension +- `./webview-ui`: contains the "frontend" of the extension +- `./shared`: contains types that are shared between the backend and frontend + +## Setup + +### Prerequisites + +- [Git](https://git-scm.com/) +- [NodeJS / npm](https://nodejs.org/) +- [VSCode](https://code.visualstudio.com/) +- [vsce](https://www.npmjs.com/package/vsce) +- Chromium based browser for Playwright (`npx playwright install chromium`) + +### Getting the code + +```bash +git clone https://github.com/rage/tmc-vscode.git +``` + +### Preparing the repository + +From a terminal, where you have cloned the repository, update the `tmc-python-tester` submodule + +```bash +git submodule init && git submodule update +``` + +Then execute the following command to install the required dependencies: + +```bash +npm run ci:all +``` + +Then prepare the backend: + +```bash +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. + +## Linting + +This project uses [ESLint](https://eslint.org/) for code linting. You can run ESLint across the code by calling `npm run eslint` from a terminal. + +## Developing the extension + +From VSCode, the extension can be launched with `F5` by default. +Automatic build task starts the first time that the extension is launched from VSCode. + +You can also build the extension by running `npm run webpack` or `npm run webpack:watch`. + +## Updating dependencies + +The tmc-langs version can be updated by changing the `TMC_LANGS_RUST_VERSION` variable in `config.js`. + +## Testing + +The tests use a mock backend which needs to be initialised. Run `cd backend && npm run setup` to do so. The tests can be run with `npm run test`. If you get a `Connection error: TypeError`, make sure the backend is running. + +1. `npm run webpack:watch` to keep building the extension while writing code while VSCode is closed. + +2. `npm run backend:start` to start the mock backend used by the tests. + +3. `npm run playwright-test` to run the tests, `npm run playwright-test-debug` to debug the tests. + +Playwright integration tests can be written in the `./playwright` directory. + +The Playwright tests start a new instance of VSCode, meaning if you have VSCode open already the tests will fail due to multiple instances of VSCode. For this reason it's best to use another editor when working on the Playwright tests. + +You can set the environment variable `PW_TEST_REPORT_OPEN` to `never` to prevent constantly opening the HTML test report when working on the tests. + +## Bundling + +To generate a VSIX (installation package) run the following from a terminal: + +``` +vsce package +``` + +## Submitting a Pull Request + +Submit a pull request, and if it fixes problems that have an existing issues on GitHub, tag the issues in the body using "Resolves #issue_id" or "Fixes #issue_id". + +## Releasing + +To release, create a release with the tag in the format `vMAJOR.MINOR.PATCH`, for example `v1.2.3`. For a pre-release version, append `-prerelease` to the tag, for example `v1.2.3-prerelease`. + +A script, `./bin/validateRelease.sh`, is ran during the release process to ensure that + +- the `CHANGELOG.md` has an entry for the tagged version +- the `package.json` and `package-lock.json` has the same version number as the tagged version + +You can update the `package-lock.json` version with `npm i --package-lock-only`. + +You can run the script manually by giving the GitHub release tag you're going to use as an argument. For example `./bin/validateRelease.sh v3.0.0-prerelease`. + +The extension is packaged using the script `./bin/package.bash`. Like the validation script, you should install and test the resulting package manually to ensure there's no problems with the packaging. (You can install the extension from the package by selecting `Extensions: Install from VSIX` from the command palette) (TODO: automatically test the actual package somehow?) + +## Other notes + +Running the extension produces the following superfluous warnings: + +- `An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing.`: https://github.com/microsoft/vscode/issues/192853 +- `[Violation] Avoid using document.write(). `: https://github.com/microsoft/vscode/issues/156147 + +Updating langs can be done by changing the version number at `config.js`. diff --git a/package.json b/package.json index 656de4e8..ba10a839 100644 --- a/package.json +++ b/package.json @@ -194,11 +194,6 @@ "dark": "resources/dark/refresh.svg" } }, - { - "command": "tmcTreeView.removeCourse", - "title": "Remove Course", - "category": "TMC" - }, { "command": "tmcView.activateEntry", "title": "Activate", @@ -347,10 +342,6 @@ "command": "tmcTreeView.refreshCourses", "when": "false" }, - { - "command": "tmcTreeView.removeCourse", - "when": "false" - }, { "command": "tmcView.activateEntry", "when": "false" @@ -415,12 +406,6 @@ "when": "view == tmcView && test-my-code:LoggedIn", "group": "navigation" } - ], - "view/item/context": [ - { - "command": "tmcTreeView.removeCourse", - "when": "view == tmcView && viewItem == child" - } ] }, "views": { diff --git a/shared/langsSchema.ts b/shared/langsSchema.ts index 9ab1a509..5c3e8428 100644 --- a/shared/langsSchema.ts +++ b/shared/langsSchema.ts @@ -1,6 +1,3 @@ -// VERSION=0.38.0 -// https://raw.githubusercontent.com/rage/tmc-langs-rust/0.38.0/crates/tmc-langs-cli/bindings.d.ts - export type Locale = string; export type CliOutput = @@ -17,16 +14,10 @@ export type DataKind = "output-data-kind": "exercise-packaging-configuration"; "output-data": ExercisePackagingConfiguration; } - | { "output-data-kind": "local-tmc-exercises"; "output-data": Array } - | { "output-data-kind": "local-mooc-exercises"; "output-data": Array } | { "output-data-kind": "refresh-result"; "output-data": RefreshData } | { "output-data-kind": "test-result"; "output-data": RunResult } | { "output-data-kind": "exercise-desc"; "output-data": ExerciseDesc } | { "output-data-kind": "updated-exercises"; "output-data": Array } - | { - "output-data-kind": "tmc-exercise-download"; - "output-data": DownloadOrUpdateTmcCourseExercisesResult; - } | { "output-data-kind": "mooc-exercise-download"; "output-data": DownloadOrUpdateMoocCourseExercisesResult; @@ -50,9 +41,17 @@ export type DataKind = } | { "output-data-kind": "submission-finished"; "output-data": SubmissionFinished } | { "output-data-kind": "config-value"; "output-data": ConfigValue } - | { "output-data-kind": "tmc-config"; "output-data": TmcConfig } | { "output-data-kind": "compressed-project-hash"; "output-data": string } | { "output-data-kind": "submission-sandbox"; "output-data": string } + | { "output-data-kind": "local-tmc-exercises"; "output-data": Array } + | { + "output-data-kind": "tmc-exercise-download"; + "output-data": DownloadOrUpdateTmcCourseExercisesResult; + } + | { "output-data-kind": "tmc-config"; "output-data": TmcConfig } + | { "output-data-kind": "mooc-updated-exercises"; "output-data": Array } + | { "output-data-kind": "local-mooc-exercises"; "output-data": Array } + | { "output-data-kind": "mooc-course-instance"; "output-data": CourseInstance } | { "output-data-kind": "mooc-course-instances"; "output-data": Array } | { "output-data-kind": "mooc-exercise-slides"; "output-data": Array } | { "output-data-kind": "mooc-exercise-slide"; "output-data": TmcExerciseSlide } @@ -276,7 +275,7 @@ export type TmcExerciseDownload = { path: string; }; -export type MoocExerciseDownload = { id: string; path: string }; +export type MoocExerciseDownload = { "task-id": string; path: string }; export type CombinedCourseData = { details: CourseDetails; @@ -630,6 +629,7 @@ export type TmcExerciseTask = { assignment: unknown; public_spec: PublicSpec | null; model_solution_spec: ModelSolutionSpec | null; + checksum: string; }; export type PublicSpec = diff --git a/shared/lib.ts b/shared/lib.ts index 23301b77..2998b8a1 100644 --- a/shared/lib.ts +++ b/shared/lib.ts @@ -7,6 +7,7 @@ import * as util from "node:util"; import { Course, + CourseInstance, Organization, RunResult, StyleValidationResult, @@ -14,6 +15,138 @@ import { } from "./langsSchema"; import { createIs } from "typia"; + +export interface LocalTmcCourseData { + id: number; + name: string; + title: string; + description: string; + organization: string; + exercises: LocalTmcCourseExercise[]; + availablePoints: number; + awardedPoints: number; + perhapsExamMode: boolean; + newExercises: number[]; + notifyAfter: number; + disabled: boolean; + materialUrl: string | null; +} + +export interface LocalMoocCourseData { + courseId: string; + instanceId: string; + courseName: string; + instanceName: string | null; + courseDescription: string | null; + instanceDescription: string | null; + awardedPoints: number; + availablePoints: number; + disabled: boolean; + materialUrl: string | null; + exercises: LocalMoocCourseExercise[]; + newExercises: string[]; + notifyAfter: number; + perhapsExamMode: boolean; +} + +export interface LocalTmcCourseExercise { + id: number; + availablePoints: number; + awardedPoints: number; + /// Equivalent to exercise slug + name: string; + deadline: string | null; + passed: boolean; + softDeadline: string | null; +} + +export interface LocalMoocCourseExercise { + id: string; + slug: string; + deadline: string | null; + passed: boolean; + softDeadline: string | null; +} + +export type LocalCourseExercise = Enum; + +export namespace LocalCourseExercise { + export function getSlug(lce: LocalCourseExercise): string { + return match( + lce, + (tmc) => tmc.name, + (mooc) => mooc.slug, + ); + } + + export function unwrap( + lce: LocalCourseExercise, + ): LocalTmcCourseExercise | LocalMoocCourseExercise { + return match( + lce, + (tmc) => tmc, + (mooc) => mooc, + ); + } + + export function getId(lce: LocalCourseExercise): ExerciseIdentifier { + const id = match( + lce, + (tmc) => tmc.id, + (mooc) => mooc.id, + ); + return ExerciseIdentifier.from(id); + } +} + +export type LocalCourseData = Enum; + +export namespace LocalCourseData { + export function getCourseId(lcd: LocalCourseData): CourseIdentifier { + return match( + lcd, + (tmc) => makeTmcKind({ courseId: tmc.id }), + (mooc) => makeMoocKind({ instanceId: mooc.courseId }), + ); + } + + export function getCourseName(lcd: LocalCourseData): string { + return match( + lcd, + (tmc) => tmc.name, + (mooc) => mooc.courseName, + ); + } + + export function getNewExercises(lcd: LocalCourseData): Array { + return match( + lcd, + (tmc) => tmc.newExercises.map((neid) => makeTmcKind({ tmcExerciseId: neid })), + (mooc) => mooc.newExercises.map((neid) => makeMoocKind({ moocExerciseId: neid })), + ); + } + + export function getExercises(lcd: LocalCourseData): Array { + return match( + lcd, + (tmc) => tmc.exercises.map(makeTmcKind), + (mooc) => mooc.exercises.map(makeMoocKind), + ); + } +} + +export function getCourseExercises( + course: LocalCourseData, +): Enum, Array> { + // doesn't work without an intermediate variable........ + const ret = match( + course, + (tmc) => makeTmcKind(tmc.exercises), + (mooc) => makeMoocKind(mooc.exercises), + ); + return ret; +} + /** * Contains the state of the webview. */ @@ -40,6 +173,8 @@ export type Panel = | SelectCoursePanel | ExerciseTestsPanel | ExerciseSubmissionPanel + | SelectPlatformPanel + | SelectMoocCoursePanel | InitializationErrorHelpPanel; export type PanelType = Panel["type"]; @@ -73,6 +208,7 @@ export type MyCoursesPanel = { id: number; type: "MyCourses"; courses?: Array; + moocCourses?: Array; tmcDataPath?: string; tmcDataSize?: string; courseDeadlines: Record; @@ -81,13 +217,15 @@ export type MyCoursesPanel = { export type CourseDetailsPanel = { id: number; type: "CourseDetails"; - courseId: number; - course?: CourseData; + courseId: CourseIdentifier; + course?: LocalCourseData; offlineMode?: boolean; - exerciseGroups?: Array; - updateableExercises?: Array; - disabled?: boolean; - exerciseStatuses?: Record; + updateableExercises?: Array; + exerciseGroups: Array; + exerciseStatuses: { + tmc: Record; + mooc: Record; + }; }; export type SelectOrganizationPanel = { @@ -108,8 +246,8 @@ export type SelectCoursePanel = { export type ExerciseTestsPanel = { id: number; type: "ExerciseTests"; - course: TestCourse; - exercise: TestExercise; + course: LocalCourseData; + exercise: LocalCourseExercise; exerciseUri: Uri; testRunId: number; }; @@ -117,8 +255,8 @@ export type ExerciseTestsPanel = { export type ExerciseSubmissionPanel = { id: number; type: "ExerciseSubmission"; - course: TestCourse; - exercise: TestExercise; + course: LocalCourseData; + exercise: LocalCourseExercise; }; export type InitializationErrorHelpPanel = { @@ -126,6 +264,18 @@ export type InitializationErrorHelpPanel = { type: "InitializationErrorHelp"; }; +export type SelectPlatformPanel = { + id: number; + type: "SelectPlatform"; + requestingPanel: TargetPanel; +}; + +export type SelectMoocCoursePanel = { + id: number; + type: "SelectMoocCourse"; + requestingPanel: TargetPanel; +}; + /* * ======== messages to webview ======== */ @@ -148,7 +298,7 @@ export type ExtensionToWebview = | { type: "setMyCourses"; target: TargetPanel; - courses: Array; + courses: Array; } | { type: "setTmcDataPath"; @@ -158,7 +308,7 @@ export type ExtensionToWebview = | { type: "setNextCourseDeadline"; target: TargetPanel; - courseId: number; + courseId: CourseIdentifier; deadline: string; } | { @@ -174,7 +324,7 @@ export type ExtensionToWebview = | { type: "setCourseData"; target: TargetPanel; - courseData: CourseData; + courseData: LocalCourseData; } | { type: "setCourseGroups"; @@ -185,19 +335,19 @@ export type ExtensionToWebview = | { type: "setCourseDisabledStatus"; target: BroadcastPanel; - courseId: number; + courseId: CourseIdentifier; disabled: boolean; } | { type: "exerciseStatusChange"; target: BroadcastPanel; - exerciseId: number; + exerciseId: ExerciseIdentifier; status: ExerciseStatus; } | { type: "setUpdateables"; target: BroadcastPanel; - exerciseIds: Array; + exerciseIds: Array; } | { type: "setOrganizations"; @@ -264,13 +414,33 @@ export type ExtensionToWebview = | { type: "setNewExercises"; target: BroadcastPanel; - courseId: number; - exerciseIds: Array; + 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; + } | { type: "initializationErrors"; target: TargetPanel; @@ -288,7 +458,7 @@ export type ExtensionToWebview = // they only had one... | { type: never; - target: TargetPanel; + target: never; }; // helper type for messages from the extension to a specific panel @@ -350,7 +520,7 @@ export type WebviewToExtension = } | { type: "removeCourse"; - id: number; + id: CourseIdentifier; } | { type: "openCourseWorkspace"; @@ -358,44 +528,42 @@ export type WebviewToExtension = } | { type: "downloadExercises"; - ids: Array; - courseName: string; - organizationSlug: string; - courseId: number; + ids: Array; + courseId: CourseIdentifier; mode: "download" | "update"; } | { type: "clearNewExercises"; - courseId: number; + courseId: CourseIdentifier; } | { type: "changeTmcDataPath"; } | { type: "openCourseDetails"; - courseId: number; + courseId: CourseIdentifier; } | { type: "openMyCourses"; } | { type: "refreshCourseDetails"; - id: number; + id: CourseIdentifier; useCache: boolean; } | { type: "openExercises"; - ids: Array; - courseName: string; + ids: Array; + courseId: CourseIdentifier; } | { type: "closeExercises"; - ids: Array; - courseName: string; + ids: Array; + courseId: CourseIdentifier; } | { type: "refreshCourseDetails"; - id: number; + id: CourseIdentifier; useCache: boolean; } | { @@ -406,7 +574,7 @@ export type WebviewToExtension = | { type: "addCourse"; organizationSlug: string; - courseId: number; + courseId: CourseIdentifier; requestingPanel: TargetPanel; } | { @@ -423,14 +591,14 @@ export type WebviewToExtension = } | { type: "submitExercise"; - course: TestCourse; - exercise: TestExercise; + course: LocalCourseData; + exercise: LocalCourseExercise; exerciseUri: Uri; } | { type: "pasteExercise"; - course: TestCourse; - exercise: TestExercise; + course: LocalCourseData; + exercise: LocalCourseExercise; requestingPanel: TargetPanel; } | { @@ -440,13 +608,36 @@ export type WebviewToExtension = | { type: "requestInitializationErrors"; sourcePanel: InitializationErrorHelpPanel; + } + | { + type: "selectPlatform"; + sourcePanel: TargetPanel; + } + | { + type: "selectMoocCourse"; + sourcePanel: TargetPanel; + } + | { + type: "requestSelectMoocCourseData"; + sourcePanel: TargetPanel; + } + | { + type: "addMoocCourse"; + organizationSlug: string; + courseId: string; + instanceId: string; + courseName: string; + instanceName: string | null; + requestingPanel: TargetPanel; }; /* * ======== additional types ======== */ -export type CourseData = { +export type CourseData = Enum; + +export type TmcCourseData = { id: number; name: string; title: string; @@ -461,6 +652,17 @@ export type CourseData = { 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; }; @@ -472,7 +674,7 @@ export type ExerciseGroup = { }; export type Exercise = { - id: number; + id: ExerciseIdentifier; name: string; isHard: boolean; hardDeadlineString: string; @@ -502,7 +704,7 @@ export type TestExercise = { export type TestResultData = { testResult: RunResult; - id: number; + id: ExerciseIdentifier; courseSlug: string; exerciseName: string; tmcLogs: { @@ -515,7 +717,7 @@ export type TestResultData = { }; export type TestCourse = { - id: number; + id: CourseIdentifier; name: string; title: string; description: string; @@ -662,3 +864,149 @@ export class BaseError extends Error { return errorMessage; } } + +type TmcKind = { kind: "tmc" }; + +type MoocKind = { kind: "mooc" }; + +export type Enum = { kind: "tmc"; data: Tmc } | { kind: "mooc"; data: Mooc }; + +export namespace Enum { + export function unwrap(e: Enum): A | B { + return match( + e, + (e) => e, + (e) => e, + ); + } +} + +export type CourseIdentifier = Enum<{ courseId: number }, { instanceId: string }>; + +export namespace CourseIdentifier { + export function from(id: number | string): CourseIdentifier { + if (typeof id === "number") { + return makeTmcKind({ courseId: id }); + } else if (typeof id === "string") { + return makeMoocKind({ instanceId: id }); + } else { + assertUnreachable(id); + } + } + + export function toString(id: CourseIdentifier): string { + return match( + id, + (tmc) => tmc.courseId.toString(), + (mooc) => mooc.instanceId, + ); + } +} + +export type TmcExerciseId = number; +export type MoocExerciseId = string; + +export type ExerciseIdentifier = Enum<{ tmcExerciseId: number }, { moocExerciseId: string }>; + +export namespace ExerciseIdentifier { + export function from(id: number | string): ExerciseIdentifier { + if (typeof id === "number") { + return makeTmcKind({ tmcExerciseId: id }); + } else if (typeof id === "string") { + return makeMoocKind({ moocExerciseId: id }); + } else { + assertUnreachable(id); + } + } + + export function unwrap(id: ExerciseIdentifier): number | string { + if (id.kind === "tmc") { + return id.data.tmcExerciseId; + } + if (id.kind === "mooc") { + return id.data.moocExerciseId; + } else { + assertUnreachable(id); + } + } + + export function toString(id: ExerciseIdentifier): string { + return match( + id, + (tmc) => tmc.tmcExerciseId.toString(), + (mooc) => mooc.moocExerciseId, + ); + } +} + +// helper to simulate Rust's `match` +export function match(data: Enum, tmc: (x: A) => C, mooc: (x: B) => D): C | D { + switch (data.kind) { + case "tmc": { + return tmc(data.data); + } + case "mooc": { + return mooc(data.data); + } + default: { + assertUnreachable(data); + } + } +} + +export function matchBackend( + data: A, + tmc: (x: A) => B, + mooc: (x: A) => C, +): B | C { + switch (data.backend) { + case "tmc": { + return tmc(data); + } + case "mooc": { + return mooc(data); + } + default: { + assertUnreachable(data.backend); + } + } +} + +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): { kind: "tmc" } & { data: T } { + return { kind: "tmc", data: t }; +} + +export function makeMoocKind(t: T): { kind: "mooc" } & { data: T } { + return { kind: "mooc", data: t }; +} + +export function unwrap(e: Enum): A | B { + return match( + e, + (a) => a, + (b) => b, + ); +} diff --git a/src/actions/addNewCourse.ts b/src/actions/addNewCourse.ts index e6149f6a..61bbb288 100644 --- a/src/actions/addNewCourse.ts +++ b/src/actions/addNewCourse.ts @@ -1,61 +1,101 @@ import { Err, Result } from "ts-results"; -import { LocalCourseData } from "../api/storage"; import { Logger } from "../utilities"; -import { combineApiExerciseData } from "../utilities/apiData"; +import { combineTmcApiExerciseData } from "../utilities/apiData"; import { refreshLocalExercises } from "./refreshLocalExercises"; import { ActionContext } from "./types"; +import { CourseIdentifier, LocalMoocCourseData, LocalTmcCourseData, match } from "../shared/shared"; /** * Adds a new course to user's courses. */ export async function addNewCourse( actionContext: ActionContext, - organization: string, - course: number, + organizationSlug: string, + course: CourseIdentifier, ): Promise> { - const { tmc, ui, userData, workspaceManager } = actionContext; - if (!(tmc.ok && userData.ok && workspaceManager.ok)) { + const { langs, ui, userData, workspaceManager } = actionContext; + if (!(langs.ok && userData.ok && workspaceManager.ok)) { return new Err(new Error("Extension was not initialized properly")); } Logger.info("Adding new course"); - const courseDataResult = await tmc.val.getCourseData(course); - if (courseDataResult.err) { - return courseDataResult; - } - const courseData = courseDataResult.val; + return match( + course, + async (tmcCourse) => { + const courseDataResult = await langs.val.getTmcCourseData(tmcCourse.courseId); + if (courseDataResult.err) { + return courseDataResult; + } + const courseData = courseDataResult.val; + + let availablePoints = 0; + let awardedPoints = 0; + courseData.exercises.forEach((x) => { + availablePoints += x.available_points.length; + awardedPoints += x.awarded_points.length; + }); - let availablePoints = 0; - let awardedPoints = 0; - courseData.exercises.forEach((x) => { - availablePoints += x.available_points.length; - awardedPoints += x.awarded_points.length; - }); + const localData: LocalTmcCourseData = { + description: courseData.details.description || "", + exercises: combineTmcApiExerciseData( + courseData.details.exercises, + courseData.exercises, + ), + id: courseData.details.id, + name: courseData.details.name, + title: courseData.details.title, + organization: organizationSlug, + availablePoints: availablePoints, + awardedPoints: awardedPoints, + perhapsExamMode: courseData.settings.hide_submission_results, + newExercises: [], + notifyAfter: 0, + disabled: courseData.settings.disabled_status === "enabled" ? false : true, + materialUrl: courseData.settings.material_url, + }; + userData.val.addCourse({ kind: "tmc", data: localData }); + ui.treeDP.addChildWithId("myCourses", localData.id, localData.title, { + command: "tmc.courseDetails", + title: "Go To Course Details", + arguments: [CourseIdentifier.from(localData.id)], + }); + workspaceManager.val.createWorkspaceFile(courseData.details.name); + //await displayUserCourses(actionContext); + return refreshLocalExercises(actionContext); + }, + async (mooc) => { + const courseInstanceRes = await langs.val.getMoocCourseInstanceData(mooc.instanceId); + if (courseInstanceRes.err) { + return courseInstanceRes; + } + const [courseInstance, _] = courseInstanceRes.val; - const localData: LocalCourseData = { - description: courseData.details.description || "", - exercises: combineApiExerciseData(courseData.details.exercises, courseData.exercises), - id: courseData.details.id, - name: courseData.details.name, - title: courseData.details.title, - organization: organization, - availablePoints: availablePoints, - awardedPoints: awardedPoints, - perhapsExamMode: courseData.settings.hide_submission_results, - newExercises: [], - notifyAfter: 0, - disabled: courseData.settings.disabled_status === "enabled" ? false : true, - materialUrl: courseData.settings.material_url, - }; - userData.val.addCourse(localData); - ui.treeDP.addChildWithId("myCourses", localData.id, localData.title, { - command: "tmc.courseDetails", - title: "Go To Course Details", - arguments: [localData.id], - }); - workspaceManager.val.createWorkspaceFile(courseData.details.name); - //await displayUserCourses(actionContext); - return refreshLocalExercises(actionContext); + const localData: LocalMoocCourseData = { + courseId: courseInstance.course_id, + instanceId: courseInstance.id, + courseName: courseInstance.course_name, + instanceName: courseInstance.instance_name, + courseDescription: courseInstance.course_description, + instanceDescription: courseInstance.instance_description, + awardedPoints: 0, + availablePoints: 0, + disabled: false, + materialUrl: null, + exercises: [], + newExercises: [], + notifyAfter: 0, + perhapsExamMode: false, + }; + userData.val.addCourse({ kind: "mooc", data: localData }); + ui.treeDP.addChildWithId("myCourses", localData.instanceId, localData.courseName, { + command: "tmc.courseDetails", + title: "Go To Course Details", + arguments: [CourseIdentifier.from(localData.instanceId)], + }); + workspaceManager.val.createWorkspaceFile(courseInstance.course_slug); + return refreshLocalExercises(actionContext); + }, + ); } diff --git a/src/actions/checkForExerciseUpdates.ts b/src/actions/checkForExerciseUpdates.ts index 09daedc7..7f76b619 100644 --- a/src/actions/checkForExerciseUpdates.ts +++ b/src/actions/checkForExerciseUpdates.ts @@ -1,6 +1,7 @@ import { flatten } from "lodash"; import { Err, Ok, Result } from "ts-results"; +import { assertUnreachable, CourseIdentifier, ExerciseIdentifier } from "../shared/shared"; import { Logger } from "../utilities"; import { ActionContext } from "./types"; @@ -10,43 +11,66 @@ interface Options { } interface OutdatedExercise { - courseId: number; + courseId: CourseIdentifier; exerciseName: string; - exerciseId: number; + exerciseId: ExerciseIdentifier; } /** * 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, ): Promise> { - const { tmc, userData } = actionContext; - if (!(tmc.ok && userData.ok)) { + const { langs, userData } = actionContext; + if (!(langs.ok && userData.ok)) { return new Err(new Error("Extension was not initialized properly")); } const forceRefresh = options?.forceRefresh ?? false; Logger.info("Checking for exercise updates, forced update:", forceRefresh); - const checkUpdatesResult = await tmc.val.checkExerciseUpdates({ forceRefresh }); - if (checkUpdatesResult.err) { - return checkUpdatesResult; + const tmcCheckUpdatesResult = await langs.val.checkTmcExerciseUpdates({ forceRefresh }); + if (tmcCheckUpdatesResult.err) { + return tmcCheckUpdatesResult; } - const updateableExerciseIds = new Set(checkUpdatesResult.val.map((x) => x.id)); + const moocCheckUpdatesResult = await langs.val.checkMoocExerciseUpdates({ forceRefresh }); + if (moocCheckUpdatesResult.err) { + return moocCheckUpdatesResult; + } + + const tmcUpdateableExerciseIds = new Set(tmcCheckUpdatesResult.val.map((x) => x.id)); + const moocUpdateableExerciseIds = new Set(moocCheckUpdatesResult.val.map((x) => x)); const outdatedExercisesByCourse = userData.val .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 outdatedExercises = course.data.exercises.filter((x) => + tmcUpdateableExerciseIds.has(x.id), + ); + return outdatedExercises.map((x) => ({ + courseId: CourseIdentifier.from(course.data.id), + exerciseId: ExerciseIdentifier.from(x.id), + exerciseName: x.name, + })); + } + case "mooc": { + const outdatedExercises = course.data.exercises.filter((x) => + moocUpdateableExerciseIds.has(x.id), + ); + return outdatedExercises.map((x) => ({ + courseId: CourseIdentifier.from(course.data.instanceId), + exerciseId: ExerciseIdentifier.from(x.id), + exerciseName: x.slug, + })); + } + 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/downloadNewExercisesForCourse.ts index ba4e0852..822cabe5 100644 --- a/src/actions/downloadNewExercisesForCourse.ts +++ b/src/actions/downloadNewExercisesForCourse.ts @@ -6,6 +6,7 @@ import { Logger } from "../utilities"; import { downloadOrUpdateExercises } from "./downloadOrUpdateExercises"; import { refreshLocalExercises } from "./refreshLocalExercises"; import { ActionContext } from "./types"; +import { CourseIdentifier, ExerciseIdentifier, LocalCourseData } from "../shared/shared"; /** * Downloads course's new exercises using relevate data from the context's UserData. Also handles @@ -15,16 +16,16 @@ import { ActionContext } from "./types"; */ export async function downloadNewExercisesForCourse( actionContext: ActionContext, - courseId: number, + courseId: CourseIdentifier, ): Promise> { const { userData } = actionContext; if (userData.err) { return new Err(new Error("Extension was not initialized properly")); } const course = userData.val.getCourse(courseId); - Logger.info("Downloading new exercises for course:", course.title); + Logger.info("Downloading new exercises for course"); - const postNewExercises = async (exerciseIds: number[]): Promise => + const postNewExercises = async (exerciseIds: ExerciseIdentifier[]): Promise => await TmcPanel.postMessage({ type: "setNewExercises", target: { @@ -36,10 +37,11 @@ export async function downloadNewExercisesForCourse( postNewExercises([]); - const downloadResult = await downloadOrUpdateExercises(actionContext, course.newExercises); + const newExercises = LocalCourseData.getNewExercises(course); + const downloadResult = await downloadOrUpdateExercises(actionContext, newExercises); if (downloadResult.err) { Logger.error("Failed to download new exercises.", downloadResult.val); - postNewExercises(course.newExercises); + postNewExercises(newExercises); return downloadResult; } @@ -49,11 +51,11 @@ export async function downloadNewExercisesForCourse( ); if (refreshResult.err) { Logger.error("Failed to refresh workspace.", downloadResult.val); - postNewExercises(course.newExercises); + postNewExercises(newExercises); return refreshResult; } - postNewExercises(course.newExercises); + postNewExercises(newExercises); return Ok.EMPTY; } diff --git a/src/actions/downloadOrUpdateExercises.ts b/src/actions/downloadOrUpdateExercises.ts index 974393d8..8e01cfec 100644 --- a/src/actions/downloadOrUpdateExercises.ts +++ b/src/actions/downloadOrUpdateExercises.ts @@ -1,15 +1,15 @@ import { Err, Ok, Result } from "ts-results"; import { TmcPanel } from "../panels/TmcPanel"; -import { ExtensionToWebview } from "../shared/shared"; +import { ExerciseIdentifier, ExtensionToWebview } from "../shared/shared"; import { ExerciseStatus } from "../ui/types"; import { Logger } from "../utilities"; import { ActionContext } from "./types"; interface DownloadResults { - successful: number[]; - failed: number[]; + successful: ExerciseIdentifier[]; + failed: ExerciseIdentifier[]; } /** @@ -20,10 +20,10 @@ interface DownloadResults { */ export async function downloadOrUpdateExercises( actionContext: ActionContext, - exerciseIds: number[], + exerciseIds: ExerciseIdentifier[], ): Promise> { - const { dialog, settings, tmc } = actionContext; - if (tmc.err) { + const { dialog, settings, langs } = actionContext; + if (langs.err) { return new Err(new Error("Extension was not initialized properly")); } Logger.info("Downloading exercises", exerciseIds); @@ -33,15 +33,17 @@ export async function downloadOrUpdateExercises( } TmcPanel.postMessage(...exerciseIds.map((x) => wrapToMessage(x, "downloading"))); - const statuses = new Map(exerciseIds.map((x) => [x, "downloadFailed"])); + const statuses = new Map( + exerciseIds.map((x) => [ExerciseIdentifier.unwrap(x), "downloadFailed"]), + ); const downloadTemplate = !settings.getDownloadOldSubmission(); const downloadResult = await dialog.progressNotification( "Downloading exercises...", (progress) => { - return tmc.val.downloadExercises(exerciseIds, downloadTemplate, (download) => { + return langs.val.downloadExercises(exerciseIds, downloadTemplate, (download) => { progress.report(download); - statuses.set(download.id, "closed"); + statuses.set(ExerciseIdentifier.unwrap(download.id), "closed"); TmcPanel.postMessage(wrapToMessage(download.id, "closed")); }); }, @@ -51,19 +53,38 @@ export async function downloadOrUpdateExercises( return downloadResult; } - const { downloaded, failed, skipped } = downloadResult.val; - if (skipped.length > 0) { - Logger.warn(`${skipped.length} downloads were skipped.`); + const [ + { downloaded: tmcDownloaded, failed: tmcFailed, skipped: tmcSkipped }, + { downloaded: moocDownloaded, failed: moocFailed, skipped: moocSkipped }, + ] = downloadResult.val; + if (tmcSkipped.length > 0) { + Logger.warn(`${tmcSkipped.length} downloads were skipped.`); + } + if (moocSkipped.length > 0) { + Logger.warn(`${moocSkipped.length} downloads were skipped.`); } - downloaded.forEach((x) => statuses.set(x.id, "closed")); - skipped.forEach((x) => statuses.set(x.id, "closed")); - failed?.forEach(([exercise, reason]) => { + tmcDownloaded.forEach((x) => statuses.set(x.id, "closed")); + moocDownloaded.forEach((x) => statuses.set(x["task-id"], "closed")); + tmcSkipped.forEach((x) => statuses.set(x.id, "closed")); + moocSkipped.forEach((x) => statuses.set(x["task-id"], "closed")); + tmcFailed?.forEach(([exercise, reason]) => { Logger.error(`Failed to download exercise ${exercise["exercise-slug"]}: ${reason}`); statuses.set(exercise.id, "downloadFailed"); }); + moocFailed?.forEach(([exercise, reason]) => { + Logger.error(`Failed to download exercise ${exercise["task-id"]}: ${reason}`); + statuses.set(exercise["task-id"], "downloadFailed"); + }); postMessages(statuses); - if (failed && failed.length > 0) { - const failedDownloads = failed.map(([f]) => f["exercise-slug"]); + if (tmcFailed && tmcFailed.length > 0) { + const failedDownloads = tmcFailed.map(([f]) => f["exercise-slug"]); + dialog.errorNotification( + "Failed to update exercises.", + new Error(failedDownloads.join(", ")), + ); + } + if (moocFailed && moocFailed.length > 0) { + const failedDownloads = moocFailed.map(([f]) => f["task-id"]); dialog.errorNotification( "Failed to update exercises.", new Error(failedDownloads.join(", ")), @@ -73,11 +94,15 @@ export async function downloadOrUpdateExercises( return Ok(sortResults(statuses)); } -function postMessages(statuses: Map): void { - TmcPanel.postMessage(...Array.from(statuses.entries()).map(([id, s]) => wrapToMessage(id, s))); +function postMessages(statuses: Map): void { + TmcPanel.postMessage( + ...Array.from(statuses.entries()).map(([id, s]) => + wrapToMessage(ExerciseIdentifier.from(id), s), + ), + ); } -function wrapToMessage(exerciseId: number, status: ExerciseStatus): ExtensionToWebview { +function wrapToMessage(exerciseId: ExerciseIdentifier, status: ExerciseStatus): ExtensionToWebview { return { type: "exerciseStatusChange", target: { @@ -88,14 +113,14 @@ function wrapToMessage(exerciseId: number, status: ExerciseStatus): ExtensionToW }; } -function sortResults(statuses: Map): DownloadResults { - const successful: number[] = []; - const failed: number[] = []; +function sortResults(statuses: Map): DownloadResults { + const successful: ExerciseIdentifier[] = []; + const failed: ExerciseIdentifier[] = []; statuses.forEach((status, id) => { if (status !== "downloadFailed") { - successful.push(id); + successful.push(ExerciseIdentifier.from(id)); } else { - failed.push(id); + failed.push(ExerciseIdentifier.from(id)); } }); return { successful, failed }; diff --git a/src/actions/extension.ts b/src/actions/extension.ts new file mode 100644 index 00000000..b42d8da5 --- /dev/null +++ b/src/actions/extension.ts @@ -0,0 +1,320 @@ +import * as path from "path"; +import { createIs } from "typia"; +import * as vscode from "vscode"; + +import { checkForCourseUpdates, 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/langs"; +import WorkspaceManager from "../api/workspaceManager"; +import { + CLIENT_NAME, + DEBUG_MODE, + EXERCISE_CHECK_INTERVAL, + EXTENSION_ID, + TMC_LANGS_CONFIG_DIR, +} from "../config/constants"; +import Settings from "../config/settings"; +import { UserData } from "../config/userdata"; +import { EmptyLangsResponseError, HaltForReloadError } from "../errors"; +import * as init from "../init"; +import { migrateExtensionDataFromPreviousVersions } from "../migrate"; +import { randomPanelId, TmcPanel } from "../panels/TmcPanel"; +import UI from "../ui/ui"; +import { cliFolder, Logger, LogLevel, semVerCompare } from "../utilities"; +import { Err, Ok, Result } from "ts-results"; + +let maintenanceInterval: NodeJS.Timeout | undefined; + +function initializationError(dialog: Dialog, step: string, error: Error, cliFolder: string): void { + Logger.errorWithDialog( + dialog, + `Initialization error during ${step}:`, + error, + "If this issue is not resolved, the extension may not function properly.", + ); + 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:", + cliFolder, + ); + } +} + +export async function activate(context: vscode.ExtensionContext): Promise { + try { + await activateInner(context); + } catch (e) { + // this should never occur, we always want to activate the extension even if only partially + Logger.error("Fatal error during initialization:", e); + vscode.window.showErrorMessage( + `Fatal error during TestMyCode extension initialization: ${e}`, + ); + Logger.show(); + } +} + +async function activateInner(context: vscode.ExtensionContext): Promise { + const extensionVersion = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.version; + Logger.configure(LogLevel.Verbose); + Logger.info(`Starting ${EXTENSION_ID} in "${DEBUG_MODE ? "development" : "production"}" mode.`); + Logger.info(`${vscode.env.appName} version: ${vscode.version}`); + Logger.info(`${EXTENSION_ID} version: ${extensionVersion}`); + Logger.info(`Currently open workspace: ${vscode.workspace.name}`); + + const dialog = new Dialog(); + const cliFolderPath = cliFolder(context); + const cliPathResult = await init.ensureLangsUpdated(cliFolderPath, dialog); + + // download langs if necessary + let langs: Result; + if (cliPathResult.err) { + langs = cliPathResult; + initializationError(dialog, "tmc-langs setup", cliPathResult.val, cliFolderPath); + } else { + langs = new Ok( + new TMC(cliPathResult.val, CLIENT_NAME, extensionVersion, { + cliConfigDir: TMC_LANGS_CONFIG_DIR, + }), + ); + } + + // check auth status + let authenticated = false; + if (langs.ok) { + const authenticatedResult = await langs.val.isAuthenticated({ timeout: 15000 }); + if (authenticatedResult.err) { + initializationError( + dialog, + "authentication check", + authenticatedResult.val, + cliFolderPath, + ); + await vscode.commands.executeCommand("setContext", "test-my-code:LoggedIn", false); + } else { + authenticated = authenticatedResult.val; + await vscode.commands.executeCommand( + "setContext", + "test-my-code:LoggedIn", + authenticated, + ); + } + } else { + Logger.warn("Could not check login status"); + await vscode.commands.executeCommand("setContext", "test-my-code:LoggedIn", false); + } + + // migrate data between versions + const storage = new Storage(context); + if (langs.ok) { + const migrationResult = await migrateExtensionDataFromPreviousVersions( + context, + storage, + dialog, + langs.val, + vscode.workspace.getConfiguration(), + ); + if (migrationResult.err) { + if (migrationResult.val instanceof HaltForReloadError) { + Logger.warn("Extension expected to restart", migrationResult.val); + return; + } + + initializationError(dialog, "migration", migrationResult.val, cliFolderPath); + } + } else { + Logger.warn("Skipped data migration"); + } + + // get data path + let tmcDataPath: string | undefined; + if (langs.ok) { + const dataPathResult = await langs.val.getSetting("projects-dir", createIs()); + if (dataPathResult.err) { + Logger.error("Failed to define datapath:", dataPathResult.val); + initializationError(dialog, "finding datapath", dataPathResult.val, cliFolderPath); + } else if (dataPathResult.val === undefined) { + Logger.error("Failed to define datapath: no value found."); + initializationError( + dialog, + "finding datapath", + new Error("No value for datapath."), + cliFolderPath, + ); + } else { + tmcDataPath = dataPathResult.val; + } + } + + const workspaceFileFolder = path.join(context.globalStorageUri.fsPath, "workspaces"); + const resources = await init.resourceInitialization( + context, + storage, + tmcDataPath, + workspaceFileFolder, + ); + if (resources.err) { + initializationError(dialog, "resource initialization", resources.val, cliFolderPath); + } + + const settings = new Settings(storage); + context.subscriptions.push(settings); + + Logger.configure(settings.getLogLevel()); + + const ui = new UI(); + const loggedIn = ui.treeDP.createVisibilityGroup(authenticated); + const visibilityGroups = { + loggedIn, + }; + + if (langs.ok) { + langs.val.on("login", async () => { + await vscode.commands.executeCommand("setContext", "test-my-code:LoggedIn", true); + ui.treeDP.updateVisibility([visibilityGroups.loggedIn]); + }); + langs.val.on("logout", async () => { + dialog.warningNotification("Your TMC session has expired, please log in."); + await vscode.commands.executeCommand("setContext", "test-my-code:LoggedIn", false); + ui.treeDP.updateVisibility([visibilityGroups.loggedIn.not]); + TmcPanel.renderMain(context.extensionUri, context, actionContext, { + type: "Login", + id: randomPanelId(), + }); + }); + } else { + Logger.warn("Skipped login command setup"); + } + + let showWelcome = false; + if (resources.ok) { + const currentVersion = resources.val.extensionVersion; + const previousState = storage.getSessionState(); + const previousVersion = previousState?.extensionVersion; + if (currentVersion !== previousVersion) { + storage.updateSessionState({ extensionVersion: currentVersion }); + } + const versionDiff = semVerCompare(currentVersion, previousVersion || "", "minor"); + if (versionDiff === undefined || versionDiff > 0) { + showWelcome = true; + } + } else { + Logger.warn("Skipped version check"); + } + + let userData: Result; + let workspaceManager: Result; + let exerciseDecorationProvider: Result; + if (resources.ok) { + userData = new Ok(new UserData(storage)); + workspaceManager = new Ok(new WorkspaceManager(resources.val)); + context.subscriptions.push(workspaceManager.val); + if (workspaceManager.val.activeCourse) { + await vscode.commands.executeCommand( + "setContext", + "test-my-code:WorkspaceActive", + true, + ); + await workspaceManager.val.verifyWorkspaceSettingsIntegrity(); + } + exerciseDecorationProvider = new Ok( + new ExerciseDecorationProvider(userData.val, workspaceManager.val), + ); + } else { + Logger.warn("Skipped userdata setup"); + exerciseDecorationProvider = new Err( + new Error( + "Could not initialize exercise decoration provider due to failure in resource initialization", + ), + ); + userData = new Err( + new Error( + "Could not initialize exercise decoration provider due to failure in resource initialization", + ), + ); + workspaceManager = new Err( + new Error( + "Could not initialize exercise decoration provider due to failure in resource initialization", + ), + ); + } + + const actionContext: ActionContext = { + dialog, + exerciseDecorationProvider, + resources, + settings, + langs, + ui, + userData, + workspaceManager, + visibilityGroups, + }; + + const refreshResult = await refreshLocalExercises(actionContext); + if (refreshResult.err) { + Logger.warn("Failed to set initial exercises.", refreshResult.val); + } + + init.registerUiActions(actionContext); + init.registerCommands(context, actionContext); + init.registerSettingsCallbacks(actionContext); + + if (exerciseDecorationProvider.ok) { + context.subscriptions.push( + vscode.window.registerFileDecorationProvider(exerciseDecorationProvider.val), + ); + } + + if (authenticated) { + vscode.commands.executeCommand("tmc.updateExercises", "silent"); + checkForCourseUpdates(actionContext); + } + + if (maintenanceInterval) { + clearInterval(maintenanceInterval); + } + + maintenanceInterval = setInterval(async () => { + const authenticated = langs.ok ? await langs.val.isAuthenticated() : Ok(false); + if (authenticated.err) { + Logger.error("Failed to check if authenticated", authenticated.val); + } else if (authenticated.val) { + vscode.commands.executeCommand("tmc.updateExercises", "silent"); + checkForCourseUpdates(actionContext); + } + await vscode.commands.executeCommand( + "setContext", + "test-my-code:LoggedIn", + authenticated.val, + ); + }, EXERCISE_CHECK_INTERVAL); + + if (showWelcome) { + await vscode.commands.executeCommand("tmc.showWelcome"); + } + + if ( + !( + langs.ok && + userData.ok && + workspaceManager.ok && + exerciseDecorationProvider.ok && + resources.ok + ) + ) { + TmcPanel.renderMain(context.extensionUri, context, actionContext, { + id: randomPanelId(), + type: "InitializationErrorHelp", + }); + } +} + +export function deactivate(): void { + if (maintenanceInterval) { + clearInterval(maintenanceInterval); + } +} diff --git a/src/actions/index.ts b/src/actions/index.ts index 26a3e863..eac7e218 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,10 +1,10 @@ export * from "./addNewCourse"; export * from "./checkForExerciseUpdates"; -export * from "./downloadNewExercisesForCourse"; -export * from "./downloadOrUpdateExercises"; export * from "./moveExtensionDataPath"; export * from "./refreshLocalExercises"; export * from "./user"; export * from "./updateCourse"; export * from "./webview"; export * from "./workspace"; +export * from "./downloadOrUpdateExercises"; +export * from "./downloadNewExercisesForCourse"; diff --git a/src/actions/moveExtensionDataPath.ts b/src/actions/moveExtensionDataPath.ts index a8b984fe..c3998278 100644 --- a/src/actions/moveExtensionDataPath.ts +++ b/src/actions/moveExtensionDataPath.ts @@ -19,39 +19,19 @@ export async function moveExtensionDataPath( newPath: vscode.Uri, onUpdate?: (value: { percent: number; message?: string }) => void, ): Promise> { - const { resources, tmc } = actionContext; - if (!(tmc.ok && resources.ok)) { + const { resources, langs } = actionContext; + if (!(langs.ok && resources.ok)) { return new Err(new Error("Extension was not initialized properly")); } Logger.info("Moving extension data path"); - // This appears to be unnecessary with current VS Code version - /* - const activeCourse = workspaceManager.activeCourse; - if (activeCourse) { - const exercisesToClose = workspaceManager - .getExercisesByCourseSlug(activeCourse) - .filter((x) => x.status === ExerciseStatus.Open) - .map((x) => x.exerciseSlug); - - // Close exercises without writing the result to "reopen" them with refreshLocalExercises - const closeResult = await workspaceManager.closeCourseExercises( - activeCourse, - exercisesToClose, - ); - if (closeResult.err) { - return closeResult; - } - } - */ - // Use given path if empty dir, otherwise append let newFsPath = newPath.fsPath; if (fs.readdirSync(newFsPath).length > 0) { newFsPath = path.join(newFsPath, "tmcdata"); } - const moveResult = await tmc.val.moveProjectsDirectory(newFsPath, onUpdate); + const moveResult = await langs.val.moveProjectsDirectory(newFsPath, onUpdate); if (moveResult.err) { return moveResult; } diff --git a/src/actions/refreshLocalExercises.ts b/src/actions/refreshLocalExercises.ts index 70ae3bda..fba492e4 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"; @@ -13,44 +14,99 @@ import { ActionContext } from "./types"; export async function refreshLocalExercises( actionContext: ActionContext, ): Promise> { - const { tmc, userData, workspaceManager } = actionContext; - if (!(tmc.ok && userData.ok && workspaceManager.ok)) { + const { langs, userData, workspaceManager } = actionContext; + if (!(langs.ok && userData.ok && workspaceManager.ok)) { return new Err(new Error("Extension was not initialized properly")); } Logger.info("Refreshing local exercises"); const workspaceExercises: WorkspaceExercise[] = []; for (const course of userData.val.getCourses()) { - const exercisesResult = await tmc.val.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 exercisesResult = await langs.val.listLocalCourseExercises( + "tmc", + course.data.name, + ); + if (exercisesResult.err) { + Logger.warn( + `Failed to get exercises for course: ${JSON.stringify(course, null, 2)}`, + exercisesResult.val, + ); + continue; + } + + const closedExercisesResult = ( + await langs.val.getSetting( + `closed-exercises-for:${course.data.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) => ({ + backend: "tmc", + courseSlug: course.data.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": { + const exercisesResult = await langs.val.listLocalCourseExercises( + "mooc", + course.data.courseName, + ); + if (exercisesResult.err) { + Logger.warn( + `Failed to get exercises for course: ${JSON.stringify(course, null, 2)}`, + exercisesResult.val, + ); + continue; + } - const closedExercisesResult = ( - await tmc.val.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"]), - })), - ); + const closedExercisesResult = ( + await langs.val.getSetting( + `closed-exercises-for:${course.data.courseName}`, + 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) => ({ + backend: "mooc", + courseSlug: course.data.courseName, + exerciseSlug: x["exercise-slug"], + status: closedExercises.has(x["exercise-slug"]) + ? ExerciseStatus.Closed + : ExerciseStatus.Open, + uri: vscode.Uri.file(x["exercise-path"]), + })), + ); + break; + } + default: { + assertUnreachable(course); + } + } } return workspaceManager.val.setExercises(workspaceExercises); diff --git a/src/actions/types.ts b/src/actions/types.ts index 01596db2..4b7660ba 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -1,7 +1,7 @@ import { Result } from "ts-results"; import Dialog from "../api/dialog"; import ExerciseDecorationProvider from "../api/exerciseDecorationProvider"; -import TMC from "../api/tmc"; +import Langs from "../api/langs"; import WorkspaceManager from "../api/workspaceManager"; import Resources from "../config/resources"; import Settings from "../config/settings"; @@ -15,7 +15,7 @@ export type ActionContext = { exerciseDecorationProvider: Result; resources: Result; settings: Settings; - tmc: Result; + langs: Result; ui: UI; userData: Result; workspaceManager: Result; diff --git a/src/actions/updateCourse.ts b/src/actions/updateCourse.ts index 5658f082..a3681535 100644 --- a/src/actions/updateCourse.ts +++ b/src/actions/updateCourse.ts @@ -2,12 +2,21 @@ import { Err, Ok, Result } from "ts-results"; import { ConnectionError, ForbiddenError } from "../errors"; import { TmcPanel } from "../panels/TmcPanel"; +import { + CourseIdentifier, + Enum, + ExerciseIdentifier, + LocalCourseData, + makeMoocKind, + makeTmcKind, + match, +} from "../shared/shared"; import { Logger } from "../utilities"; -import { combineApiExerciseData } from "../utilities/apiData"; +import { combineTmcApiExerciseData } from "../utilities/apiData"; import { refreshLocalExercises } from "./refreshLocalExercises"; import { ActionContext } from "./types"; -import { use } from "chai"; +import { CombinedCourseData, CourseInstance, TmcExerciseSlide } from "../shared/langsSchema"; /** * Updates the given course by re-fetching all data from the server. Handles authorization and @@ -18,15 +27,19 @@ import { use } from "chai"; */ export async function updateCourse( actionContext: ActionContext, - courseId: number, + courseId: CourseIdentifier, ): Promise> { - const { exerciseDecorationProvider, tmc, userData, workspaceManager } = actionContext; - if (!(tmc.ok && userData.ok && workspaceManager.ok && exerciseDecorationProvider.ok)) { + const { exerciseDecorationProvider, langs, userData, workspaceManager } = actionContext; + if (!(langs.ok && userData.ok && workspaceManager.ok && exerciseDecorationProvider.ok)) { return new Err(new Error("Extension was not initialized properly")); } 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", @@ -47,21 +60,32 @@ export async function updateCourse( ); }; const courseData = userData.val.getCourse(courseId); - const updateResult = await tmc.val.getCourseData(courseId, { forceRefresh: true }); + const updateResult: Result< + Enum]>, + Error + > = await match( + courseId, + (tmcId) => + langs.val + .getTmcCourseData(tmcId.courseId, { forceRefresh: true }) + .then((res) => res.map(makeTmcKind)), + (moocId) => + langs.val + .getMoocCourseInstanceData(moocId.instanceId) + .then((res) => res.map(makeMoocKind)), + ); if (updateResult.err) { if (updateResult.val instanceof ForbiddenError) { - if (!courseData.disabled) { - Logger.warn( - `Failed to access information for course ${courseData.name}. Marking as disabled.`, - ); - const course = userData.val.getCourse(courseId); - await userData.val.updateCourse({ ...course, disabled: true }); - postMessage(course.id, true, []); + const course = userData.val.getCourse(courseId); + const courseIdent = LocalCourseData.getCourseId(course); + if (!courseData.data.disabled) { + Logger.warn(`Failed to access information for course. Marking as disabled.`); + course.data.disabled = true; + await userData.val.updateCourse(course); + postMessage(courseIdent, true, []); } else { - Logger.warn( - `ForbiddenError above probably caused by course still being disabled ${courseData.name}`, - ); - postMessage(courseData.id, true, []); + Logger.warn(`ForbiddenError above probably caused by course still being disabled`); + postMessage(courseIdent, true, []); } return Ok(false); } else if (updateResult.val instanceof ConnectionError) { @@ -72,33 +96,55 @@ export async function updateCourse( } } - const { details, exercises, settings } = updateResult.val; - const [availablePoints, awardedPoints] = exercises.reduce( - (a, b) => [a[0] + b.available_points.length, a[1] + b.awarded_points.length], - [0, 0], - ); + const updateExercisesResult = await match( + updateResult.val, + async (tmc) => { + const { details, exercises, settings } = tmc; + const [availablePoints, awardedPoints] = exercises.reduce( + (a, b) => [a[0] + b.available_points.length, a[1] + b.awarded_points.length], + [0, 0], + ); - await userData.val.updateCourse({ - ...courseData, - availablePoints, - awardedPoints, - description: details.description || "", - disabled: settings.disabled_status !== "enabled", - materialUrl: settings.material_url, - perhapsExamMode: settings.hide_submission_results, - }); + courseData.data = { + ...courseData.data, + availablePoints, + awardedPoints, + description: details.description || "", + disabled: settings.disabled_status !== "enabled", + materialUrl: settings.material_url, + perhapsExamMode: settings.hide_submission_results, + }; + await userData.val.updateCourse(courseData); - const updateExercisesResult = await userData.val.updateExercises( - courseId, - combineApiExerciseData(details.exercises, exercises), + return await userData.val.updateExercises( + courseId, + combineTmcApiExerciseData(details.exercises, exercises).map(makeTmcKind), + ); + }, + async (mooc) => { + const [_courseInstance, slides] = mooc; + const localExercises = slides + .flatMap((s) => + s.tasks.map((t) => ({ + id: t.task_id, + slug: s.exercise_name, + deadline: s.deadline, + passed: false, + softDeadline: s.deadline, + })), + ) + .map(makeMoocKind); + return await userData.val.updateExercises(courseId, localExercises); + }, ); if (updateExercisesResult.err) { return updateExercisesResult; } - if (courseData.name === workspaceManager.val.activeCourse) { + const courseName = LocalCourseData.getCourseName(courseData); + if (courseName === workspaceManager.val.activeCourse) { exerciseDecorationProvider.val.updateDecorationsForExercises( - ...workspaceManager.val.getExercisesByCourseSlug(courseData.name), + ...workspaceManager.val.getExercisesByCourseSlug(courseName), ); } @@ -106,7 +152,11 @@ export async function updateCourse( await refreshLocalExercises(actionContext); const course = userData.val.getCourse(courseId); - postMessage(course.id, course.disabled, course.newExercises); + postMessage( + LocalCourseData.getCourseId(course), + course.data.disabled, + LocalCourseData.getNewExercises(course), + ); return Ok(true); } diff --git a/src/actions/user.ts b/src/actions/user.ts index 2fce597d..e55d7914 100644 --- a/src/actions/user.ts +++ b/src/actions/user.ts @@ -9,12 +9,21 @@ 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 { + WorkspaceExercise, + 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"; -import { ExerciseSubmissionPanel, ExerciseTestsPanel, TestResultData } from "../shared/shared"; +import { + CourseIdentifier, + ExerciseSubmissionPanel, + ExerciseTestsPanel, + TestResultData, + LocalCourseData, + LocalCourseExercise, +} from "../shared/shared"; import { Logger, parseFeedbackQuestion } from "../utilities/"; import { getActiveEditorExecutablePath } from "../window"; @@ -32,8 +41,8 @@ export async function login( username: string, password: string, ): Promise> { - const { tmc, dialog } = actionContext; - if (tmc.err) { + const { langs, dialog } = actionContext; + if (langs.err) { return new Err(new Error("Extension was not initialized properly")); } Logger.info("Logging in"); @@ -42,7 +51,7 @@ export async function login( return new Err(new Error("Username and password may not be empty.")); } - const result = await tmc.val.authenticate(username, password); + const result = await langs.val.authenticate(username, password); if (result.err) { dialog.errorNotification(`Failed to log in: "${result.val.message}:"`, result.val); return result; @@ -55,12 +64,12 @@ export async function login( * Logs the user out, updating UI state */ export async function logout(actionContext: ActionContext): Promise> { - const { tmc, dialog } = actionContext; - if (tmc.err) { + const { langs, dialog } = actionContext; + if (langs.err) { return new Err(new Error("Extension was not initialized properly")); } - const result = await tmc.val.deauthenticate(); + const result = await langs.val.deauthenticate(); if (result.err) { dialog.errorNotification(`Failed to log out: "${result.val.message}:"`, result.val); return result; @@ -75,15 +84,17 @@ export async function logout(actionContext: ActionContext): Promise> { - const { tmc, userData } = actionContext; - if (!(tmc.ok && userData.ok)) { + const { langs, userData } = actionContext; + if (!(langs.ok && userData.ok)) { return new Err(new Error("Extension was not initialized properly")); } - const course = userData.val.getCourseByName(exercise.courseSlug); - const courseExercise = course.exercises.find((x) => x.name === exercise.exerciseSlug); + const course = userData.val.getCourseBySlug(exercise.courseSlug); + const courseExercise = LocalCourseData.getExercises(course).find( + (x) => LocalCourseExercise.getSlug(x) === exercise.exerciseSlug, + ); if (!courseExercise) { return Err( new Error( @@ -106,15 +117,17 @@ export async function testExercise( let data: TestResultData = { ...EXAM_TEST_RESULT, - id: courseExercise.id, - disabled: course.disabled, - courseSlug: course.name, + id: LocalCourseExercise.getId(courseExercise), + disabled: course.data.disabled, + courseSlug: LocalCourseData.getCourseName(course), }; - if (!course.perhapsExamMode) { + if (!course.data.perhapsExamMode) { const executablePath = getActiveEditorExecutablePath(actionContext); - const [testRunner, testInterrupt] = tmc.val.runTests(exercise.uri.fsPath, executablePath); - const [validationRunner, validationInterrupt] = tmc.val.runCheckstyle(exercise.uri.fsPath); + const [testRunner, testInterrupt] = langs.val.runTests(exercise.uri.fsPath, executablePath); + const [validationRunner, validationInterrupt] = langs.val.runCheckstyle( + exercise.uri.fsPath, + ); testInterrupts.set(testRunId, [testInterrupt, validationInterrupt]); const exerciseName = exercise.exerciseSlug; @@ -145,11 +158,11 @@ export async function testExercise( data = { testResult: testResults.val, - id: courseExercise.id, - courseSlug: course.name, + id: LocalCourseExercise.getId(courseExercise), + courseSlug: LocalCourseData.getCourseName(course), exerciseName, tmcLogs: testResults.val.logs, - disabled: course.disabled, + disabled: course.data.disabled, styleValidationResult: validationResults.val, }; @@ -177,19 +190,21 @@ 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, ): Promise> { - const { exerciseDecorationProvider, tmc, userData } = actionContext; - if (!(tmc.ok && userData.ok && exerciseDecorationProvider.ok)) { + const { exerciseDecorationProvider, langs, userData } = actionContext; + if (!(langs.ok && userData.ok && exerciseDecorationProvider.ok)) { return new Err(new Error("Extension was not initialized properly")); } Logger.info(`Submitting exercise ${exercise.exerciseSlug} to server`); - const course = userData.val.getCourseByName(exercise.courseSlug); - const courseExercise = course.exercises.find((x) => x.name === exercise.exerciseSlug); + const course = userData.val.getCourseBySlug(exercise.courseSlug); + const courseExercise = LocalCourseData.getExercises(course).find( + (x) => LocalCourseExercise.getSlug(x) === exercise.exerciseSlug, + ); if (!courseExercise) { return Err( new Error( @@ -206,8 +221,8 @@ export async function submitExercise( }; await TmcPanel.renderSide(context.extensionUri, context, actionContext, panel); - const submissionResult = await tmc.val.submitExerciseAndWaitForResults( - courseExercise.id, + const submissionResult = await langs.val.submitExerciseAndWaitForResults( + LocalCourseExercise.getId(courseExercise), exercise.uri.fsPath, (progressPercent, message) => { TmcPanel.postMessage({ @@ -258,10 +273,11 @@ export async function submitExercise( questions, }); - const courseData = userData.val.getCourseByName( - exercise.courseSlug, + const courseData = userData.val.getCourse( + LocalCourseData.getCourseId(panel.course), ) as Readonly; - await checkForCourseUpdates(actionContext, courseData.id); + const courseId = LocalCourseData.getCourseId(courseData); + await checkForCourseUpdates(actionContext, courseId); vscode.commands.executeCommand("tmc.updateExercises", "silent"); return Ok.EMPTY; @@ -272,24 +288,59 @@ export async function submitExercise( * @param id Exercise ID * @returns TMC Paste link if the action was successful. */ -export async function pasteExercise( +export async function pasteTmcExercise( + actionContext: ActionContext, + courseSlug: string, + exerciseName: string, +): Promise> { + const { langs, userData, workspaceManager, dialog } = actionContext; + if (!(langs.ok && userData.ok && workspaceManager.ok)) { + return new Err(new Error("Extension was not initialized properly")); + } + + const exerciseId = userData.val.getTmcExerciseByName(courseSlug, exerciseName)?.id; + const exercisePath = workspaceManager.val.getExerciseBySlug("tmc", courseSlug, exerciseName) + ?.uri.fsPath; + if (!exerciseId || !exercisePath) { + return Err(new Error("Failed to resolve exercise id")); + } + + const pasteResult = await langs.val.submitTmcExerciseToPaste(exerciseId, exercisePath); + if (pasteResult.err) { + dialog.errorNotification( + `Failed to send exercise to TMC Paste: ${pasteResult.val.message}.`, + pasteResult.val, + ); + return pasteResult; + } + + const pasteLink = pasteResult.val; + if (pasteLink === "") { + const message = "Didn't receive paste link from server."; + return new Err(new Error(`Failed to send exercise to TMC Paste: ${message}`)); + } + + return new Ok(pasteLink); +} + +export async function pasteMoocExercise( actionContext: ActionContext, courseSlug: string, exerciseName: string, ): Promise> { - const { tmc, userData, workspaceManager, dialog } = actionContext; - if (!(tmc.ok && userData.ok && workspaceManager.ok)) { + const { langs, userData, workspaceManager, dialog } = actionContext; + if (!(langs.ok && userData.ok && workspaceManager.ok)) { return new Err(new Error("Extension was not initialized properly")); } - const exerciseId = userData.val.getExerciseByName(courseSlug, exerciseName)?.id; - const exercisePath = workspaceManager.val.getExerciseBySlug(courseSlug, exerciseName)?.uri - .fsPath; + const exerciseId = userData.val.getMoocExerciseByName(courseSlug, exerciseName)?.id; + const exercisePath = workspaceManager.val.getExerciseBySlug("tmc", courseSlug, exerciseName) + ?.uri.fsPath; if (!exerciseId || !exercisePath) { return Err(new Error("Failed to resolve exercise id")); } - const pasteResult = await tmc.val.submitExerciseToPaste(exerciseId, exercisePath); + const pasteResult = await langs.val.submitMoocExerciseToPaste(exerciseId, exercisePath); if (pasteResult.err) { dialog.errorNotification( `Failed to send exercise to TMC Paste: ${pasteResult.val.message}.`, @@ -313,48 +364,53 @@ export async function pasteExercise( */ export async function checkForCourseUpdates( actionContext: ActionContext, - courseId?: number, + courseId?: CourseIdentifier, ): Promise { const { dialog, userData } = actionContext; if (userData.err) { Logger.error("Extension was not initialized properly"); return; } - const courses = courseId ? [userData.val.getCourse(courseId)] : userData.val.getCourses(); - const filteredCourses = courses.filter((c) => c.notifyAfter <= Date.now()); - Logger.info(`Checking for course updates for courses ${filteredCourses.map((c) => c.name)}`); + + const filteredCourses = courses.filter((c) => c.data.notifyAfter <= Date.now()); + Logger.info(`Checking for course updates for courses`); const updatedCourses: LocalCourseData[] = []; for (const course of filteredCourses) { - await updateCourse(actionContext, course.id); - updatedCourses.push(userData.val.getCourse(course.id)); + const courseId = LocalCourseData.getCourseId(course); + await updateCourse(actionContext, courseId); + updatedCourses.push(userData.val.getCourse(courseId)); } const handleDownload = async (course: LocalCourseData): Promise => { - const downloadResult = await downloadNewExercisesForCourse(actionContext, course.id); + const courseId = LocalCourseData.getCourseId(course); + const downloadResult = await downloadNewExercisesForCourse(actionContext, courseId); if (downloadResult.err) { dialog.errorNotification( - `Failed to download new exercises for course "${course.title}."`, + `Failed to download new exercises for course"`, downloadResult.val, ); } }; for (const course of updatedCourses) { - if (course.newExercises.length > 0 && !course.disabled) { + const newExercises = LocalCourseData.getNewExercises(course); + if (newExercises.length > 0 && !course.data.disabled) { + const courseId = LocalCourseData.getCourseId(course); + const courseName = LocalCourseData.getCourseName(course); dialog.notification( - `Found ${course.newExercises.length} new exercises for ${course.name}. Do you wish to download them now?`, + `Found ${newExercises.length} new exercises for ${courseName}. Do you wish to download them now?`, ["Download", async (): Promise => handleDownload(course)], [ "Remind me later", (): void => { - userData.val.setNotifyDate(course.id, Date.now() + NOTIFICATION_DELAY); + userData.val.setNotifyDate(courseId, Date.now() + NOTIFICATION_DELAY); }, ], [ "Don't remind about these exercises", (): void => { - userData.val.clearFromNewExercises(course.id); + userData.val.clearFromNewExercises(courseId); }, ], ); @@ -418,20 +474,24 @@ export async function openWorkspace(actionContext: ActionContext, name: string): * * @param id ID of the course to remove */ -export async function removeCourse(actionContext: ActionContext, id: number): Promise { - const { tmc, ui, userData, workspaceManager, dialog } = actionContext; - if (!(tmc.ok && userData.ok && workspaceManager.ok)) { +export async function removeCourse( + actionContext: ActionContext, + id: CourseIdentifier, +): Promise { + const { langs, ui, userData, workspaceManager, dialog } = actionContext; + if (!(langs.ok && userData.ok && workspaceManager.ok)) { Logger.error("Extension was not initialized properly"); return; } const course = userData.val.getCourse(id); - Logger.info(`Closing exercises for ${course.name} and removing course data from userData`); + const courseName = LocalCourseData.getCourseName(course); + Logger.info(`Closing exercises for ${courseName} and removing course data from userData`); - const unsetResult = await tmc.val.unsetSetting(`closed-exercises-for:${course.name}`); + const unsetResult = await langs.val.unsetSetting(`closed-exercises-for:${courseName}`); if (unsetResult.err) { dialog.errorNotification( - `Failed to remove TMC-langs data for "${course.name}:"`, + `Failed to remove TMC-langs data for "${courseName}:"`, unsetResult.val, ); } @@ -439,7 +499,7 @@ export async function removeCourse(actionContext: ActionContext, id: number): Pr userData.val.deleteCourse(id); ui.treeDP.removeChildWithId("myCourses", id.toString()); - if (workspaceManager.val.activeCourse === course.name) { + if (workspaceManager.val.activeCourse === courseName) { Logger.info("Closing course workspace because it was removed."); await vscode.commands.executeCommand("workbench.action.closeFolder"); } diff --git a/src/actions/webview.ts b/src/actions/webview.ts index b39edb38..eef42e8f 100644 --- a/src/actions/webview.ts +++ b/src/actions/webview.ts @@ -9,7 +9,17 @@ import { ExtensionContext } from "vscode"; import { ExerciseStatus } from "../api/workspaceManager"; import { randomPanelId, TmcPanel } from "../panels/TmcPanel"; import { Exercise } from "../shared/langsSchema"; -import { ExtensionToWebview, MyCoursesPanel, Panel } from "../shared/shared"; +import { + ExerciseIdentifier, + ExtensionToWebview, + makeMoocKind, + makeTmcKind, + MyCoursesPanel, + Panel, + LocalCourseData, + CourseIdentifier, + match, +} from "../shared/shared"; import * as UITypes from "../ui/types"; import { dateToString, Logger, parseDate, parseNextDeadlineAfter } from "../utilities/"; @@ -23,9 +33,9 @@ export async function displayUserCourses( context: ExtensionContext, actionContext: ActionContext, ): Promise { - const { userData, tmc } = actionContext; + const { userData, langs } = actionContext; Logger.info("Displaying My Courses view"); - if (!(userData.ok && tmc.ok)) { + if (!(userData.ok && langs.ok)) { Logger.error("Extension was not initialized properly"); return; } @@ -40,14 +50,14 @@ export async function displayUserCourses( const newExercisesCourses: ExtensionToWebview[] = courses.map((c) => ({ type: "setNewExercises", target: panel, - courseId: c.id, - exerciseIds: c.disabled ? [] : c.newExercises, + courseId: LocalCourseData.getCourseId(c), + exerciseIds: c.data.disabled ? [] : c.data.newExercises.map(ExerciseIdentifier.from), })); const disabledStatusCourses: ExtensionToWebview[] = courses.map((c) => ({ type: "setCourseDisabledStatus", target: panel, - courseId: c.id, - disabled: c.disabled, + courseId: LocalCourseData.getCourseId(c), + disabled: c.data.disabled, })); TmcPanel.renderMain(context.extensionUri, context, actionContext, panel); @@ -56,8 +66,8 @@ export async function displayUserCourses( const now = new Date(); courses.forEach(async (course) => { - const courseId = course.id; - const exercises: Exercise[] = (await tmc.val.getCourseDetails(courseId)) + const courseId = LocalCourseData.getCourseId(course); + const exercises: Exercise[] = (await langs.val.getCourseDetails(courseId)) .map((x) => x.exercises) .unwrapOr([]); @@ -78,7 +88,7 @@ export async function displayUserCourses( TmcPanel.postMessage({ type: "setNextCourseDeadline", target: panel, - courseId: course.id, + courseId: LocalCourseData.getCourseId(course), deadline, }); }); @@ -90,7 +100,7 @@ export async function displayUserCourses( export async function displayLocalCourseDetails( context: ExtensionContext, actionContext: ActionContext, - courseId: number, + courseId: CourseIdentifier, ): Promise { const { userData, workspaceManager } = actionContext; if (!(userData.ok && workspaceManager.ok)) { @@ -98,10 +108,10 @@ export async function displayLocalCourseDetails( return; } const course = userData.val.getCourse(courseId); - Logger.info(`Display course view for ${course.name}`); + Logger.info(`Display course view for ${LocalCourseData.getCourseName(course)}`); const mapStatus = ( - exerciseId: number, + exerciseId: ExerciseIdentifier, status: ExerciseStatus, expired: boolean, ): UITypes.ExerciseStatus => { @@ -121,49 +131,67 @@ export async function displayLocalCourseDetails( const initialState: UITypes.WebviewMessage[] = [ { command: "setCourseDisabledStatus", - courseId: course.id, - disabled: course.disabled, + courseId, + disabled: match( + course, + (tmc) => tmc.disabled, + (mooc) => mooc.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.val.getExerciseBySlug(course.name, ex.name); - const softDeadline = ex.softDeadline ? parseDate(ex.softDeadline) : null; - const hardDeadline = ex.deadline ? parseDate(ex.deadline) : null; - initialState.push({ - command: "exerciseStatusChange", - exerciseId: ex.id, - status: mapStatus( - ex.id, - 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, - }; - - exerciseData.set(groupName, { - name: groupName, - nextDeadlineString: "", - exercises: group?.exercises.concat(entry) || [entry], - }); - }); + match( + course, + (tmc) => + tmc.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.val.getExerciseBySlug( + "tmc", + LocalCourseData.getCourseName(course), + ex.name, + ); + const softDeadline = ex.softDeadline ? parseDate(ex.softDeadline) : null; + const hardDeadline = ex.deadline ? parseDate(ex.deadline) : null; + initialState.push({ + command: "exerciseStatusChange", + exerciseId: ex.id, + status: mapStatus( + makeTmcKind({ tmcExerciseId: ex.id }), + exData?.status ?? ExerciseStatus.Missing, + hardDeadline !== null && currentDate >= hardDeadline, + ), + }); + const entry: UITypes.CourseDetailsExercise = { + id: makeTmcKind({ tmcExerciseId: ex.id }), + name, + passed: tmc.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], + }); + }), + (mooc) => {}, + ); const panel: Panel = { type: "CourseDetails", id: randomPanelId(), - courseId: course.id, + courseId, + exerciseGroups: [], + exerciseStatuses: { + tmc: {}, + mooc: {}, + }, }; TmcPanel.renderMain(context.extensionUri, context, actionContext, panel); diff --git a/src/actions/workspace.ts b/src/actions/workspace.ts index 49e0e8b3..1f4575f4 100644 --- a/src/actions/workspace.ts +++ b/src/actions/workspace.ts @@ -10,7 +10,15 @@ import { compact } from "lodash"; import { Err, Ok, Result } from "ts-results"; import { ExerciseStatus } from "../api/workspaceManager"; import { randomPanelId, TmcPanel } from "../panels/TmcPanel"; -import { CourseDetailsPanel, ExtensionToWebview } from "../shared/shared"; +import { + CourseDetailsPanel, + CourseIdentifier, + ExerciseIdentifier, + ExtensionToWebview, + LocalCourseData, + LocalCourseExercise, + match, +} from "../shared/shared"; import { Logger } from "../utilities"; import * as systeminformation from "systeminformation"; import { ActionContext } from "./types"; @@ -22,23 +30,33 @@ import { ActionContext } from "./types"; export async function openExercises( context: vscode.ExtensionContext, actionContext: ActionContext, - exerciseIdsToOpen: number[], - courseName: string, -): Promise> { + exerciseIdsToOpen: ExerciseIdentifier[], + courseId: CourseIdentifier, +): Promise, Error>> { Logger.info("Opening exercises", exerciseIdsToOpen); - const { workspaceManager, userData, tmc, dialog } = actionContext; - if (!(userData.ok && workspaceManager.ok && tmc.ok)) { + const { workspaceManager, userData, langs, dialog } = actionContext; + if (!(userData.ok && workspaceManager.ok && langs.ok)) { return Err(new Error("Extension was not initialized properly")); } - const course = userData.val.getCourseByName(courseName); - const courseExercises = new Map(course.exercises.map((x) => [x.id, x])); - const exercisesToOpen = compact(exerciseIdsToOpen.map((x) => courseExercises.get(x))); + const course = userData.val.getCourse(courseId); + const courseExercises = new Map( + LocalCourseData.getExercises(course).map((x) => [x.data.id, x]), + ); + const exercisesToOpen = compact( + exerciseIdsToOpen.map((x) => courseExercises.get(ExerciseIdentifier.unwrap(x))), + ); + const courseName = match( + course, + (tmc) => tmc.name, + (mooc) => mooc.courseName, + ); const openResult = await workspaceManager.val.openCourseExercises( + course.kind, courseName, - exercisesToOpen.map((e) => e.name), + exercisesToOpen.map(LocalCourseExercise.getSlug), ); if (openResult.err) { return openResult; @@ -48,7 +66,7 @@ export async function openExercises( .getExercisesByCourseSlug(courseName) .filter((x) => x.status === ExerciseStatus.Closed) .map((x) => x.exerciseSlug); - const settingsResult = await tmc.val.setSetting( + const settingsResult = await langs.val.setSetting( `closed-exercises-for:${courseName}`, closedExerciseNames, ); @@ -73,7 +91,12 @@ export async function openExercises( const panel: CourseDetailsPanel = { id: randomPanelId(), type: "CourseDetails", - courseId: course.id, + courseId, + exerciseGroups: [], + exerciseStatuses: { + tmc: {}, + mooc: {}, + }, }; TmcPanel.renderMain(context.extensionUri, context, actionContext, panel); }, @@ -101,31 +124,55 @@ export async function openExercises( */ export async function closeExercises( actionContext: ActionContext, - ids: number[], - courseName: string, -): Promise> { - const { workspaceManager, userData, tmc } = actionContext; - if (!(userData.ok && workspaceManager.ok && tmc.ok)) { + ids: Array, + courseId: CourseIdentifier, +): Promise, Error>> { + const { workspaceManager, userData, langs } = actionContext; + if (!(userData.ok && workspaceManager.ok && langs.ok)) { return Err(new Error("Extension was not initialized properly")); } - const course = userData.val.getCourseByName(courseName); - const exercises = new Map(course.exercises.map((x) => [x.id, x])); - const exerciseSlugs = compact(ids.map((x) => exercises.get(x)?.name)); + const course = userData.val.getCourse(courseId); + const exercises = new Map(LocalCourseData.getExercises(course).map((x) => [x.data.id, x])); + const exerciseSlugs = compact( + ids.map((x) => { + const exercise = exercises.get(ExerciseIdentifier.unwrap(x)); + if (!exercise) { + return undefined; + } + return match( + exercise, + (tmc) => tmc.name, + (mooc) => mooc.id, + ); + }), + ); - const closeResult = await workspaceManager.val.closeCourseExercises(courseName, exerciseSlugs); + const courseName = LocalCourseData.getCourseName(course); + const closeResult = await workspaceManager.val.closeCourseExercises( + course.kind, + courseName, + exerciseSlugs, + ); if (closeResult.err) { return closeResult; } - const slugToId = new Map(Array.from(exercises.entries(), ([key, val]) => [val.name, key])); - const closedIds = closeResult.val.map((exercise) => slugToId.get(exercise.exerciseSlug) || 0); + const slugToId = new Map( + Array.from(exercises.entries(), ([key, val]) => [ + LocalCourseExercise.getSlug(val), + ExerciseIdentifier.from(key), + ]), + ); + const closedIds = closeResult.val + .map((exercise) => slugToId.get(exercise.exerciseSlug)) + .filter((e) => e !== undefined); const closedExerciseNames = workspaceManager.val .getExercisesByCourseSlug(courseName) .filter((x) => x.status === ExerciseStatus.Closed) .map((x) => x.exerciseSlug); - const settingsResult = await tmc.val.setSetting( + const settingsResult = await langs.val.setSetting( `closed-exercises-for:${courseName}`, closedExerciseNames, ); diff --git a/src/api/exerciseDecorationProvider.ts b/src/api/exerciseDecorationProvider.ts index 3284f6bf..aa8cbce2 100644 --- a/src/api/exerciseDecorationProvider.ts +++ b/src/api/exerciseDecorationProvider.ts @@ -54,7 +54,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 81% rename from src/api/tmc.ts rename to src/api/langs.ts index c06c3f7e..837ec77b 100644 --- a/src/api/tmc.ts +++ b/src/api/langs.ts @@ -28,7 +28,9 @@ import { CourseData, CourseDetails, CourseExercise, + CourseInstance, DataKind, + DownloadOrUpdateMoocCourseExercisesResult, DownloadOrUpdateTmcCourseExercisesResult, ExerciseDetails, LocalTmcExercise, @@ -40,12 +42,21 @@ import { Submission, SubmissionFeedbackResponse, SubmissionFinished, + TmcExerciseSlide, + TmcExerciseTask, UpdatedExercise, } from "../shared/langsSchema"; import { Logger } from "../utilities/logger"; import { SubmissionFeedback } from "./types"; -import { BaseError } from "../shared/shared"; +import { + assertUnreachable, + BaseError, + CourseIdentifier, + ExerciseIdentifier, + makeTmcKind, + match, +} from "../shared/shared"; interface Options { apiCacheLifetime?: string; @@ -90,10 +101,11 @@ interface CacheConfig { } /** - * A Class that provides an interface to all TMC services. + * A Class that provides an interface to all langs functionality. */ -export default class TMC { +export default class Langs { private static readonly _exerciseUpdatesCacheKey = "exercise-updates"; + private static readonly _moocExerciseUpdatesCacheKey = "mooc-exercise-updates"; private _nextSubmissionAllowedTimestamp: number; private readonly _options: Options; @@ -235,6 +247,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( @@ -245,6 +258,8 @@ export default class TMC { this.clientName, "--course-slug", courseSlug, + "--course-type", + courseKind, ], }, "local-tmc-exercises", @@ -440,7 +455,7 @@ export default class TMC { * Checks for updates for all exercises in this client's context. Uses TMC-langs * `check-exercise-updates` core command internally. */ - public async checkExerciseUpdates( + public async checkTmcExerciseUpdates( options?: CacheOptions, ): Promise, Error>> { const res = await this._executeLangsCommand( @@ -448,7 +463,20 @@ export default class TMC { args: this._tmcCmd("check-exercise-updates"), }, "updated-exercises", - { forceRefresh: options?.forceRefresh, key: TMC._exerciseUpdatesCacheKey }, + { forceRefresh: options?.forceRefresh, key: Langs._exerciseUpdatesCacheKey }, + ); + return res.map((x) => x.data["output-data"]); + } + + public async checkMoocExerciseUpdates( + options?: CacheOptions, + ): Promise, Error>> { + const res = await this._executeLangsCommand( + { + args: this._moocCmd("check-exercise-updates"), + }, + "mooc-updated-exercises", + { forceRefresh: options?.forceRefresh, key: Langs._moocExerciseUpdatesCacheKey }, ); return res.map((x) => x.data["output-data"]); } @@ -461,40 +489,104 @@ export default class TMC { * @param downloadTemplate Flag for downloading exercise template instead of latest submission. */ public async downloadExercises( - ids: number[], + ids: ExerciseIdentifier[], downloadTemplate: boolean, - onDownloaded: (value: { id: number; percent: number; message?: string }) => void, - ): Promise> { + onDownloaded: (value: { + id: ExerciseIdentifier; + percent: number; + message?: string; + }) => void, + ): Promise< + Result< + [DownloadOrUpdateTmcCourseExercisesResult, DownloadOrUpdateMoocCourseExercisesResult], + Error + > + > { const onStdout = (res: StatusUpdateData): void => { if ( res["update-data-kind"] === "client-update-data" && res.data?.["client-update-data-kind"] === "exercise-download" ) { onDownloaded({ - id: res.data.id, + id: makeTmcKind({ tmcExerciseId: res.data.id }), percent: res["percent-done"], message: res.message ?? undefined, }); } }; const downloadTemplateArg = downloadTemplate ? ["--download-template"] : []; - const res = await this._executeLangsCommand( - { - args: this._tmcCmd( - "download-or-update-course-exercises", - ...downloadTemplateArg, - "--exercise-id", - ...ids.map((id) => id.toString()), + const tmcIds = ids + .map((id) => + match( + id, + (tmc) => tmc.tmcExerciseId, + (mooc) => null, ), - onStdout, - }, - "tmc-exercise-download", - ); - return res.andThen((x) => { - // Invalidate exercise update cache - this._responseCache.delete(TMC._exerciseUpdatesCacheKey); - return Ok(x.data["output-data"]); - }); + ) + .filter((id) => id !== null); + const moocIds = ids + .map((id) => + match( + id, + (tmc) => null, + (mooc) => mooc.moocExerciseId, + ), + ) + .filter((id) => id !== null); + + let tmcOutputData = null; + if (tmcIds.length < 0) { + const tmcRes = await this._executeLangsCommand( + { + args: this._tmcCmd( + "download-or-update-course-exercises", + ...downloadTemplateArg, + "--exercise-id", + ...tmcIds.map((id) => id.toString()), + ), + onStdout, + }, + "tmc-exercise-download", + ); + const tmcMappedRes = tmcRes.andThen((x) => { + // Invalidate exercise update cache + this._responseCache.delete(Langs._exerciseUpdatesCacheKey); + return Ok(x.data["output-data"]); + }); + if (tmcMappedRes.err) { + return Err(tmcMappedRes.val); + } + tmcOutputData = tmcMappedRes.val; + } + + let moocOutputData = null; + if (moocIds.length < 0) { + const moocRes = await this._executeLangsCommand( + { + args: this._moocCmd( + "download-or-update-course-exercises", + ...downloadTemplateArg, + "--exercise-id", + ...moocIds, + ), + onStdout, + }, + "mooc-exercise-download", + ); + const moocMappedRes = moocRes.andThen((x) => { + // Invalidate exercise update cache + this._responseCache.delete(Langs._exerciseUpdatesCacheKey); + return Ok(x.data["output-data"]); + }); + if (moocMappedRes.err) { + return Err(moocMappedRes.val); + } + moocOutputData = moocMappedRes.val; + } + + const tmcExercises = tmcOutputData ?? { downloaded: [], skipped: [], failed: [] }; + const moocExercises = moocOutputData ?? { downloaded: [], skipped: [], failed: [] }; + return Ok([tmcExercises, moocExercises]); } /** @@ -506,7 +598,7 @@ export default class TMC { * @param submissionId Id of the exercise submission to download. * @param saveOldState Whether to submit the current state of the exercise beforehand. */ - public async downloadOldSubmission( + public async downloadTmcOldSubmission( exerciseId: number, exercisePath: string, submissionId: number, @@ -529,6 +621,29 @@ export default class TMC { return res.err ? res : Ok.EMPTY; } + public async downloadMoocOldSubmission( + exerciseId: string, + exercisePath: string, + submissionId: string, + saveOldState: boolean, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + progressCallback?: (downloadedPct: number, increment: number) => void, + ): Promise> { + const saveOldStateArg = saveOldState ? ["--save-old-state"] : []; + const args = this._moocCmd( + "download-old-submission", + "--submission-id", + submissionId, + ...saveOldStateArg, + "--exercise-id", + exerciseId, + "--output-path", + exercisePath, + ); + const res = await this._executeLangsCommand({ args }, null); + return res.err ? res : Ok.EMPTY; + } + /** * Gets all courses of the given organization. Results may vary depending on the user account's * priviledges. Uses TMC-langs `get-courses` core command internally. @@ -560,7 +675,7 @@ export default class TMC { * @param courseId Id to the course. * @returns A combination of getCourseDetails, getCourseExercises, getCourseSettings. */ - public async getCourseData( + public async getTmcCourseData( courseId: number, options?: CacheOptions, ): Promise> { @@ -603,6 +718,12 @@ export default class TMC { return res.map((x) => x.data["output-data"]); } + public async getMoocCourseInstanceData( + courseId: string, + ): Promise], Error>> { + throw "asd"; + } + /** * Gets user-specific details of the given course. Uses TMC-langs `get-course-details` core * command internally. @@ -611,17 +732,30 @@ export default class TMC { * @returns Details of the course. */ public async getCourseDetails( - courseId: number, + courseId: CourseIdentifier, options?: CacheOptions, ): Promise> { - const res = await this._executeLangsCommand( - { - args: this._tmcCmd("get-course-details", "--course-id", courseId.toString()), - }, - "course-details", - { forceRefresh: options?.forceRefresh, key: `course-${courseId}-details` }, - ); - return res.map((x) => x.data["output-data"]); + if (courseId.kind === "tmc") { + const res = await this._executeLangsCommand( + { + args: this._tmcCmd("get-course-details", "--course-id", courseId.toString()), + }, + "course-details", + { forceRefresh: options?.forceRefresh, key: `course-${courseId}-details` }, + ); + return res.map((x) => x.data["output-data"]); + } else if (courseId.kind === "mooc") { + const res = await this._executeLangsCommand( + { + args: this._moocCmd("get-course-details", "--course-id", courseId.toString()), + }, + "course-details", + { forceRefresh: options?.forceRefresh, key: `course-${courseId}-details` }, + ); + return res.map((x) => x.data["output-data"]); + } else { + assertUnreachable(courseId); + } } /** @@ -694,7 +828,7 @@ export default class TMC { * @param exerciseId Id of the exercise. * @returns Array of old submissions. */ - public async getOldSubmissions(exerciseId: number): Promise> { + public async getTmcOldSubmissions(exerciseId: number): Promise> { const res = await this._executeLangsCommand( { args: this._tmcCmd( @@ -708,6 +842,16 @@ export default class TMC { return res.map((x) => x.data["output-data"]); } + public async getMoocOldSubmissions(exerciseId: string): Promise> { + const res = await this._executeLangsCommand( + { + args: this._moocCmd("get-exercise-submissions", "--exercise-id", exerciseId), + }, + "submissions", + ); + return res.map((x) => x.data["output-data"]); + } + /** * Gets data of the given organization. Uses TMC-langs `get-organization` core command * internally. @@ -734,7 +878,9 @@ export default class TMC { * * @returns A list of organizations. */ - public async getOrganizations(options?: CacheOptions): Promise> { + public async getTmcOrganizations( + options?: CacheOptions, + ): Promise> { const remapper: CacheConfig["remapper"] = (res) => { if (res.data?.["output-data-kind"] === "organizations") { return res.data["output-data"].map((x) => [ @@ -755,6 +901,29 @@ export default class TMC { return res.map((x) => x.data["output-data"]); } + public async getMoocOrganizations( + options?: CacheOptions, + ): Promise> { + const remapper: CacheConfig["remapper"] = (res) => { + if (res.data?.["output-data-kind"] === "organizations") { + return res.data["output-data"].map((x) => [ + `organization-${x.slug}`, + { ...res, data: { "output-data-kind": "organization", "output-data": x } }, + ]); + } else { + return []; + } + }; + const res = await this._executeLangsCommand( + { + args: this._moocCmd("get-organizations"), + }, + "organizations", + { forceRefresh: options?.forceRefresh, key: "organizations", remapper }, + ); + return res.map((x) => x.data["output-data"]); + } + /** * Reverts given exercise to its original template. Optionally submits the current state * of the exercise beforehand. Uses TMC-langs `reset-exercise` core command internally. @@ -763,7 +932,7 @@ export default class TMC { * @param saveOldState Whether to submit current state of the exercise before reseting it. */ public async resetExercise( - exerciseId: number, + exerciseId: ExerciseIdentifier, exercisePath: string, saveOldState: boolean, ): Promise> { @@ -772,7 +941,7 @@ export default class TMC { "reset-exercise", ...saveOldStateArg, "--exercise-id", - exerciseId.toString(), + ExerciseIdentifier.toString(exerciseId), "--exercise-path", exercisePath, ); @@ -791,7 +960,7 @@ export default class TMC { * @param progressCallback Optional callback function that can be used to get status reports. */ public async submitExerciseAndWaitForResults( - exerciseId: number, + exerciseId: ExerciseIdentifier, exercisePath: string, progressCallback?: (progressPct: number, message?: string) => void, onSubmissionUrl?: (url: string) => void, @@ -820,7 +989,7 @@ export default class TMC { "--submission-path", exercisePath, "--exercise-id", - exerciseId.toString(), + ExerciseIdentifier.toString(exerciseId), ), onStdout, }, @@ -839,7 +1008,7 @@ export default class TMC { * @param exerciseId Id of the exercise. * @returns TMC paste link. */ - public async submitExerciseToPaste( + public async submitTmcExerciseToPaste( exerciseId: number, exercisePath: string, ): Promise> { @@ -863,6 +1032,30 @@ export default class TMC { ); return res.map((x) => x.data["output-data"].paste_url); } + public async submitMoocExerciseToPaste( + exerciseId: string, + exercisePath: string, + ): Promise> { + const now = Date.now(); + if (now < this._nextSubmissionAllowedTimestamp) { + return Err(new BottleneckError("This command can't be executed at the moment.")); + } else { + this._nextSubmissionAllowedTimestamp = now + MINIMUM_SUBMISSION_INTERVAL; + } + const res = await this._executeLangsCommand( + { + args: this._moocCmd( + "paste", + "--exercise-id", + exerciseId.toString(), + "--submission-path", + exercisePath, + ), + }, + "new-submission", + ); + return res.map((x) => x.data["output-data"].paste_url); + } /** * Submits feedback for an exercise. Uses TMC-langs `send-feedback` core command internally. @@ -888,6 +1081,24 @@ 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. * @@ -981,7 +1192,10 @@ export default class TMC { 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 BaseError("Unexpected data in error response")); } @@ -1149,8 +1363,8 @@ ${error.message}`; ); Logger.debug(data); } - } catch (_e) { - Logger.warn("Failed to parse TMC-langs output"); + } catch (e) { + Logger.warn(`Failed to parse TMC-langs output`, e); Logger.debug(part); } } diff --git a/src/api/storage.ts b/src/api/storage.ts index 12f98f52..a93f712e 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -1,5 +1,7 @@ import * as vscode from "vscode"; +import { LocalMoocCourseData, LocalTmcCourseData } from "../shared/shared"; + export interface ExtensionSettings { downloadOldSubmission: boolean; hideMetaFiles: boolean; @@ -8,35 +10,9 @@ export interface ExtensionSettings { updateExercisesAutomatically: boolean; } -export interface LocalCourseData { - id: number; - name: string; - title: string; - description: string; - organization: string; - exercises: LocalCourseExercise[]; - availablePoints: number; - awardedPoints: number; - perhapsExamMode: boolean; - newExercises: number[]; - notifyAfter: number; - disabled: boolean; - materialUrl: string | null; -} - -export interface LocalCourseExercise { - id: number; - availablePoints: number; - awardedPoints: number; - /// Equivalent to exercise slug - name: string; - deadline: string | null; - passed: boolean; - softDeadline: string | null; -} - export interface UserData { - courses: LocalCourseData[]; + tmcCourses: LocalTmcCourseData[]; + moocCourses: LocalMoocCourseData[]; } export interface SessionState { @@ -48,7 +24,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/api/workspaceManager.ts b/src/api/workspaceManager.ts index 00f4ad6d..4384f47e 100644 --- a/src/api/workspaceManager.ts +++ b/src/api/workspaceManager.ts @@ -23,6 +23,7 @@ export enum ExerciseStatus { } export interface WorkspaceExercise { + backend: "tmc" | "mooc"; courseSlug: string; exerciseSlug: string; status: ExerciseStatus; @@ -123,11 +124,15 @@ export default class WorkspaceManager implements vscode.Disposable { } public getExerciseBySlug( + backend: "tmc" | "mooc", courseSlug: string, exerciseSlug: string, ): Readonly | undefined { return this._exercises.find( - (x) => x.courseSlug === courseSlug && x.exerciseSlug === exerciseSlug, + (x) => + x.backend === backend && + x.courseSlug === courseSlug && + x.exerciseSlug === exerciseSlug, ); } @@ -158,11 +163,16 @@ export default class WorkspaceManager implements vscode.Disposable { } public openCourseExercises( + backend: "tmc" | "mooc", courseSlug: string, exerciseSlugs: string[], ): Promise> { this._exercises.forEach((x) => { - if (x.courseSlug === courseSlug && exerciseSlugs.includes(x.exerciseSlug)) { + if ( + x.backend === backend && + x.courseSlug === courseSlug && + exerciseSlugs.includes(x.exerciseSlug) + ) { x.status = ExerciseStatus.Open; } }); @@ -171,12 +181,17 @@ export default class WorkspaceManager implements vscode.Disposable { } public async closeCourseExercises( + backend: "tmc" | "mooc", courseSlug: string, exerciseSlugs: string[], ): Promise, Error>> { const closedExercises: Array = []; this._exercises.forEach((x) => { - if (x.courseSlug === courseSlug && exerciseSlugs.includes(x.exerciseSlug)) { + if ( + x.backend === backend && + x.courseSlug === courseSlug && + exerciseSlugs.includes(x.exerciseSlug) + ) { x.status = ExerciseStatus.Closed; closedExercises.push(x); } diff --git a/src/commands/addNewCourse.ts b/src/commands/addNewCourse.ts index 1f26e413..ce6d1388 100644 --- a/src/commands/addNewCourse.ts +++ b/src/commands/addNewCourse.ts @@ -1,43 +1,64 @@ import * as actions from "../actions"; import { ActionContext } from "../actions/types"; +import { Organization } from "../shared/langsSchema"; +import { CourseIdentifier, Enum, makeMoocKind, makeTmcKind, match } from "../shared/shared"; import { Logger } from "../utilities"; export async function addNewCourse(actionContext: ActionContext): Promise { - const { dialog, tmc } = actionContext; + const { dialog, langs } = actionContext; Logger.info("Adding new course"); - if (tmc.err) { + if (langs.err) { Logger.error("Extension was not initialized properly"); return; } - const organizationsResult = await tmc.val.getOrganizations(); - if (organizationsResult.err) { - dialog.errorNotification("Failed to fetch organizations.", organizationsResult.val); + const tmcOrganizationsResult = await langs.val.getTmcOrganizations(); + const moocOrganizationsResult = await langs.val.getMoocOrganizations(); + if (tmcOrganizationsResult.err) { + dialog.errorNotification("Failed to fetch organizations.", tmcOrganizationsResult.val); + return; + } + if (moocOrganizationsResult.err) { + dialog.errorNotification("Failed to fetch organizations.", moocOrganizationsResult.val); return; } + const organizations: Array> = [ + ...tmcOrganizationsResult.val.map(makeTmcKind), + ...moocOrganizationsResult.val.map(makeMoocKind), + ]; const chosenOrg = await dialog.selectItem( "Which organization?", - ...organizationsResult.val.map<[string, string]>((org) => [org.name, org.slug]), + ...organizations.map<[string, Enum]>((org) => [ + org.data.name, + org, + ]), ); if (chosenOrg === undefined) { return; } - const courses = await tmc.val.getCourses(chosenOrg); + const courses = await match( + chosenOrg, + (tmc) => langs.val.getCourses(tmc.slug), + (mooc) => langs.val.getCourses(mooc.slug), + ); if (courses.err) { dialog.errorNotification(`Failed to fetch organization courses for ${chosenOrg}.`); return; } - const chosenCourse = await dialog.selectItem( + const chosenCourse = await dialog.selectItem( "Which course?", - ...courses.val.map<[string, number]>((course) => [course.title, course.id]), + ...courses.val.map<[string, CourseIdentifier]>((course) => [ + course.title, + makeTmcKind({ courseId: course.id }), + ]), ); if (chosenCourse === undefined) { return; } - const result = await actions.addNewCourse(actionContext, chosenOrg, chosenCourse); + const result = await actions.addNewCourse(actionContext, chosenOrg.data.slug, chosenCourse); if (result.err) { dialog.errorNotification("Failed to add course.", result.val); } diff --git a/src/commands/cleanExercise.ts b/src/commands/cleanExercise.ts index b2ea39bc..8194d391 100644 --- a/src/commands/cleanExercise.ts +++ b/src/commands/cleanExercise.ts @@ -10,9 +10,9 @@ export async function cleanExercise( actionContext: ActionContext, resource: vscode.Uri | undefined, ): Promise { - const { dialog, tmc, workspaceManager } = actionContext; + const { dialog, langs, workspaceManager } = actionContext; Logger.info("Cleaning exercise"); - if (!(workspaceManager.ok && tmc.ok)) { + if (!(workspaceManager.ok && langs.ok)) { Logger.error("Extension was not initialized properly"); return; } @@ -28,7 +28,7 @@ export async function cleanExercise( return; } - const cleanResult = await tmc.val.clean(exerciseToClean.fsPath); + const cleanResult = await langs.val.clean(exerciseToClean.fsPath); if (cleanResult.err) { dialog.errorNotification("Failed to clean exercise.", cleanResult.val); } diff --git a/src/commands/closeExercise.ts b/src/commands/closeExercise.ts index 26dcf09c..bf357166 100644 --- a/src/commands/closeExercise.ts +++ b/src/commands/closeExercise.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import * as actions from "../actions"; import { ActionContext } from "../actions/types"; import { Logger } from "../utilities"; +import { LocalCourseData, LocalCourseExercise } from "../shared/shared"; export async function closeExercise( actionContext: ActionContext, @@ -23,10 +24,11 @@ export async function closeExercise( return; } - const exerciseId = userData.val.getExerciseByName( + const localExercise = userData.val.getExerciseByName( exercise.courseSlug, exercise.exerciseSlug, - )?.id; + ); + const exerciseId = localExercise ? LocalCourseExercise.getId(localExercise) : undefined; if ( exerciseId && (userData.val.getPassed(exerciseId) || @@ -34,11 +36,9 @@ export async function closeExercise( `Are you sure you want to close uncompleted exercise ${exercise.exerciseSlug}?`, ))) ) { - const result = await actions.closeExercises( - actionContext, - [exerciseId], - exercise.courseSlug, - ); + const course = userData.val.getCourseBySlug(exercise.courseSlug); + const courseId = LocalCourseData.getCourseId(course); + const result = await actions.closeExercises(actionContext, [exerciseId], courseId); if (result.err) { dialog.errorNotification("Error when closing exercise.", result.val); return; diff --git a/src/commands/downloadNewExercises.ts b/src/commands/downloadNewExercises.ts index a006ce9d..6d3a9454 100644 --- a/src/commands/downloadNewExercises.ts +++ b/src/commands/downloadNewExercises.ts @@ -1,5 +1,6 @@ import * as actions from "../actions"; import { ActionContext } from "../actions/types"; +import { CourseIdentifier, LocalCourseData } from "../shared/shared"; import { Logger } from "../utilities"; export async function downloadNewExercises(actionContext: ActionContext): Promise { @@ -13,25 +14,28 @@ export async function downloadNewExercises(actionContext: ActionContext): Promis const courses = userData.val.getCourses(); const courseId = await dialog.selectItem( "Download new exercises for course?", - ...courses.map<[string, number]>((course) => [course.title, course.id]), + ...courses.map<[string, CourseIdentifier]>((course) => [ + LocalCourseData.getCourseName(course), + LocalCourseData.getCourseId(course), + ]), ); if (!courseId) { return; } const course = userData.val.getCourse(courseId); - if (course.newExercises.length === 0) { - dialog.notification(`There are no new exercises for the course ${course.title}.`, [ - "OK", - (): void => {}, - ]); + if (LocalCourseData.getNewExercises(course).length === 0) { + dialog.notification( + `There are no new exercises for the course ${LocalCourseData.getCourseName(course)}.`, + ["OK", (): void => {}], + ); return; } const downloadResult = await actions.downloadNewExercisesForCourse(actionContext, courseId); if (downloadResult.err) { dialog.errorNotification( - `Failed to download new exercises for course "${course.title}."`, + `Failed to download new exercises for course "${LocalCourseData.getCourseName(course)}."`, downloadResult.val, ); } diff --git a/src/commands/downloadOldSubmission.ts b/src/commands/downloadOldSubmission.ts index 64e6517c..74984da7 100644 --- a/src/commands/downloadOldSubmission.ts +++ b/src/commands/downloadOldSubmission.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { ActionContext } from "../actions/types"; import { OldSubmission } from "../api/types"; import { dateToString, Logger, parseDate } from "../utilities"; +import { ExerciseIdentifier, match } from "../shared/shared"; /** * Looks for older submissions of the given exercise and lets user choose which one to download. @@ -14,9 +15,9 @@ export async function downloadOldSubmission( actionContext: ActionContext, resource: vscode.Uri | undefined, ): Promise { - const { dialog, tmc, userData, workspaceManager } = actionContext; + const { dialog, langs, userData, workspaceManager } = actionContext; Logger.info("Downloading old submission"); - if (!(workspaceManager.ok && userData.ok && tmc.ok)) { + if (!(workspaceManager.ok && userData.ok && langs.ok)) { Logger.error("Extension was not initialized properly"); return; } @@ -29,17 +30,20 @@ export async function downloadOldSubmission( return; } - const exerciseId = userData.val.getExerciseByName( - exercise.courseSlug, - exercise.exerciseSlug, - )?.id; + const exerciseId = userData.val.getExerciseByName(exercise.courseSlug, exercise.exerciseSlug) + ?.data?.id; if (!exerciseId) { dialog.errorNotification("Failed to resolve exercise id."); return; } + const id = ExerciseIdentifier.from(exerciseId); Logger.debug("Fetching old submissions"); - const submissionsResult = await tmc.val.getOldSubmissions(exerciseId); + const submissionsResult = await match( + id, + (tmc) => langs.val.getTmcOldSubmissions(tmc.tmcExerciseId), + (mooc) => langs.val.getMoocOldSubmissions(mooc.moocExerciseId), + ); if (submissionsResult.err) { dialog.errorNotification("Failed to fetch old submissions.", submissionsResult.val); return; @@ -93,11 +97,22 @@ export async function downloadOldSubmission( const editor = vscode.window.activeTextEditor; const document = editor?.document.uri; - const oldDownloadResult = await tmc.val.downloadOldSubmission( - exerciseId, - exercise.uri.fsPath, - submission.id, - submitFirst, + const oldDownloadResult = await match( + id, + (tmc) => + langs.val.downloadTmcOldSubmission( + tmc.tmcExerciseId, + exercise.uri.fsPath, + submission.id, + submitFirst, + ), + (mooc) => + langs.val.downloadMoocOldSubmission( + mooc.moocExerciseId, + exercise.uri.fsPath, + submission.id.toString(), + submitFirst, + ), ); if (oldDownloadResult.err) { dialog.errorNotification("Failed to download old submission.", oldDownloadResult.val); diff --git a/src/commands/pasteExercise.ts b/src/commands/pasteExercise.ts index e4b54f64..d392dace 100644 --- a/src/commands/pasteExercise.ts +++ b/src/commands/pasteExercise.ts @@ -4,6 +4,7 @@ import * as actions from "../actions"; import { ActionContext } from "../actions/types"; import { BottleneckError } from "../errors"; import { Logger } from "../utilities"; +import { matchBackend } from "../shared/shared"; export async function pasteExercise( actionContext: ActionContext, @@ -24,11 +25,14 @@ export async function pasteExercise( return; } - const pasteResult = await actions.pasteExercise( - actionContext, - exercise.courseSlug, - exercise.exerciseSlug, + const pasteResult = await matchBackend( + exercise, + (tmc) => + actions.pasteTmcExercise(actionContext, exercise.courseSlug, exercise.exerciseSlug), + (mooc) => + actions.pasteMoocExercise(actionContext, exercise.courseSlug, exercise.exerciseSlug), ); + await actions.pasteTmcExercise(actionContext, exercise.courseSlug, exercise.exerciseSlug); if (pasteResult.err) { if (pasteResult.val instanceof BottleneckError) { Logger.warn(`Paste submission was cancelled: ${pasteResult.val.message}.`); diff --git a/src/commands/resetExercise.ts b/src/commands/resetExercise.ts index 58292b90..aa0029f3 100644 --- a/src/commands/resetExercise.ts +++ b/src/commands/resetExercise.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import { ActionContext } from "../actions/types"; import { Logger } from "../utilities"; +import { LocalCourseExercise } from "../shared/shared"; /** * Resets an exercise to its initial state. Optionally submits the exercise beforehand. @@ -13,9 +14,9 @@ export async function resetExercise( actionContext: ActionContext, resource: vscode.Uri | undefined, ): Promise { - const { dialog, tmc, userData, workspaceManager } = actionContext; + const { dialog, langs, userData, workspaceManager } = actionContext; Logger.info("Resetting exercise"); - if (!(tmc.ok && userData.ok && workspaceManager.ok)) { + if (!(langs.ok && userData.ok && workspaceManager.ok)) { Logger.error("Extension was not initialized properly"); return; } @@ -48,11 +49,8 @@ export async function resetExercise( const editor = vscode.window.activeTextEditor; const document = editor?.document.uri; - const resetResult = await tmc.val.resetExercise( - exerciseDetails.id, - exercise.uri.fsPath, - submitFirst, - ); + const id = LocalCourseExercise.getId(exerciseDetails); + const resetResult = await langs.val.resetExercise(id, exercise.uri.fsPath, submitFirst); if (resetResult.err) { dialog.errorNotification("Failed to reset exercise.", resetResult.val); return; diff --git a/src/commands/submitExercise.ts b/src/commands/submitExercise.ts index c58a77c0..bb12da15 100644 --- a/src/commands/submitExercise.ts +++ b/src/commands/submitExercise.ts @@ -25,7 +25,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); diff --git a/src/commands/switchWorkspace.ts b/src/commands/switchWorkspace.ts index cebd516d..c3edeca6 100644 --- a/src/commands/switchWorkspace.ts +++ b/src/commands/switchWorkspace.ts @@ -2,8 +2,8 @@ import * as vscode from "vscode"; import * as actions from "../actions"; import { ActionContext } from "../actions/types"; -import { LocalCourseData } from "../api/storage"; import { Logger } from "../utilities"; +import { LocalCourseData } from "../shared/shared"; export async function switchWorkspace(actionContext: ActionContext): Promise { const { dialog, userData } = actionContext; @@ -17,12 +17,12 @@ export async function switchWorkspace(actionContext: ActionContext): Promise((c) => [ - c.name === currentWorkspace ? `${c.name} (Currently open)` : c.name, - c, - ]), + ...courses.map<[string, LocalCourseData]>((c) => { + const name = LocalCourseData.getCourseName(c); + return [name === currentWorkspace ? `${name} (Currently open)` : name, c]; + }), ); if (courseWorkspace) { - actions.openWorkspace(actionContext, courseWorkspace.name); + actions.openWorkspace(actionContext, LocalCourseData.getCourseName(courseWorkspace)); } } diff --git a/src/commands/updateExercises.ts b/src/commands/updateExercises.ts index 379352da..be42f318 100644 --- a/src/commands/updateExercises.ts +++ b/src/commands/updateExercises.ts @@ -27,7 +27,7 @@ export async function updateExercises(actionContext: ActionContext, silent: stri const now = Date.now(); const exercisesToUpdate = updateablesResult.val.filter((x) => { const course = userData.val.getCourse(x.courseId); - return course.notifyAfter <= now && !course.disabled; + return course.data.notifyAfter <= now && !course.data.disabled; }); if (exercisesToUpdate.length === 0) { @@ -42,7 +42,6 @@ export async function updateExercises(actionContext: ActionContext, silent: stri ...userData.val.getCourses().map((x) => ({ type: "setUpdateables", target: { type: "CourseDetails" }, - courseId: x.id, exerciseIds: [], })), ); @@ -59,7 +58,6 @@ export async function updateExercises(actionContext: ActionContext, silent: stri ...userData.val.getCourses().map((x) => ({ type: "setUpdateables", target: { type: "CourseDetails" }, - courseId: x.id, exerciseIds: downloadResult.val.failed, })), ); diff --git a/src/commands/wipe.ts b/src/commands/wipe.ts index 969fcf90..e3a923e5 100644 --- a/src/commands/wipe.ts +++ b/src/commands/wipe.ts @@ -10,13 +10,13 @@ export async function wipe( actionContext: ActionContext, context: vscode.ExtensionContext, ): Promise { - const { dialog, resources, tmc, userData, workspaceManager } = actionContext; + const { dialog, resources, langs, userData, workspaceManager } = actionContext; Logger.info("Wiping"); if ( !( workspaceManager.ok && resources.ok && - tmc.ok && + langs.ok && userData.ok && resources.val.projectsDirectory ) @@ -59,7 +59,7 @@ Please close the workspace and any related files before running this command aga const message = "Removing extension data..."; const wipeResult = await dialog.progressNotification(message, async (progress) => { if ( - !(workspaceManager && resources && tmc && userData && resources.val.projectsDirectory) + !(workspaceManager && resources && langs && userData && resources.val.projectsDirectory) ) { Logger.error("Extension was not initialized properly"); return Err(new Error("Extension was not initialized properly")); @@ -74,15 +74,15 @@ Please close the workspace and any related files before running this command aga progress.report({ message, percent: 0.25 }); // Reset Langs settings - const result2 = await tmc.val.resetSettings(); + const result2 = await langs.val.resetSettings(); if (result2.err) { return result2; } progress.report({ message, percent: 0.5 }); // Maybe logout should have setting to disable events? - tmc.val.on("logout", () => {}); - const result3 = await tmc.val.deauthenticate(); + langs.val.on("logout", () => {}); + const result3 = await langs.val.deauthenticate(); if (result3.err) { return result3; } diff --git a/src/config/constants.ts b/src/config/constants.ts index 66962691..c36f6bb5 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -9,7 +9,7 @@ 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"; +import { ExerciseIdentifier, TestResultData } from "../shared/shared"; export const DEBUG_MODE = __DEBUG_MODE__; export const TMC_BACKEND_URL = __TMC_BACKEND_URL__; @@ -106,7 +106,7 @@ export const EXAM_TEST_RESULT: TestResultData = { ], logs: {}, }, - id: 0, + id: ExerciseIdentifier.from(0), courseSlug: "", exerciseName: "part01-exam01", tmcLogs: { diff --git a/src/config/userdata.ts b/src/config/userdata.ts index e68be7de..a18b768a 100644 --- a/src/config/userdata.ts +++ b/src/config/userdata.ts @@ -1,57 +1,148 @@ import * as _ from "lodash"; import { Err, Ok, Result } from "ts-results"; -import Storage, { LocalCourseData, LocalCourseExercise } from "../api/storage"; +import Storage from "../api/storage"; +import { + assertUnreachable, + CourseIdentifier, + ExerciseIdentifier, + makeMoocKind, + makeTmcKind, + match, + unwrap, + LocalCourseData, + LocalCourseExercise, + LocalMoocCourseData, + LocalTmcCourseData, + LocalTmcCourseExercise, + LocalMoocCourseExercise, +} from "../shared/shared"; import { Logger } from "../utilities/logger"; export class UserData { - private _courses: Map; - private _passedExercises: Set = new Set(); + 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.tmcCourses.map((x) => [x.id, x])); + this._moocCourses = new Map(persistentData.moocCourses.map((x) => [x.instanceId, x])); - persistentData.courses.forEach((x) => + persistentData.tmcCourses.forEach((x) => x.exercises.forEach((y) => { if (y.passed) { - this._passedExercises.add(y.id); + this._passedExercises.add(ExerciseIdentifier.from(y.id)); + } + }), + ); + persistentData.moocCourses.forEach((x) => + x.exercises.forEach((y) => { + if (y.passed) { + this._passedExercises.add(ExerciseIdentifier.from(y.id)); } }), ); } 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 getTmcCourses(): LocalTmcCourseData[] { + return Array.from(this._tmcCourses.values()); + } + + public getMoocCourses(): LocalMoocCourseData[] { + return Array.from(this._moocCourses.values()); } - public getCourse(id: number): Readonly { - const course = this._courses.get(id); - return course as LocalCourseData; + public getCourse(id: CourseIdentifier): LocalCourseData { + switch (id.kind) { + case "tmc": { + const course = this._tmcCourses.get(id.data.courseId); + if (!course) { + throw "nonexistent course"; + } + return makeTmcKind(course); + } + case "mooc": { + const course = this._moocCourses.get(id.data.instanceId); + if (!course) { + throw "nonexistent course"; + } + return makeMoocKind(course); + } + default: { + assertUnreachable(id); + } + } } - public getCourseByName(name: string): Readonly { - return this.getCourses().filter((x) => x.name === name)[0]; + public getCourseBySlug(slug: string): LocalCourseData { + throw "todo"; + } + + 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 getExerciseByName( courseSlug: string, exerciseName: string, ): Readonly | undefined { - 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); + return exercise ? makeTmcKind(exercise) : undefined; + } + } + for (const course of this._moocCourses.values()) { + if (course.courseName === courseSlug) { + const exercise = course.exercises.find((x) => x.slug === exerciseName); + return exercise ? makeMoocKind(exercise) : undefined; + } + } + } + + public getTmcExerciseByName( + courseSlug: string, + exerciseName: string, + ): Readonly | undefined { + for (const course of this._tmcCourses.values()) { if (course.name === courseSlug) { return course.exercises.find((x) => x.name === exerciseName); } } } + public getMoocExerciseByName( + courseSlug: string, + exerciseName: string, + ): Readonly | undefined { + for (const course of this._moocCourses.values()) { + if (course.courseName === courseSlug) { + return course.exercises.find((x) => x.slug === exerciseName); + } + } + } + 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,54 +155,151 @@ export class UserData { } public addCourse(data: LocalCourseData): void { - if (this._courses.has(data.id)) { + switch (data.kind) { + case "tmc": { + const course = data; + if (this._tmcCourses.has(course.data.id)) { + throw new Error("Trying to add an already existing course"); + } + Logger.info(`Adding course ${course.data.name} to My Courses`); + this._tmcCourses.set(course.data.id, course.data); + break; + } + case "mooc": { + const course = data; + if (this._moocCourses.has(course.data.instanceId)) { + throw new Error("Trying to add an already existing course"); + } + Logger.info(`Adding course ${course.data.courseName} to My Courses`); + this._moocCourses.set(course.data.instanceId, course.data); + 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); + public deleteCourse(id: CourseIdentifier): void { + match( + id, + (tmc) => { + this._tmcCourses.delete(tmc.courseId); + }, + (mooc) => { + this._moocCourses.delete(mooc.instanceId); + }, + ); 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; + if (!this._tmcCourses.has(course.data.id)) { + throw new Error("Trying to fetch course that doesn't exist."); + } + this._tmcCourses.set(course.data.id, course.data); + break; + } + case "mooc": { + const course = data; + if (!this._moocCourses.has(course.data.instanceId)) { + throw new Error("Trying to fetch course that doesn't exist."); + } + this._moocCourses.set(course.data.instanceId, course.data); + break; + } + default: { + assertUnreachable(data); + } } - this._courses.set(data.id, data); await this._updatePersistentData(); } public async updateExercises( - courseId: number, + courseId: CourseIdentifier, exercises: LocalCourseExercise[], ): Promise> { - const courseData = this._courses.get(courseId); + const courseData = this.getCourse(courseId); if (!courseData) { return new Err(new Error("Data missing")); } - const exerciseIds = exercises.map((exercise) => exercise.id); + const courseExercises = LocalCourseData.getExercises(courseData); + const newExercises = LocalCourseData.getNewExercises(courseData); + const exerciseIds = exercises.map((exercise) => exercise.data.id); // Filter out "new" exercises that no longer were in the API, and then append new data - courseData.newExercises = courseData.newExercises - .filter((exerciseId) => exerciseIds.includes(exerciseId)) - .concat( - exerciseIds.filter( - (newExerciseId) => !courseData.exercises.find((e) => e.id === newExerciseId), - ), - ); - if (courseData.newExercises.length > 0) { + courseData.data.newExercises = match( + courseData, + (tmc) => + tmc.newExercises + .filter((exerciseId) => exerciseIds.includes(exerciseId)) + .concat( + exerciseIds + .filter((eid) => typeof eid === "number") + .filter( + (newExerciseId) => + !courseExercises.find((e) => e.data.id === newExerciseId), + ), + ), + (mooc) => + mooc.newExercises + .filter((exerciseId) => exerciseIds.includes(exerciseId)) + .concat( + exerciseIds + .filter((eid) => typeof eid === "string") + .filter( + (newExerciseId) => + !courseExercises.find((e) => e.data.id === newExerciseId), + ), + ), + ); + if (courseData.data.newExercises.length > 0) { Logger.info( - `Found ${courseData.newExercises.length} new exercises for ${courseData.name}`, + `Found ${courseData.data.newExercises.length} new exercises for ${LocalCourseData.getNewExercises(courseData)}`, ); } - courseData.exercises = exercises; - courseData.exercises.forEach((x) => - x.passed ? this._passedExercises.add(x.id) : this._passedExercises.delete(x.id), + exercises.forEach((x) => { + const id = ExerciseIdentifier.from(x.data.id); + return x.data.passed ? this._passedExercises.add(id) : this._passedExercises.delete(id); + }); + match( + courseData, + (tmc) => { + tmc.exercises = exercises + .map((e) => + match( + e, + (tmc) => tmc, + (mooc) => undefined, + ), + ) + .filter((e) => e !== undefined); + }, + (mooc) => { + mooc.exercises = exercises + .map((e) => + match( + e, + (tmc) => undefined, + (mooc) => mooc, + ), + ) + .filter((e) => e !== undefined); + }, ); - this._courses.set(courseId, courseData); + this.addCourse(courseData); await this._updatePersistentData(); return Ok.EMPTY; } @@ -121,18 +309,18 @@ 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; } - public getPassed(exerciseId: number): boolean { + public getPassed(exerciseId: ExerciseIdentifier): boolean { return this._passedExercises.has(exerciseId); } @@ -145,26 +333,38 @@ export class UserData { * @param exercisesToClear Number list of exercises to clear. */ public async clearFromNewExercises( - courseId: number, - exercisesToClear?: number[], + courseId: CourseIdentifier, + exercisesToClear?: ExerciseIdentifier[], ): Promise> { - const courseData = this._courses.get(courseId); + let courseData = this.getCourse(courseId); if (!courseData) { return new Err(new Error("Data missing")); } - Logger.info(`Clearing new exercises for ${courseData.name}`); + const newExercises = courseData.data.newExercises.map(ExerciseIdentifier.from); + Logger.info(`Clearing new exercises`); if (exercisesToClear !== undefined) { - const unSuccessfullyDownloaded = _.difference( - courseData.newExercises, - exercisesToClear, + const unSuccessfullyDownloaded = _.difference(newExercises, exercisesToClear); + let tmcIds: number[] = []; + let moocIds: string[] = []; + unSuccessfullyDownloaded.forEach((id) => + match( + id, + (tmc) => tmcIds.push(tmc.tmcExerciseId), + (mooc) => moocIds.push(mooc.moocExerciseId), + ), ); - courseData.newExercises = unSuccessfullyDownloaded; + if (tmcIds.length !== 0) { + courseData.data.newExercises = tmcIds; + } + if (moocIds.length !== 0) { + courseData.data.newExercises = moocIds; + } if (unSuccessfullyDownloaded.length === 0) { - courseData.notifyAfter = 0; + courseData.data.notifyAfter = 0; } } else { - courseData.newExercises = []; - courseData.notifyAfter = 0; + courseData.data.newExercises = []; + courseData.data.notifyAfter = 0; } await this._updatePersistentData(); return Ok.EMPTY; @@ -177,18 +377,18 @@ export class UserData { * @param dateInMillis Next possible notification date, in milliseconds. */ public async setNotifyDate( - courseId: number, + courseId: CourseIdentifier, dateInMillis: number, ): Promise> { - const courseData = this._courses.get(courseId); + const courseData = match( + courseId, + (tmc) => this._tmcCourses.get(tmc.courseId), + (mooc) => this._moocCourses.get(mooc.instanceId), + ); if (!courseData) { return new Err(new Error("Data missing")); } - Logger.info( - `Notifying user for course ${courseData.name} again at ${new Date( - dateInMillis, - ).toString()}`, - ); + Logger.info(`Notifying user for course again at ${new Date(dateInMillis).toString()}`); courseData.notifyAfter = dateInMillis; await this._updatePersistentData(); return Ok.EMPTY; @@ -202,6 +402,9 @@ export class UserData { } private _updatePersistentData(): Promise { - return this._storage.updateUserData({ courses: Array.from(this._courses.values()) }); + return this._storage.updateUserData({ + tmcCourses: Array.from(this._tmcCourses.values()), + moocCourses: Array.from(this._moocCourses.values()), + }); } } diff --git a/src/extension.ts b/src/extension.ts index ec87cdd1..9ecfd98e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,7 +7,7 @@ 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 Langs from "./api/langs"; import WorkspaceManager from "./api/workspaceManager"; import { CLIENT_NAME, @@ -70,13 +70,13 @@ async function activateInner(context: vscode.ExtensionContext): Promise { const cliPathResult = await init.ensureLangsUpdated(cliFolderPath, dialog); // download langs if necessary - let tmc: Result; + let langs: Result; if (cliPathResult.err) { - tmc = cliPathResult; + langs = cliPathResult; initializationError(dialog, "tmc-langs setup", cliPathResult.val, cliFolderPath); } else { - tmc = new Ok( - new TMC(cliPathResult.val, CLIENT_NAME, extensionVersion, { + langs = new Ok( + new Langs(cliPathResult.val, CLIENT_NAME, extensionVersion, { cliConfigDir: TMC_LANGS_CONFIG_DIR, }), ); @@ -84,8 +84,8 @@ async function activateInner(context: vscode.ExtensionContext): Promise { // check auth status let authenticated = false; - if (tmc.ok) { - const authenticatedResult = await tmc.val.isAuthenticated({ timeout: 15000 }); + if (langs.ok) { + const authenticatedResult = await langs.val.isAuthenticated({ timeout: 15000 }); if (authenticatedResult.err) { initializationError( dialog, @@ -109,12 +109,12 @@ async function activateInner(context: vscode.ExtensionContext): Promise { // migrate data between versions const storage = new Storage(context); - if (tmc.ok) { + if (langs.ok) { const migrationResult = await migrateExtensionDataFromPreviousVersions( context, storage, dialog, - tmc.val, + langs.val, vscode.workspace.getConfiguration(), ); if (migrationResult.err) { @@ -131,8 +131,8 @@ async function activateInner(context: vscode.ExtensionContext): Promise { // get data path let tmcDataPath: string | undefined; - if (tmc.ok) { - const dataPathResult = await tmc.val.getSetting("projects-dir", createIs()); + if (langs.ok) { + const dataPathResult = await langs.val.getSetting("projects-dir", createIs()); if (dataPathResult.err) { Logger.error("Failed to define datapath:", dataPathResult.val); initializationError(dialog, "finding datapath", dataPathResult.val, cliFolderPath); @@ -171,12 +171,12 @@ async function activateInner(context: vscode.ExtensionContext): Promise { loggedIn, }; - if (tmc.ok) { - tmc.val.on("login", async () => { + if (langs.ok) { + langs.val.on("login", async () => { await vscode.commands.executeCommand("setContext", "test-my-code:LoggedIn", true); ui.treeDP.updateVisibility([visibilityGroups.loggedIn]); }); - tmc.val.on("logout", async () => { + langs.val.on("logout", async () => { dialog.warningNotification("Your TMC session has expired, please log in."); await vscode.commands.executeCommand("setContext", "test-my-code:LoggedIn", false); ui.treeDP.updateVisibility([visibilityGroups.loggedIn.not]); @@ -247,7 +247,7 @@ async function activateInner(context: vscode.ExtensionContext): Promise { exerciseDecorationProvider, resources, settings, - tmc, + langs, ui, userData, workspaceManager, @@ -279,7 +279,7 @@ async function activateInner(context: vscode.ExtensionContext): Promise { } maintenanceInterval = setInterval(async () => { - const authenticated = tmc.ok ? await tmc.val.isAuthenticated() : Ok(false); + const authenticated = langs.ok ? await langs.val.isAuthenticated() : Ok(false); if (authenticated.err) { Logger.error("Failed to check if authenticated", authenticated.val); } else if (authenticated.val) { @@ -299,7 +299,7 @@ async function activateInner(context: vscode.ExtensionContext): Promise { if ( !( - tmc.ok && + langs.ok && userData.ok && workspaceManager.ok && exerciseDecorationProvider.ok && diff --git a/src/init/commands.ts b/src/init/commands.ts index 48facd3d..f5de2580 100644 --- a/src/init/commands.ts +++ b/src/init/commands.ts @@ -5,6 +5,7 @@ import { checkForCourseUpdates, displayUserCourses, removeCourse } from "../acti import { ActionContext } from "../actions/types"; import * as commands from "../commands"; import { randomPanelId, TmcPanel } from "../panels/TmcPanel"; +import { assertUnreachable, CourseIdentifier, LocalCourseData } from "../shared/shared"; import { TmcTreeNode } from "../ui/treeview/treenode"; import { Logger } from "../utilities/"; @@ -18,18 +19,6 @@ export function registerCommands( // Commands not shown to user in Command Palette / TMC Action menu context.subscriptions.push( vscode.commands.registerCommand("tmcView.activateEntry", ui.createUiActionHandler()), - vscode.commands.registerCommand( - "tmcTreeView.removeCourse", - async (treeNode: TmcTreeNode) => { - const confirmed = await dialog.confirmation( - `Do you want to remove ${treeNode.label} from your courses? This won't delete your downloaded exercises.`, - ); - if (confirmed) { - await removeCourse(actionContext, Number(treeNode.id)); - await displayUserCourses(context, actionContext); - } - }, - ), vscode.commands.registerCommand("tmcTreeView.refreshCourses", async () => { await checkForCourseUpdates(actionContext); await commands.updateExercises(actionContext, "loud"); @@ -58,30 +47,91 @@ export function registerCommands( commands.closeExercise(actionContext, resource), ), - vscode.commands.registerCommand("tmc.courseDetails", async (courseId?: number) => { - if (userData.err) { - Logger.error("The extension was not initialized properly"); - return; - } + vscode.commands.registerCommand( + "tmc.courseDetails", + async (courseId?: CourseIdentifier) => { + if (userData.err) { + Logger.error("The extension was not initialized properly"); + return; + } - const courses = userData.val.getCourses(); - if (courses.length === 0) { - return; - } - courseId = - courseId ?? - (await dialog.selectItem( - "Which course page do you want to open?", - ...courses.map<[string, number]>((c) => [c.title, c.id]), - )); - if (courseId) { + const courses = userData.val.getCourses(); + if (courses.length === 0) { + return; + } + 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", data: { courseId: c.data.id } }, + ]; + } + case "mooc": { + return [ + c.data.courseName, + { kind: "mooc", data: { instanceId: c.data.instanceId } }, + ]; + } + } + }), + ); + if (selected === undefined) { + // user did not select anything + return; + } + actualId = selected; + } else { + actualId = courseId; + } + const course = userData.val.getCourse(actualId); TmcPanel.renderMain(context.extensionUri, context, actionContext, { id: randomPanelId(), type: "CourseDetails", - courseId, + courseId: actualId, + course, + exerciseGroups: [], + exerciseStatuses: { tmc: {}, mooc: {} }, }); - } - }), + }, + ), + + vscode.commands.registerCommand( + "tmc.courseDetails", + async (courseId?: CourseIdentifier) => { + if (userData.err) { + Logger.error("The extension was not initialized properly"); + return; + } + + const courses = userData.val.getCourses(); + if (courses.length === 0) { + return; + } + courseId = + courseId ?? + (await dialog.selectItem( + "Which course page do you want to open?", + ...courses.map<[string, CourseIdentifier]>((c) => [ + LocalCourseData.getCourseName(c), + LocalCourseData.getCourseId(c), + ]), + )); + if (courseId) { + TmcPanel.renderMain(context.extensionUri, context, actionContext, { + id: randomPanelId(), + type: "CourseDetails", + courseId, + exerciseGroups: [], + exerciseStatuses: { tmc: {}, mooc: {} }, + }); + } + }, + ), vscode.commands.registerCommand("tmc.downloadNewExercises", async () => commands.downloadNewExercises(actionContext), diff --git a/src/init/resources.ts b/src/init/resources.ts index 12bde977..8aee50e7 100644 --- a/src/init/resources.ts +++ b/src/init/resources.ts @@ -45,7 +45,7 @@ export async function resourceInitialization( // Verify that all course .code-workspaces are in-place on startup. fs.ensureDirSync(workspaceFileFolder); const userData = storage.getUserData(); - userData?.courses.forEach((course) => { + userData?.tmcCourses.forEach((course) => { const tmcWorkspaceFilePath = path.join( workspaceFileFolder, course.name + ".code-workspace", diff --git a/src/init/ui.ts b/src/init/ui.ts index 39d4326f..b4d89e87 100644 --- a/src/init/ui.ts +++ b/src/init/ui.ts @@ -1,10 +1,16 @@ -import { Result } from "ts-results"; +import { Err, Ok, Result } from "ts-results"; import * as vscode from "vscode"; import { downloadOrUpdateExercises, refreshLocalExercises } from "../actions"; import { ActionContext } from "../actions/types"; import { TmcPanel } from "../panels/TmcPanel"; -import { ExtensionToWebview } from "../shared/shared"; +import { + assertUnreachable, + CourseIdentifier, + ExerciseIdentifier, + ExtensionToWebview, + LocalCourseData, +} from "../shared/shared"; import UI from "../ui/ui"; import { Logger } from "../utilities/"; @@ -14,22 +20,77 @@ import { Logger } from "../utilities/"; * @param ui The User Interface object * @param tmc The TMC API object */ -export function registerUiActions(actionContext: ActionContext): void { +export function registerUiActions(actionContext: ActionContext): Result { const { ui, visibilityGroups, userData, - tmc, + langs, resources, exerciseDecorationProvider, workspaceManager, } = actionContext; Logger.info("Initializing UI Actions"); + if (userData.err) { + return new Err(new Error("Extension was not initialized properly")); + } + + // Register UI actions + ui.treeDP.registerAction("Log in", "logIn", [visibilityGroups.loggedIn.not], { + command: "tmc.showLogin", + title: "", + arguments: [], + }); + + const courses = userData.val.getCourses(); + ui.treeDP.registerAction( + "My Courses", + "myCourses", + [visibilityGroups.loggedIn], + { + command: "tmc.myCourses", + title: "Go to My Courses", + }, + courses.length !== 0 + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed, + 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); + } + } + }), + ); if ( !( userData.ok && - tmc.ok && + langs.ok && resources.ok && exerciseDecorationProvider.ok && workspaceManager.ok @@ -54,7 +115,7 @@ export function registerUiActions(actionContext: ActionContext): void { } // Register UI actions - if (tmc.ok) { + if (langs.ok) { // cannot login without tmc ui.treeDP.registerAction("Log in", "logIn", [visibilityGroups.loggedIn.not], { command: "tmc.showLogin", @@ -77,12 +138,12 @@ export function registerUiActions(actionContext: ActionContext): void { ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed, userCourses.map<{ label: string; id: string; command: vscode.Command }>((course) => ({ - label: course.title, - id: course.id.toString(), + label: LocalCourseData.getCourseName(course), + id: CourseIdentifier.toString(LocalCourseData.getCourseId(course)), command: { command: "tmc.courseDetails", title: "Go to course details", - arguments: [course.id], + arguments: [LocalCourseData.getCourseId(course)], }, })), ); @@ -104,6 +165,8 @@ export function registerUiActions(actionContext: ActionContext): void { command: "tmc.logout", title: "Log out", }); + + return Ok.EMPTY; } /** @@ -113,8 +176,8 @@ export async function uiDownloadExercises( ui: UI, actionContext: ActionContext, mode: string, - courseId: number, - exerciseIds: number[], + courseId: CourseIdentifier, + exerciseIds: Array, ): Promise { const { userData } = actionContext; if (userData.err) { @@ -172,7 +235,7 @@ export async function uiDownloadExercises( type: "setNewExercises", target: { type: "MyCourses" }, courseId: courseId, - exerciseIds: userData.val.getCourse(courseId).newExercises, + exerciseIds: LocalCourseData.getNewExercises(userData.val.getCourse(courseId)), }); const exerciseStatusChangeMessages = exerciseIds.map((id) => { const message: ExtensionToWebview = { diff --git a/src/migrate/index.ts b/src/migrate/index.ts index c40e1298..a21e79fc 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 Langs from "../api/langs"; import Storage from "../api/storage"; -import TMC from "../api/tmc"; import { WORKSPACE_ROOT_FILE_NAME, WORKSPACE_ROOT_FILE_TEXT, @@ -30,7 +30,7 @@ export async function migrateExtensionDataFromPreviousVersions( context: vscode.ExtensionContext, storage: Storage, dialog: Dialog, - tmc: TMC, + tmc: Langs, settings: vscode.WorkspaceConfiguration, ): Promise> { const memento = context.globalState; diff --git a/src/migrate/migrateExerciseData.ts b/src/migrate/migrateExerciseData.ts index f2d5ee6b..cdc42e10 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 Langs 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 { @@ -85,7 +85,7 @@ async function exerciseDataFromV0toV1( exerciseData: LocalExerciseDataV0[], memento: vscode.Memento, dialog: Dialog, - tmc: TMC, + tmc: Langs, ): Promise { interface ExtensionSettingsPartial { dataPath: string; @@ -164,7 +164,7 @@ async function exerciseDataFromV0toV1( export default async function migrateExerciseData( memento: vscode.Memento, dialog: Dialog, - tmc: TMC, + tmc: Langs, ): Promise> { const obsoleteKeys: string[] = []; diff --git a/src/migrate/migrateUserData.ts b/src/migrate/migrateUserData.ts index 36bd9672..33c3dad9 100644 --- a/src/migrate/migrateUserData.ts +++ b/src/migrate/migrateUserData.ts @@ -1,19 +1,25 @@ import { createIs } from "typia"; import * as vscode from "vscode"; -import { LocalCourseData } from "../api/storage"; +import { 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"; +import { LocalTmcCourseData } from "../shared/shared"; -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; @@ -37,6 +43,10 @@ export interface LocalCourseDataV0 { material_url?: string | null; } +interface UserDataV1 { + courses: LocalCourseDataV1[]; +} + export interface LocalCourseDataV1 { id: number; name: string; @@ -61,7 +71,7 @@ export interface LocalCourseDataV1 { materialUrl: string | null; } -function courseDataFromV0ToV1( +function coursesFromV0ToV1( unstableData: LocalCourseDataV0[], memento: vscode.Memento, ): LocalCourseDataV1[] { @@ -72,7 +82,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) => { @@ -103,7 +113,17 @@ function courseDataFromV0ToV1( }); } -export function resolveMissingFields(localCourseData: LocalCourseDataV1[]): LocalCourseData[] { +function localCourseDataFromV1ToV2(old: UserDataV1): UserData { + const defined = resolveMissingFieldsInV1(old.courses); + return { + tmcCourses: defined, + moocCourses: [], + }; +} + +export function resolveMissingFieldsInV1( + localCourseData: LocalCourseDataV1[], +): LocalTmcCourseData[] { return localCourseData.map((course) => { const exercises = course.exercises.map((x) => { const resolvedAwardedPoints = x.passed @@ -119,23 +139,33 @@ export function resolveMissingFields(localCourseData: LocalCourseDataV1[]): Loca }); } -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); +// migrates stored user data to the latest version +export default function migrateUserData(memento: vscode.Memento): 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 c8cd42fe..93e46396 100644 --- a/src/panels/TmcPanel.ts +++ b/src/panels/TmcPanel.ts @@ -9,7 +9,7 @@ import { login, openExercises, openWorkspace, - pasteExercise, + pasteTmcExercise, removeCourse, testInterrupts, updateCourse, @@ -19,7 +19,19 @@ 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 { + CourseIdentifier, + Enum, + ExerciseGroup, + ExerciseIdentifier, + ExtensionToWebview, + LocalCourseData, + LocalCourseExercise, + makeMoocKind, + makeTmcKind, + Panel, + WebviewToExtension, +} from "../shared/shared"; import * as UITypes from "../ui/types"; import { cliFolder, @@ -248,12 +260,11 @@ export class TmcPanel { async (message: WebviewToExtension) => { switch (message.type) { case "requestCourseDetailsData": { - const { tmc, userData, workspaceManager } = actionContext; - if (!(tmc.ok && userData.ok && workspaceManager.ok)) { + const { langs, userData, workspaceManager } = actionContext; + if (!(langs.ok && userData.ok && workspaceManager.ok)) { Logger.error("Extension was not initialized properly"); return; } - const course = userData.val.getCourse(message.sourcePanel.courseId); postMessageToWebview(webview, { type: "setCourseData", @@ -261,109 +272,126 @@ export class TmcPanel { courseData: course, }); - tmc.val.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.val.getExerciseBySlug( - course.name, - ex.name, - ); - const softDeadline = ex.softDeadline - ? parseDate(ex.softDeadline) - : null; - const hardDeadline = ex.deadline ? parseDate(ex.deadline) : null; + langs.val + .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: "exerciseStatusChange", + type: "setCourseDisabledStatus", target: message.sourcePanel, - exerciseId: ex.id, - status: mapStatus( - exData?.status ?? ExerciseStatus.Missing, - hardDeadline !== null && currentDate >= hardDeadline, - ), + courseId: LocalCourseData.getCourseId(course), + disabled: course.data.disabled, }); - 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, + const exerciseGroupData = new Map(); + LocalCourseData.getExercises(course).forEach((ex) => { + const nameMatch = + LocalCourseExercise.getSlug(ex).match(/(\w+)-(.+)/); + const groupName = nameMatch?.[1] || ""; + const group = exerciseData.get(groupName); + const name = nameMatch?.[2] || ""; + const exData = workspaceManager.val.getExerciseBySlug( + course.kind, + LocalCourseData.getCourseName(course), + LocalCourseExercise.getSlug(ex), + ); + if (!exData) { + throw "nonexistent exercise"; + } + + const softDeadline = ex.data.softDeadline + ? parseDate(ex.data.softDeadline) + : null; + const hardDeadline = ex.data.deadline + ? parseDate(ex.data.deadline) + : null; + + const exerciseId = LocalCourseExercise.getId(ex); + postMessageToWebview(webview, { + type: "exerciseStatusChange", + target: message.sourcePanel, + exerciseId, + status: mapStatus( + exData?.status ?? ExerciseStatus.Missing, + hardDeadline !== null && currentDate >= hardDeadline, ), - nextDeadlineString: offlineMode - ? "Next deadline: Not available" - : parseNextDeadlineAfter( - currentDate, - e.exercises.map((ex) => ({ - date: ex.isHard - ? ex.hardDeadline - : ex.softDeadline, - active: !ex.passed, - })), - ), + }); + const entry: UITypes.CourseDetailsExercise = { + id: exerciseId, + name, + passed: + LocalCourseData.getExercises(course).find( + (ce) => + LocalCourseExercise.getId(ce) === exerciseId, + )?.data.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( + exerciseData.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, + ), + 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 "requestExerciseSubmissionData": { @@ -377,6 +405,10 @@ export class TmcPanel { } case "requestMyCoursesData": { const { userData, workspaceManager, resources } = actionContext; + if (userData.err) { + Logger.error("Extension was not initialized properly"); + return; + } if ( !( userData.ok && @@ -409,8 +441,8 @@ export class TmcPanel { break; } case "requestSelectCourseData": { - const { tmc } = actionContext; - if (!tmc.ok) { + const { langs } = actionContext; + if (!langs.ok) { Logger.error("Extension was not initialized properly"); return; } @@ -421,21 +453,31 @@ export class TmcPanel { tmcBackendUrl: TMC_BACKEND_URL, }); - const organizations = await tmc.val.getOrganizations(); + const organizations = await langs.val.getTmcOrganizations(); if (organizations.err) { + const error = `Failed to fetch organizations. ${organizations.val}`; actionContext.dialog.errorNotification( "Failed to open panel.", organizations.val, ); + postMessageToWebview(webview, { + type: "requestSelectCourseDataError", + target: message.sourcePanel, + error, + }); return; } const organization = organizations.val.find( (o) => o.slug === message.sourcePanel.organizationSlug, ); if (organization === undefined) { - actionContext.dialog.errorNotification( - `Failed to open panel: could not find organization "${message.sourcePanel.organizationSlug}"`, - ); + const error = `Failed to find organization not find organization "${message.sourcePanel.organizationSlug}".`; + actionContext.dialog.errorNotification(error); + postMessageToWebview(webview, { + type: "requestSelectCourseDataError", + target: message.sourcePanel, + error, + }); return; } postMessageToWebview(webview, { @@ -444,12 +486,18 @@ export class TmcPanel { organization, }); - const courses = await tmc.val.getCourses(organization.slug); + const courses = await langs.val.getCourses(organization.slug); if (courses.err) { + const error = `Failed to fetch organization courses. ${courses.val}`; actionContext.dialog.errorNotification( "Failed to open panel.", courses.val, ); + postMessageToWebview(webview, { + type: "requestSelectCourseDataError", + target: message.sourcePanel, + error, + }); return; } postMessageToWebview(webview, { @@ -460,8 +508,8 @@ export class TmcPanel { break; } case "requestSelectOrganizationData": { - const { tmc } = actionContext; - if (!tmc.ok) { + const { langs } = actionContext; + if (!langs.ok) { Logger.error("Extension was not initialized properly"); return; } @@ -472,12 +520,18 @@ export class TmcPanel { tmcBackendUrl: TMC_BACKEND_URL, }); - const organizations = await tmc.val.getOrganizations(); + const organizations = await langs.val.getTmcOrganizations(); if (organizations.err) { + const error = `Failed fetch organizations. ${organizations.val}`; actionContext.dialog.errorNotification( "Failed to open panel.", organizations.val, ); + postMessageToWebview(webview, { + type: "requestSelectOrganizationDataError", + target: message.sourcePanel, + error, + }); return; } postMessageToWebview(webview, { @@ -532,11 +586,21 @@ export class TmcPanel { id: randomPanelId(), type: "CourseDetails", courseId: message.courseId, + 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(), @@ -553,9 +617,10 @@ export class TmcPanel { } const course = userData.val.getCourse(message.id); + const courseName = LocalCourseData.getCourseName(course); if ( await actionContext.dialog.explicitConfirmation( - `Do you want to remove ${course.name} from your courses? \ + `Do you want to remove ${LocalCourseData.getCourseName(course)} from your courses? \ This won't delete your downloaded exercises.`, ) ) { @@ -569,7 +634,7 @@ export class TmcPanel { webview, ); actionContext.dialog.notification( - `${course.name} was removed from courses.`, + `${courseName} was removed from courses.`, ); } break; @@ -597,7 +662,7 @@ export class TmcPanel { const result = await closeExercises( actionContext, message.ids, - message.courseName, + message.courseId, ); if (result.err) { actionContext.dialog.errorNotification( @@ -628,22 +693,29 @@ export class TmcPanel { break; } case "openExercises": { - const { tmc, userData } = actionContext; - if (!(tmc.ok && userData.ok)) { + const { langs, userData } = actionContext; + if (!(langs.ok && userData.ok)) { Logger.error("Extension was not initialized properly"); return; } // todo: move to actions // download exercises that don't exist locally - const course = userData.val.getCourseByName(message.courseName); - const courseExercises = new Map(course.exercises.map((x) => [x.id, x])); + const course = userData.val.getCourse(message.courseId); const exercisesToOpen = compact( message.ids.map((x) => courseExercises.get(x)), ); - const localCourseExercises = await tmc.val.listLocalCourseExercises( - message.courseName, + const localCourseExercises = await langs.val.listLocalCourseExercises( + message.courseId.kind, + LocalCourseData.getCourseName(course), ); + const courseExercises = new Map( + LocalCourseData.getExercises(course).map((x) => [ + LocalCourseExercise.getId(x), + x, + ]), + ); + if (localCourseExercises.err) { actionContext.dialog.errorNotification( "Error trying to list local exercises while opening selected exercises.", @@ -655,15 +727,18 @@ export class TmcPanel { (lce) => lce["exercise-slug"], ); const exercisesToDownload = exercisesToOpen.filter( - (eto) => !localCourseExerciseSlugs.includes(eto.name), + (eto) => + !localCourseExerciseSlugs.includes( + LocalCourseExercise.getSlug(eto), + ), ); if (exercisesToDownload.length !== 0) { await uiDownloadExercises( actionContext.ui, actionContext, "", - course.id, - exercisesToDownload.map((etd) => etd.id), + LocalCourseData.getCourseId(course), + exercisesToDownload.map((etd) => LocalCourseExercise.getId(etd)), ); } @@ -672,7 +747,7 @@ export class TmcPanel { extensionContext, actionContext, message.ids, - message.courseName, + message.courseId, ); if (result.err) { actionContext.dialog.errorNotification( @@ -696,7 +771,7 @@ 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( @@ -708,7 +783,9 @@ export class TmcPanel { { id: randomPanelId(), type: "CourseDetails", - courseId: courseId, + courseId, + exerciseGroups: [], + exerciseStatuses: { tmc: {}, mooc: {} }, }, webview, ); @@ -794,10 +871,10 @@ export class TmcPanel { break; } case "pasteExercise": { - const pasteResult = await pasteExercise( + const pasteResult = await pasteTmcExercise( actionContext, - message.course.name, - message.exercise.name, + LocalCourseData.getCourseName(message.course), + LocalCourseExercise.getSlug(message.exercise), ); if (pasteResult.err) { actionContext.dialog.errorNotification( @@ -823,11 +900,67 @@ 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 { langs } = actionContext; + if (!langs.ok) { + Logger.error("Extension was not initialized properly"); + return; + } + const courseInstances = await langs.val.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 { userData } = actionContext; + if (!userData.ok) { + Logger.error("Extension was not initialized properly"); + return; + } + const result = await addNewCourse( + actionContext, + message.organizationSlug, + makeMoocKind({ instanceId: message.instanceId }), + ); + if (result.err) { + actionContext.dialog.errorNotification( + "Failed to add new course.", + result.val, + ); + } + postMessageToWebview(webview, { + type: "setMyCourses", + target: message.requestingPanel, + courses: userData.val.getCourses(), + }); + break; + } case "requestInitializationErrors": { const { exerciseDecorationProvider, resources, - tmc, + langs, userData, workspaceManager, } = actionContext; @@ -837,7 +970,7 @@ export class TmcPanel { target: message.sourcePanel, cliFolder: cliFolder(extensionContext), initializationErrors: { - tmc: formatError(tmc), + tmc: formatError(langs), userData: formatError(userData), workspaceManager: formatError(workspaceManager), resources: formatError(resources), diff --git a/src/test-integration/tmc_langs_cli.spec.ts b/src/test-integration/tmc_langs_cli.spec.ts index d546894e..0fd39fe1 100644 --- a/src/test-integration/tmc_langs_cli.spec.ts +++ b/src/test-integration/tmc_langs_cli.spec.ts @@ -7,11 +7,12 @@ import * as path from "path"; import * as kill from "tree-kill"; import { Result } from "ts-results"; -import TMC from "../api/tmc"; +import Langs from "../api/langs"; import { SubmissionFeedback } from "../api/types"; import { CLIENT_NAME, TMC_LANGS_VERSION } from "../config/constants"; import { AuthenticationError, AuthorizationError, BottleneckError, RuntimeError } from "../errors"; import { getLangsCLIForPlatform, getPlatform } from "../utilities/"; +import { CourseIdentifier, ExerciseIdentifier } from "../shared/shared"; // __dirname is the dist folder when built. const PROJECT_ROOT = path.join(__dirname, ".."); @@ -59,7 +60,7 @@ suite("tmc langs cli spec", function () { let onLoggedInCalls: number; let onLoggedOutCalls: number; let projectsDir: string; - let tmc: TMC; + let tmc: Langs; setup(function () { configDir = path.join(testDir, CLIENT_CONFIG_DIR_NAME); @@ -67,7 +68,7 @@ suite("tmc langs cli spec", function () { onLoggedInCalls = 0; onLoggedOutCalls = 0; projectsDir = setupProjectsDir(configDir, path.join(testDir, "tmcdata")); - tmc = new TMC(CLI_FILE, CLIENT_NAME, "test", { + tmc = new Langs(CLI_FILE, CLIENT_NAME, "test", { cliConfigDir: testDir, }); tmc.on("login", () => onLoggedInCalls++); @@ -111,23 +112,29 @@ 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.downloadExercises( + [ExerciseIdentifier.from(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(); - expect(downloads.failed?.length).to.be.equal(1); + const [tmcDownloads, moocDownloads] = ( + await tmc.downloadExercises([ExerciseIdentifier.from(404)], true, () => {}) + ).unwrap(); + expect(tmcDownloads.failed?.length).to.be.equal(1); }); test("should get existing api data", async function () { - const data = (await tmc.getCourseData(1)).unwrap(); + const data = (await tmc.getTmcCourseData(1)).unwrap(); expect(data.details.name).to.be.equal("python-course"); expect(data.exercises.length).to.be.equal(2); expect(data.settings.name).to.be.equal("python-course"); - const details = (await tmc.getCourseDetails(1)).unwrap(); + const details = (await tmc.getCourseDetails(CourseIdentifier.from(1))).unwrap(); expect(details.id).to.be.equal(1); expect(details.name).to.be.equal("python-course"); @@ -144,22 +151,22 @@ suite("tmc langs cli spec", function () { const exercise = (await tmc.getExerciseDetails(1)).unwrap(); expect(exercise.exercise_name).to.be.equal("part01-01_passing_exercise"); - const submissions = (await tmc.getOldSubmissions(1)).unwrap(); + const submissions = (await tmc.getTmcOldSubmissions(1)).unwrap(); expect(submissions.length).to.be.greaterThan(0); const organization = (await tmc.getOrganization("test")).unwrap(); expect(organization.slug).to.be.equal("test"); expect(organization.name).to.be.equal("Test Organization"); - const organizations = (await tmc.getOrganizations()).unwrap(); + const organizations = (await tmc.getTmcOrganizations()).unwrap(); expect(organizations.length).to.be.equal(1, "Expected to get one organization."); }); test("should encounter errors when trying to get non-existing api data", async function () { - const dataResult = await tmc.getCourseData(404); + const dataResult = await tmc.getTmcCourseData(404); expect(dataResult.val).to.be.instanceOf(RuntimeError); - const detailsResult = await tmc.getCourseDetails(404); + const detailsResult = await tmc.getCourseDetails(CourseIdentifier.from(404)); expect(detailsResult.val).to.be.instanceOf(RuntimeError); const exercisesResult = await tmc.getCourseExercises(404); @@ -174,7 +181,7 @@ suite("tmc langs cli spec", function () { const exerciseResult = await tmc.getExerciseDetails(404); expect(exerciseResult.val).to.be.instanceOf(RuntimeError); - const submissionsResult = await tmc.getOldSubmissions(404); + const submissionsResult = await tmc.getTmcOldSubmissions(404); expect(submissionsResult.val).to.be.instanceOf(RuntimeError); const result = await tmc.getOrganization("404"); @@ -195,8 +202,10 @@ suite("tmc langs cli spec", function () { setup(async function () { deleteSync(projectsDir, { force: true }); - const result = (await tmc.downloadExercises([1], true, () => {})).unwrap(); - exercisePath = result.downloaded[0].path; + const [tmcRes, moocRes] = ( + await tmc.downloadExercises([ExerciseIdentifier.from(1)], true, () => {}) + ).unwrap(); + exercisePath = tmcRes.downloaded[0].path; }); test("should be able to clean the exercise", async function () { @@ -204,7 +213,9 @@ suite("tmc langs cli spec", function () { }); test("should be able to list local exercises", async function () { - const result = await unwrapResult(tmc.listLocalCourseExercises("python-course")); + const result = await unwrapResult( + tmc.listLocalCourseExercises("tmc", "python-course"), + ); expect(result.length).to.be.equal(1); expect(first(result)?.["exercise-path"]).to.be.equal(exercisePath); }); @@ -231,45 +242,49 @@ suite("tmc langs cli spec", function () { }); test("should be able to check for exercise updates", async function () { - const result = await unwrapResult(tmc.checkExerciseUpdates()); + const result = await unwrapResult(tmc.checkTmcExerciseUpdates()); expect(result.length).to.be.equal(0); }); test("should be able to save the exercise state and revert it to an old submission", async function () { - const submissions = await unwrapResult(tmc.getOldSubmissions(1)); - await unwrapResult(tmc.downloadOldSubmission(1, exercisePath, 1, true)); + const submissions = await unwrapResult(tmc.getTmcOldSubmissions(1)); + await unwrapResult(tmc.downloadTmcOldSubmission(1, exercisePath, 1, true)); // State saving check is based on a side effect of making a new submission. - const newSubmissions = await unwrapResult(tmc.getOldSubmissions(1)); + const newSubmissions = await unwrapResult(tmc.getTmcOldSubmissions(1)); expect(newSubmissions.length).to.be.equal(submissions.length + 1); }); test("should be able to download an old submission without saving the current state", async function () { - const submissions = await unwrapResult(tmc.getOldSubmissions(1)); - await unwrapResult(tmc.downloadOldSubmission(1, exercisePath, 1, false)); + const submissions = await unwrapResult(tmc.getTmcOldSubmissions(1)); + await unwrapResult(tmc.downloadTmcOldSubmission(1, exercisePath, 1, false)); // State saving check is based on a side effect of making a new submission. - const newSubmissions = await unwrapResult(tmc.getOldSubmissions(1)); + const newSubmissions = await unwrapResult(tmc.getTmcOldSubmissions(1)); expect(newSubmissions.length).to.be.equal(submissions.length); }); // Langs fails to remove folder on Windows CI test.skip("should be able to save the exercise state and reset it to original template", async function () { - const submissions = await unwrapResult(tmc.getOldSubmissions(1)); - await unwrapResult(tmc.resetExercise(1, exercisePath, true)); + const submissions = await unwrapResult(tmc.getTmcOldSubmissions(1)); + await unwrapResult( + tmc.resetExercise(ExerciseIdentifier.from(1), exercisePath, true), + ); // State saving check is based on a side effect of making a new submission. - const newSubmissions = await unwrapResult(tmc.getOldSubmissions(1)); + const newSubmissions = await unwrapResult(tmc.getTmcOldSubmissions(1)); expect(newSubmissions.length).to.be.equal(submissions.length + 1); }); // Langs fails to remove folder on Windows CI test.skip("should be able to reset exercise without saving the current state", async function () { - const submissions = await unwrapResult(tmc.getOldSubmissions(1)); - await unwrapResult(tmc.resetExercise(1, exercisePath, false)); + const submissions = await unwrapResult(tmc.getTmcOldSubmissions(1)); + await unwrapResult( + tmc.resetExercise(ExerciseIdentifier.from(1), exercisePath, false), + ); // State saving check is based on a side effect of making a new submission. - const newSubmissions = await unwrapResult(tmc.getOldSubmissions(1)); + const newSubmissions = await unwrapResult(tmc.getTmcOldSubmissions(1)); expect(newSubmissions.length).to.be.equal(submissions.length); }); @@ -277,7 +292,7 @@ suite("tmc langs cli spec", function () { let url: string | undefined; const results = await unwrapResult( tmc.submitExerciseAndWaitForResults( - 1, + ExerciseIdentifier.from(1), exercisePath, undefined, (x) => (url = x), @@ -288,20 +303,26 @@ suite("tmc langs cli spec", function () { }); test("should encounter an error if trying to submit the exercise twice too soon", async function () { - const first = tmc.submitExerciseAndWaitForResults(1, exercisePath); - const second = tmc.submitExerciseAndWaitForResults(1, exercisePath); + const first = tmc.submitExerciseAndWaitForResults( + ExerciseIdentifier.from(1), + exercisePath, + ); + const second = tmc.submitExerciseAndWaitForResults( + ExerciseIdentifier.from(1), + exercisePath, + ); const [, secondResult] = await Promise.all([first, second]); expect(secondResult.val).to.be.instanceOf(BottleneckError); }); test("should be able to submit the exercise to TMC-paste", async function () { - const pasteUrl = await unwrapResult(tmc.submitExerciseToPaste(1, exercisePath)); + const pasteUrl = await unwrapResult(tmc.submitTmcExerciseToPaste(1, exercisePath)); expect(pasteUrl).to.include("localhost"); }); test("should encounter an error if trying to submit to paste twice too soon", async function () { - const first = tmc.submitExerciseToPaste(1, exercisePath); - const second = tmc.submitExerciseToPaste(1, exercisePath); + const first = tmc.submitTmcExerciseToPaste(1, exercisePath); + const second = tmc.submitTmcExerciseToPaste(1, exercisePath); const [, secondResult] = await Promise.all([first, second]); expect(secondResult.val).to.be.instanceOf(BottleneckError); }); @@ -326,22 +347,29 @@ suite("tmc langs cli spec", function () { // Downloads exercise on Langs 0.18 test.skip("should encounter an error when attempting to revert to an older submission", async function () { - const result = await tmc.downloadOldSubmission(1, missingExercisePath, 1, false); + const result = await tmc.downloadTmcOldSubmission(1, missingExercisePath, 1, false); expect(result.val).to.be.instanceOf(RuntimeError); }); test("should encounter an error when trying to reset it", async function () { - const result = await tmc.resetExercise(1, missingExercisePath, false); + const result = await tmc.resetExercise( + ExerciseIdentifier.from(1), + missingExercisePath, + false, + ); expect(result.val).to.be.instanceOf(RuntimeError); }); test("should encounter an error when trying to submit it", async function () { - const result = await tmc.submitExerciseAndWaitForResults(1, missingExercisePath); + const result = await tmc.submitExerciseAndWaitForResults( + ExerciseIdentifier.from(1), + missingExercisePath, + ); expect(result.val).to.be.instanceOf(RuntimeError); }); test("should encounter an error when trying to submit it to TMC-paste", async function () { - const result = await tmc.submitExerciseToPaste(404, missingExercisePath); + const result = await tmc.submitTmcExerciseToPaste(404, missingExercisePath); expect(result.val).to.be.instanceOf(RuntimeError); }); }); @@ -352,7 +380,7 @@ suite("tmc langs cli spec", function () { let onLoggedOutCalls: number; let configDir: string; let projectsDir: string; - let tmc: TMC; + let tmc: Langs; setup(function () { configDir = path.join(testDir, CLIENT_CONFIG_DIR_NAME); @@ -360,7 +388,7 @@ suite("tmc langs cli spec", function () { onLoggedInCalls = 0; onLoggedOutCalls = 0; projectsDir = setupProjectsDir(configDir, path.join(testDir, "tmcdata")); - tmc = new TMC(CLI_FILE, CLIENT_NAME, "test", { + tmc = new Langs(CLI_FILE, CLIENT_NAME, "test", { cliConfigDir: testDir, }); tmc.on("login", () => onLoggedInCalls++); @@ -389,15 +417,19 @@ 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.downloadExercises( + [ExerciseIdentifier.from(1)], + true, + () => {}, + ); expect(result.val).to.be.instanceOf(RuntimeError); }); test("should not get existing api data in general", async function () { - const dataResult = await tmc.getCourseData(0); + const dataResult = await tmc.getTmcCourseData(0); expect(dataResult.val).to.be.instanceOf(RuntimeError); - const detailsResult = await tmc.getCourseDetails(0); + const detailsResult = await tmc.getCourseDetails(CourseIdentifier.from(0)); expect(detailsResult.val).to.be.instanceOf(AuthorizationError); const exercisesResult = await tmc.getCourseExercises(0); @@ -412,7 +444,7 @@ suite("tmc langs cli spec", function () { const exerciseResult = await tmc.getExerciseDetails(1); expect(exerciseResult.val).to.be.instanceOf(AuthorizationError); - const submissionsResult = await tmc.getOldSubmissions(1); + const submissionsResult = await tmc.getTmcOldSubmissions(1); expect(submissionsResult.val).to.be.instanceOf(AuthorizationError); }); @@ -421,7 +453,7 @@ suite("tmc langs cli spec", function () { expect(organization.slug).to.be.equal("test"); expect(organization.name).to.be.equal("Test Organization"); - const organizations = await unwrapResult(tmc.getOrganizations()); + const organizations = await unwrapResult(tmc.getTmcOrganizations()); expect(organizations.length).to.be.equal(1, "Expected to get one organization."); }); @@ -447,9 +479,11 @@ suite("tmc langs cli spec", function () { setup(async function () { deleteSync(projectsDir, { force: true }); writeCredentials(configDir); - const result = (await tmc.downloadExercises([1], true, () => {})).unwrap(); + const [tmcRes, moocRes] = ( + await tmc.downloadExercises([ExerciseIdentifier.from(1)], true, () => {}) + ).unwrap(); clearCredentials(configDir); - exercisePath = result.downloaded[0].path; + exercisePath = tmcRes.downloaded[0].path; }); test("should be able to clean the exercise", async function () { @@ -458,7 +492,9 @@ suite("tmc langs cli spec", function () { }); test("should be able to list local exercises", async function () { - const result = await unwrapResult(tmc.listLocalCourseExercises("python-course")); + const result = await unwrapResult( + tmc.listLocalCourseExercises("tmc", "python-course"), + ); expect(result.length).to.be.equal(1); expect(first(result)?.["exercise-path"]).to.be.equal(exercisePath); }); @@ -469,23 +505,30 @@ suite("tmc langs cli spec", function () { }); test("should not be able to load old submission", async function () { - const result = await tmc.downloadOldSubmission(1, exercisePath, 1, true); + const result = await tmc.downloadTmcOldSubmission(1, exercisePath, 1, true); expect(result.val).to.be.instanceOf(RuntimeError); }); test("should not be able to reset exercise", async function () { - const result = await tmc.resetExercise(1, exercisePath, true); + const result = await tmc.resetExercise( + ExerciseIdentifier.from(1), + exercisePath, + true, + ); expect(result.val).to.be.instanceOf(AuthorizationError); }); test("should not be able to submit exercise", async function () { - const result = await tmc.submitExerciseAndWaitForResults(1, exercisePath); + const result = await tmc.submitExerciseAndWaitForResults( + ExerciseIdentifier.from(1), + exercisePath, + ); expect(result.val).to.be.instanceOf(AuthorizationError); }); // This actually works test.skip("should not be able to submit exercise to TMC-paste", async function () { - const result = await tmc.submitExerciseToPaste(1, exercisePath); + const result = await tmc.submitTmcExerciseToPaste(1, exercisePath); expect(result.val).to.be.instanceOf(AuthorizationError); }); }); diff --git a/src/test/actions/checkForExerciseUpdates.test.ts b/src/test/actions/checkForExerciseUpdates.test.ts index a5c1f91e..531815ef 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 Langs from "../../api/langs"; import { UserData } from "../../config/userdata"; import { createMockActionContext } from "../mocks/actionContext"; import { createTMCMock, TMCMockValues } from "../mocks/tmc"; @@ -14,13 +14,13 @@ suite("checkForExerciseUpdates action", function () { const stubContext = createMockActionContext(); const updateableExercises = [{ courseId: 0, exerciseId: 2, exerciseName: "other_world" }]; - let tmcMock: IMock; + let tmcMock: IMock; let tmcMockValues: TMCMockValues; let userDataMock: IMock; const actionContext = (): ActionContext => ({ ...stubContext, - tmc: new Ok(tmcMock.object), + langs: new Ok(tmcMock.object), userData: new Ok(userDataMock.object), }); @@ -38,7 +38,7 @@ suite("checkForExerciseUpdates action", function () { for (const forceRefresh of [true, false]) { await checkForExerciseUpdates(actionContext(), { forceRefresh }); tmcMock.verify( - (x) => x.checkExerciseUpdates(It.isObjectWith({ forceRefresh })), + (x) => x.checkTmcExerciseUpdates(It.isObjectWith({ forceRefresh })), Times.once(), ); } diff --git a/src/test/actions/downloadOrUpdateExercises.test.ts b/src/test/actions/downloadOrUpdateExercises.test.ts index b3c94b33..350340fb 100644 --- a/src/test/actions/downloadOrUpdateExercises.test.ts +++ b/src/test/actions/downloadOrUpdateExercises.test.ts @@ -3,12 +3,12 @@ import { first, last } from "lodash"; import { Err, Ok, Result } from "ts-results"; import { IMock, It, Times } from "typemoq"; -import { downloadOrUpdateExercises } from "../../actions"; import { ActionContext } from "../../actions/types"; import Dialog from "../../api/dialog"; -import TMC from "../../api/tmc"; +import Langs from "../../api/langs"; import Settings from "../../config/settings"; import { + DownloadOrUpdateMoocCourseExercisesResult, DownloadOrUpdateTmcCourseExercisesResult, TmcExerciseDownload, } from "../../shared/langsSchema"; @@ -19,6 +19,8 @@ import { createDialogMock } from "../mocks/dialog"; import { createSettingsMock, SettingsMockValues } from "../mocks/settings"; import { createTMCMock, TMCMockValues } from "../mocks/tmc"; import { createUIMock } from "../mocks/ui"; +import { downloadOrUpdateExercises } from "../../actions"; +import { ExerciseIdentifier } from "../../shared/shared"; const helloWorld: TmcExerciseDownload = { "course-slug": "python-course", @@ -40,7 +42,7 @@ suite("downloadOrUpdateExercises action", function () { let dialogMock: IMock; let settingsMock: IMock; let settingsMockValues: SettingsMockValues; - let tmcMock: IMock; + let tmcMock: IMock; let tmcMockValues: TMCMockValues; let uiMock: IMock; let webviewMessages: WebviewMessage[]; @@ -49,7 +51,7 @@ suite("downloadOrUpdateExercises action", function () { ...stubContext, dialog: dialogMock.object, settings: settingsMock.object, - tmc: new Ok(tmcMock.object), + langs: new Ok(tmcMock.object), ui: uiMock.object, }); @@ -57,12 +59,18 @@ suite("downloadOrUpdateExercises action", function () { downloaded: TmcExerciseDownload[], skipped: TmcExerciseDownload[], failed: Array<[TmcExerciseDownload, string[]]> | undefined, - ): Result => { - return Ok({ - downloaded, - failed, - skipped, - }); + ): Result< + [DownloadOrUpdateTmcCourseExercisesResult, DownloadOrUpdateMoocCourseExercisesResult], + Error + > => { + return Ok([ + { + downloaded, + failed, + skipped, + }, + { downloaded: [], failed: [], skipped: [] }, + ]); }; setup(function () { @@ -92,7 +100,10 @@ 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 downloadOrUpdateExercises(actionContext(), [ + ExerciseIdentifier.from(1), + ExerciseIdentifier.from(2), + ]); expect(result.val).to.be.equal(error); }); @@ -102,7 +113,12 @@ suite("downloadOrUpdateExercises action", function () { [], undefined, ); - const result = (await downloadOrUpdateExercises(actionContext(), [1, 2])).unwrap(); + const result = ( + await downloadOrUpdateExercises(actionContext(), [ + ExerciseIdentifier.from(1), + ExerciseIdentifier.from(2), + ]) + ).unwrap(); expect(result.successful).to.be.deep.equal([1, 2]); }); @@ -112,7 +128,12 @@ suite("downloadOrUpdateExercises action", function () { [helloWorld, otherWorld], undefined, ); - const result = (await downloadOrUpdateExercises(actionContext(), [1, 2])).unwrap(); + const result = ( + await downloadOrUpdateExercises(actionContext(), [ + ExerciseIdentifier.from(1), + ExerciseIdentifier.from(2), + ]) + ).unwrap(); expect(result.successful).to.be.deep.equal([1, 2]); }); @@ -122,7 +143,9 @@ suite("downloadOrUpdateExercises action", function () { [otherWorld], undefined, ); - const result = (await downloadOrUpdateExercises(actionContext(), [1])).unwrap(); + const result = ( + await downloadOrUpdateExercises(actionContext(), [ExerciseIdentifier.from(1)]) + ).unwrap(); expect(result.successful).to.be.deep.equal([1, 2]); }); @@ -135,14 +158,19 @@ suite("downloadOrUpdateExercises action", function () { [otherWorld, [""]], ], ); - const result = (await downloadOrUpdateExercises(actionContext(), [1, 2])).unwrap(); + const result = ( + await downloadOrUpdateExercises(actionContext(), [ + ExerciseIdentifier.from(1), + ExerciseIdentifier.from(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 downloadOrUpdateExercises(actionContext(), [ExerciseIdentifier.from(1)]); tmcMock.verify( (x) => x.downloadExercises(It.isAny(), It.isValue(true), It.isAny()), Times.once(), @@ -156,7 +184,7 @@ 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 downloadOrUpdateExercises(actionContext(), [ExerciseIdentifier.from(1)]); tmcMock.verify( (x) => x.downloadExercises(It.isAny(), It.isValue(true), It.isAny()), Times.never(), @@ -176,7 +204,7 @@ suite("downloadOrUpdateExercises action", function () { cb({ id: helloWorld.id, percent: 0.5 }); return createDownloadResult([helloWorld], [], undefined); }); - await downloadOrUpdateExercises(actionContext(), [1]); + await downloadOrUpdateExercises(actionContext(), [ExerciseIdentifier.from(1)]); expect(webviewMessages.length).to.be.greaterThanOrEqual( 2, "expected at least two status messages", @@ -193,7 +221,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 downloadOrUpdateExercises(actionContext(), [ExerciseIdentifier.from(1)]); expect(webviewMessages.length).to.be.greaterThanOrEqual( 2, "expected at least two status messages", @@ -210,7 +238,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 downloadOrUpdateExercises(actionContext(), [ExerciseIdentifier.from(1)]); expect(webviewMessages.length).to.be.greaterThanOrEqual( 2, "expected at least two status messages", @@ -227,7 +255,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 downloadOrUpdateExercises(actionContext(), [ExerciseIdentifier.from(1)]); expect(webviewMessages.length).to.be.greaterThanOrEqual( 2, "expected at least two status messages", @@ -245,7 +273,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 downloadOrUpdateExercises(actionContext(), [ExerciseIdentifier.from(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 d9ebeb2e..9ca8f3be 100644 --- a/src/test/actions/moveExtensionDataPath.test.ts +++ b/src/test/actions/moveExtensionDataPath.test.ts @@ -6,7 +6,7 @@ import * as vscode from "vscode"; import { moveExtensionDataPath } from "../../actions"; import { ActionContext } from "../../actions/types"; -import TMC from "../../api/tmc"; +import Langs from "../../api/langs"; import WorkspaceManager, { ExerciseStatus } from "../../api/workspaceManager"; import { UserData } from "../../config/userdata"; import { workspaceExercises } from "../fixtures/workspaceManager"; @@ -35,7 +35,7 @@ suite("moveExtensionDataPath action", function () { const stubContext = createMockActionContext(); let root: string; - let tmcMock: IMock; + let tmcMock: IMock; let tmcMockValues: TMCMockValues; let userDataMock: IMock; let workspaceManagerMock: IMock; @@ -43,7 +43,7 @@ suite("moveExtensionDataPath action", function () { const actionContext = (): ActionContext => ({ ...stubContext, - tmc: new Ok(tmcMock.object), + langs: new Ok(tmcMock.object), userData: new Ok(userDataMock.object), workspaceManager: new Ok(workspaceManagerMock.object), }); @@ -78,7 +78,12 @@ suite("moveExtensionDataPath action", function () { test.skip("should close current workspace's exercises", async function () { await moveExtensionDataPath(actionContext(), emptyFolder(root)); workspaceManagerMock.verify( - (x) => x.closeCourseExercises(It.isValue(courseName), It.isValue(openExerciseSlugs)), + (x) => + x.closeCourseExercises( + "tmc", + It.isValue(courseName), + It.isValue(openExerciseSlugs), + ), Times.once(), ); }); @@ -93,7 +98,12 @@ suite("moveExtensionDataPath action", function () { workspaceManagerMockValues.activeCourse = undefined; await moveExtensionDataPath(actionContext(), emptyFolder(root)); workspaceManagerMock.verify( - (x) => x.closeCourseExercises(It.isValue(courseName), It.isValue(openExerciseSlugs)), + (x) => + x.closeCourseExercises( + "tmc", + It.isValue(courseName), + It.isValue(openExerciseSlugs), + ), Times.never(), ); }); diff --git a/src/test/actions/refreshLocalExercises.test.ts b/src/test/actions/refreshLocalExercises.test.ts index 1a6f0364..cbcc9d13 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 Langs from "../../api/langs"; import WorkspaceManager from "../../api/workspaceManager"; import { UserData } from "../../config/userdata"; import { createMockActionContext } from "../mocks/actionContext"; @@ -15,7 +15,7 @@ import { createWorkspaceMangerMock, WorkspaceManagerMockValues } from "../mocks/ suite("refreshLocalExercises action", function () { const stubContext = createMockActionContext(); - let tmcMock: IMock; + let tmcMock: IMock; let tmcMockValues: TMCMockValues; let userDataMock: IMock; let userDataMockValues: UserDataMockValues; @@ -24,7 +24,7 @@ suite("refreshLocalExercises action", function () { const actionContext = (): ActionContext => ({ ...stubContext, - tmc: new Ok(tmcMock.object), + langs: new Ok(tmcMock.object), userData: new Ok(userDataMock.object), workspaceManager: new Ok(workspaceManagerMock.object), }); 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 17394032..871a1da2 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 Langs from "../../api/langs"; import WorkspaceManager, { ExerciseStatus } from "../../api/workspaceManager"; import { cleanExercise } from "../../commands"; import { createMockActionContext } from "../mocks/actionContext"; @@ -19,14 +19,14 @@ suite("Clean exercise command", function () { const stubContext = createMockActionContext(); const uri = vscode.Uri.file(PASSING_EXERCISE_PATH); - let tmcMock: IMock; + let tmcMock: IMock; let workspaceManagerMock: IMock; let workspaceManagerMockValues: WorkspaceManagerMockValues; function actionContext(): ActionContext { return { ...stubContext, - tmc: new Ok(tmcMock.object), + langs: new Ok(tmcMock.object), workspaceManager: new Ok(workspaceManagerMock.object), }; } @@ -38,6 +38,7 @@ suite("Clean exercise command", function () { test("should clean active exercise by default", async function () { workspaceManagerMockValues.activeExercise = { + backend: "tmc", courseSlug: "test-python-course", exerciseSlug: "part01-01_passing_exercise", status: ExerciseStatus.Open, diff --git a/src/test/fixtures/userData.ts b/src/test/fixtures/userData.ts index cc16b56c..6f23a4fd 100644 --- a/src/test/fixtures/userData.ts +++ b/src/test/fixtures/userData.ts @@ -1,7 +1,8 @@ -import { LocalCourseExercise, UserData } from "../../api/storage"; +import { UserData } from "../../api/storage"; import { LocalCourseDataV0, LocalCourseDataV1 } from "../../migrate/migrateUserData"; +import { LocalTmcCourseExercise } from "../../shared/shared"; -export const userDataExerciseHelloWorld: LocalCourseExercise = { +export const userDataExerciseHelloWorld: LocalTmcCourseExercise = { id: 1, availablePoints: 1, awardedPoints: 0, @@ -229,7 +230,7 @@ export const v2_0_0: UserDataV1 = { }; export const v2_1_0: UserData = { - courses: [ + tmcCourses: [ { id: 0, availablePoints: 3, @@ -265,4 +266,5 @@ export const v2_1_0: UserData = { title: "The Python Course", }, ], + moocCourses: [], }; diff --git a/src/test/fixtures/workspaceManager.ts b/src/test/fixtures/workspaceManager.ts index 5655abdb..5750d4be 100644 --- a/src/test/fixtures/workspaceManager.ts +++ b/src/test/fixtures/workspaceManager.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { ExerciseStatus, WorkspaceExercise } from "../../api/workspaceManager"; export const exerciseHelloWorld: WorkspaceExercise = { + backend: "tmc", courseSlug: "test-python-course", exerciseSlug: "hello_world", status: ExerciseStatus.Open, @@ -10,6 +11,7 @@ export const exerciseHelloWorld: WorkspaceExercise = { }; export const exerciseOtherWorld: WorkspaceExercise = { + backend: "tmc", courseSlug: "test-python-course", exerciseSlug: "other_world", status: ExerciseStatus.Closed, diff --git a/src/test/migrate/migrate.test.ts b/src/test/migrate/migrate.test.ts index 5d5810c1..70a0c64e 100644 --- a/src/test/migrate/migrate.test.ts +++ b/src/test/migrate/migrate.test.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode"; import Dialog from "../../api/dialog"; import Storage from "../../api/storage"; -import TMC from "../../api/tmc"; +import Langs from "../../api/langs"; import { migrateExtensionDataFromPreviousVersions } from "../../migrate"; import { Logger, LogLevel } from "../../utilities"; import * as exerciseData from "../fixtures/exerciseData"; @@ -36,7 +36,7 @@ suite("Extension data migration", function () { let context: vscode.ExtensionContext; let dialogMock: IMock; let storage: Storage; - let tmcMock: IMock; + let tmcMock: IMock; let settingsMock: IMock; let root: string; diff --git a/src/test/migrate/migrateExerciseData.test.ts b/src/test/migrate/migrateExerciseData.test.ts index d335e60f..6bde9ccd 100644 --- a/src/test/migrate/migrateExerciseData.test.ts +++ b/src/test/migrate/migrateExerciseData.test.ts @@ -3,7 +3,7 @@ import { IMock, It, Times } from "typemoq"; import * as vscode from "vscode"; import Dialog from "../../api/dialog"; -import TMC from "../../api/tmc"; +import Langs from "../../api/langs"; import migrateExerciseData from "../../migrate/migrateExerciseData"; import { Logger, LogLevel } from "../../utilities"; import * as exerciseData from "../fixtures/exerciseData"; @@ -25,7 +25,7 @@ suite("Exercise data migration", function () { let dialogMock: IMock; let memento: vscode.Memento; - let tmcMock: IMock; + let tmcMock: IMock; setup(function () { [dialogMock] = createDialogMock(); diff --git a/src/test/migrate/migrateUserData.test.ts b/src/test/migrate/migrateUserData.test.ts index 928dbb75..933f670c 100644 --- a/src/test/migrate/migrateUserData.test.ts +++ b/src/test/migrate/migrateUserData.test.ts @@ -32,7 +32,7 @@ suite("User data migration", function () { test("should succeed with version 0.1.0 data", async function () { await memento.update(USER_DATA_KEY_V0, userData.v0_1_0); - const migratedCourse = migrateUserData(memento).data?.courses[0]; + const migratedCourse = migrateUserData(memento).data?.tmcCourses[0]; expect(migratedCourse?.id).to.be.equal(0); expect(migratedCourse?.description).to.be.equal("Python Course"); expect(migratedCourse?.exercises.length).to.be.equal(2); @@ -43,27 +43,27 @@ suite("User data migration", function () { test("should succeed with version 0.2.0 data", async function () { await memento.update(USER_DATA_KEY_V0, userData.v0_2_0); - const migratedCourse = migrateUserData(memento).data?.courses[0]; + const migratedCourse = migrateUserData(memento).data?.tmcCourses[0]; expect(migratedCourse?.availablePoints).to.be.equal(3); expect(migratedCourse?.awardedPoints).to.be.equal(0); }); test("should succeed with version 0.3.0 data", async function () { await memento.update(USER_DATA_KEY_V0, userData.v0_3_0); - const migratedCourse = migrateUserData(memento).data?.courses[0]; + const migratedCourse = migrateUserData(memento).data?.tmcCourses[0]; expect(migratedCourse?.newExercises).to.be.deep.equal([2, 3, 4]); expect(migratedCourse?.notifyAfter).to.be.equal(1234); }); test("should succeed with version 0.4.0 data", async function () { await memento.update(USER_DATA_KEY_V0, userData.v0_4_0); - const migratedCourse = migrateUserData(memento).data?.courses[0]; + const migratedCourse = migrateUserData(memento).data?.tmcCourses[0]; expect(migratedCourse?.title).to.be.equal("The Python Course"); }); test("should succeed with version 0.6.0 data", async function () { await memento.update(USER_DATA_KEY_V0, userData.v0_6_0); - const migratedCourse = migrateUserData(memento).data?.courses[0]; + const migratedCourse = migrateUserData(memento).data?.tmcCourses[0]; expect(migratedCourse?.exercises.find((x) => x.id === 1)?.name).to.be.equal( "hello_world", ); @@ -74,27 +74,27 @@ suite("User data migration", function () { test("should succeed with version 0.8.0 data", async function () { await memento.update(USER_DATA_KEY_V0, userData.v0_8_0); - const migratedCourse = migrateUserData(memento).data?.courses[0]; + const migratedCourse = migrateUserData(memento).data?.tmcCourses[0]; expect(migratedCourse?.perhapsExamMode).to.be.true; }); test("should succeed with version 0.9.0 data", async function () { await memento.update(USER_DATA_KEY_V0, userData.v0_9_0); - const migratedCourse = migrateUserData(memento).data?.courses[0]; + const migratedCourse = migrateUserData(memento).data?.tmcCourses[0]; expect(migratedCourse?.disabled).to.be.true; expect(migratedCourse?.materialUrl).to.be.equal("mooc.fi"); }); test("should succeed with version 1.0.0 data", async function () { await memento.update(USER_DATA_KEY_V0, userData.v1_0_0); - const migratedCourse = migrateUserData(memento).data?.courses[0]; + const migratedCourse = migrateUserData(memento).data?.tmcCourses[0]; expect(migratedCourse?.disabled).to.be.true; expect(migratedCourse?.materialUrl).to.be.equal("mooc.fi"); }); test("should succeed with version 2.0.0 data", async function () { await memento.update(USER_DATA_KEY_V1, userData.v2_0_0); - const courses = migrateUserData(memento).data?.courses; + const courses = migrateUserData(memento).data?.tmcCourses; courses?.forEach((course) => { course.exercises.forEach((x) => { expect(x.availablePoints).to.be.equal( @@ -134,7 +134,7 @@ suite("User data migration", function () { test("should find more exercise info from old exerciseData", async function () { await memento.update(UNSTABLE_EXERCISE_DATA_KEY, exerciseData.v0_1_0(root)); await memento.update(USER_DATA_KEY_V0, userData.v0_1_0); - const migratedCourse = migrateUserData(memento).data?.courses[0]; + const migratedCourse = migrateUserData(memento).data?.tmcCourses[0]; const exercise1 = migratedCourse?.exercises.find((x) => x.id === 1); expect(exercise1?.deadline).to.be.equal("20201214"); @@ -171,7 +171,7 @@ suite("User data migration", function () { }, ]; await memento.update(USER_DATA_KEY_V0, { courses }); - const migrated = migrateUserData(memento).data?.courses; + const migrated = migrateUserData(memento).data?.tmcCourses; expect(migrated?.length).to.be.equal(2); expect(migrated?.find((x) => x.id === 0)?.name).to.be.equal("test-python-course"); expect(migrated?.find((x) => x.id === 1)?.name).to.be.equal("test-java-course"); diff --git a/src/test/mocks/actionContext.ts b/src/test/mocks/actionContext.ts index 7232f4e5..19a295ef 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 Langs from "../../api/langs"; import WorkspaceManager from "../../api/workspaceManager"; import Resouces from "../../config/resources"; import Settings from "../../config/settings"; @@ -18,7 +18,7 @@ export function createMockActionContext(): ActionContext { exerciseDecorationProvider: Mock.ofType>().object, resources: Mock.ofType>().object, settings: Mock.ofType().object, - tmc: Mock.ofType>().object, + langs: Mock.ofType>().object, ui: Mock.ofType().object, userData: Mock.ofType>().object, workspaceManager: Mock.ofType>().object, diff --git a/src/test/mocks/tmc.ts b/src/test/mocks/tmc.ts index 5dc315f0..17125282 100644 --- a/src/test/mocks/tmc.ts +++ b/src/test/mocks/tmc.ts @@ -1,8 +1,9 @@ import { Err, Ok, Result } from "ts-results"; import { IMock, It, Mock } from "typemoq"; -import TMC from "../../api/tmc"; +import Langs from "../../api/langs"; import { + DownloadOrUpdateMoocCourseExercisesResult, DownloadOrUpdateTmcCourseExercisesResult, LocalExercise, LocalTmcExercise, @@ -17,7 +18,10 @@ const NOT_MOCKED_ERROR = Err(new Error("Method was not mocked.")); export interface TMCMockValues { clean: Result; - downloadExercises: Result; + downloadExercises: Result< + [DownloadOrUpdateTmcCourseExercisesResult, DownloadOrUpdateMoocCourseExercisesResult], + Error + >; listLocalCourseExercisesPythonCourse: Result; getSettingClosedExercises: Result; getSettingProjectsDir: Result; @@ -27,7 +31,7 @@ export interface TMCMockValues { checkExerciseUpdates: Result, Error>; } -export function createTMCMock(): [IMock, TMCMockValues] { +export function createTMCMock(): [IMock, TMCMockValues] { const values: TMCMockValues = { clean: Ok.EMPTY, downloadExercises: NOT_MOCKED_ERROR, @@ -44,7 +48,7 @@ export function createTMCMock(): [IMock, TMCMockValues] { return [mock, values]; } -export function createFailingTMCMock(): [IMock, TMCMockValues] { +export function createFailingTMCMock(): [IMock, TMCMockValues] { const error = Err(new Error()); const values: TMCMockValues = { clean: error, @@ -62,8 +66,8 @@ export function createFailingTMCMock(): [IMock, TMCMockValues] { return [mock, values]; } -function setupMockValues(values: TMCMockValues): IMock { - const mock = Mock.ofType(); +function setupMockValues(values: TMCMockValues): IMock { + const mock = Mock.ofType(); // --------------------------------------------------------------------------------------------- // Authentication commands @@ -75,7 +79,7 @@ function setupMockValues(values: TMCMockValues): IMock { mock.setup((x) => x.clean(It.isAny())).returns(async () => values.clean); - mock.setup((x) => x.listLocalCourseExercises(It.isValue("test-python-course"))).returns( + mock.setup((x) => x.listLocalCourseExercises("tmc", It.isValue("test-python-course"))).returns( async () => values.listLocalCourseExercisesPythonCourse, ); @@ -103,7 +107,7 @@ function setupMockValues(values: TMCMockValues): IMock { // Core commands // --------------------------------------------------------------------------------------------- - mock.setup((x) => x.checkExerciseUpdates(It.isAny())).returns( + mock.setup((x) => x.checkTmcExerciseUpdates(It.isAny())).returns( async () => values.checkExerciseUpdates, ); diff --git a/src/test/mocks/userdata.ts b/src/test/mocks/userdata.ts index 3a1a246f..16a1420c 100644 --- a/src/test/mocks/userdata.ts +++ b/src/test/mocks/userdata.ts @@ -1,17 +1,22 @@ import { IMock, It, Mock } from "typemoq"; -import { LocalCourseData, LocalCourseExercise } from "../../api/storage"; import { UserData } from "../../config/userdata"; import { v2_1_0 as userData } from "../fixtures/userData"; +import { + LocalCourseData, + LocalTmcCourseExercise, + makeMoocKind, + makeTmcKind, +} from "../../shared/shared"; export interface UserDataMockValues { getCourses: LocalCourseData[]; - getExerciseByName: Readonly | undefined; + getExerciseByName: Readonly | undefined; } export function createUserDataMock(): [IMock, UserDataMockValues] { const values: UserDataMockValues = { - getCourses: userData.courses, + getCourses: userData.tmcCourses.map(makeTmcKind), getExerciseByName: undefined, }; const mock = setupMockValues(values); @@ -24,7 +29,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/test/mocks/workspaceManager.ts b/src/test/mocks/workspaceManager.ts index fd751d11..10578f2f 100644 --- a/src/test/mocks/workspaceManager.ts +++ b/src/test/mocks/workspaceManager.ts @@ -35,7 +35,7 @@ function setupMockValues(values: WorkspaceManagerMockValues): IMock x.activeCourse).returns(() => values.activeCourse); mock.setup((x) => x.activeExercise).returns(() => values.activeExercise); - mock.setup((x) => x.closeCourseExercises(It.isAny(), It.isAny())).returns( + mock.setup((x) => x.closeCourseExercises("tmc", It.isAny(), It.isAny())).returns( async () => values.closeExercises, ); 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..aaf2f6d2 100644 --- a/src/ui/types.ts +++ b/src/ui/types.ts @@ -1,15 +1,16 @@ import { FeedbackQuestion } from "../actions/types"; -import Storage, { LocalCourseData } from "../api/storage"; -import TMC from "../api/tmc"; +import Storage from "../api/storage"; +import Langs from "../api/langs"; import { Course, Organization } from "../api/types"; import { ExtensionSettings } from "../config/settings"; import { SubmissionFinished } from "../shared/langsSchema"; +import { CourseIdentifier, ExerciseIdentifier, LocalCourseData } from "../shared/shared"; import { LogLevel } from "../utilities/logger"; import UI from "./ui"; export type HandlerContext = { - tmc: TMC; + tmc: Langs; storage: Storage; ui: UI; visibilityGroups: VisibilityGroups; @@ -42,7 +43,7 @@ export type CourseDetailsExerciseGroup = { }; export type CourseDetailsExercise = { - id: number; + id: ExerciseIdentifier; name: string; passed: boolean; softDeadline: Date | null; @@ -132,7 +133,7 @@ export interface LoginError { export interface SetCourseDisabledStatus { command: "setCourseDisabledStatus"; - courseId: number; + courseId: CourseIdentifier; disabled: boolean; } diff --git a/src/utilities/apiData.ts b/src/utilities/apiData.ts index 128f1f9a..73c9fcf0 100644 --- a/src/utilities/apiData.ts +++ b/src/utilities/apiData.ts @@ -1,28 +1,28 @@ -import { LocalCourseExercise } from "../api/storage"; import { LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER, LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER, } from "../config/constants"; import { CourseExercise, Exercise } from "../shared/langsSchema"; +import { LocalTmcCourseExercise } from "../shared/shared"; /** * Takes exercise arrays from two different endpoints and attempts to resolve them into * `LocalCourseExercise`. Uses common default values, if matching id is not found from * `courseExercises`. */ -export function combineApiExerciseData( +export function combineTmcApiExerciseData( 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 ? LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER : LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER; - return { + const localCourseExercise: LocalTmcCourseExercise = { id: x.id, availablePoints: match?.available_points.length ?? LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, @@ -32,5 +32,6 @@ export function combineApiExerciseData( passed: x.completed, softDeadline: x.soft_deadline, }; + return localCourseExercise; }); } diff --git a/webview-ui/src/App.svelte b/webview-ui/src/App.svelte index d35aa249..c018d5f6 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"; import InitializationErrorHelp from "./panels/InitializationErrorHelp.svelte"; onMount(() => { @@ -120,6 +122,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 === "InitializationErrorHelp"} {:else if $state.panel.type === "App"} diff --git a/webview-ui/src/components/ExercisePart.svelte b/webview-ui/src/components/ExercisePart.svelte index 1c99a259..34447095 100644 --- a/webview-ui/src/components/ExercisePart.svelte +++ b/webview-ui/src/components/ExercisePart.svelte @@ -1,25 +1,47 @@ @@ -153,17 +238,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..."}
@@ -172,8 +265,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" > @@ -202,8 +295,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 @@ -217,8 +310,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 @@ -231,7 +324,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.
@@ -239,18 +332,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} @@ -269,12 +359,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 @@ -282,12 +368,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 @@ -295,12 +377,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..6915b6ad 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( + organizationSlug: string, + courseId: string, + instanceId: string, + courseName: string, + instanceName: string | null, + ) { + postMessageToWebview({ + type: "selectedMoocCourse", + target: panel.requestingPanel, + organizationSlug: organizationSlug, + 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.organization_slug, + instance.course_id, + instance.id, + instance.course_name, + instance.instance_name, + )} + on:keypress={() => + selectCourse( + instance.organization_slug, + 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}