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/config.js b/config.js index 48ffdae7..a2e0046e 100644 --- a/config.js +++ b/config.js @@ -3,7 +3,7 @@ const path = require("path"); -const TMC_LANGS_RUST_VERSION = "0.38.0"; +const TMC_LANGS_RUST_VERSION = "0.39.4"; const mockTmcLocalMooc = { __TMC_BACKEND_URL__: JSON.stringify("http://localhost:4001"), diff --git a/package.json b/package.json index 656de4e8..6b7712ab 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", @@ -243,6 +238,11 @@ "type": "boolean", "default": true, "description": "Download exercise updates automatically." + }, + "testMyCode.javaHome": { + "type": "string", + "default": "", + "description": "If set, the Java (JDK) version in this path is used for Java exercises." } } }, @@ -347,10 +347,6 @@ "command": "tmcTreeView.refreshCourses", "when": "false" }, - { - "command": "tmcTreeView.removeCourse", - "when": "false" - }, { "command": "tmcView.activateEntry", "when": "false" @@ -415,12 +411,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..8ae6800e 100644 --- a/shared/langsSchema.ts +++ b/shared/langsSchema.ts @@ -1,5 +1,5 @@ -// VERSION=0.38.0 -// https://raw.githubusercontent.com/rage/tmc-langs-rust/0.38.0/crates/tmc-langs-cli/bindings.d.ts +// VERSION=0.39.4 +// https://raw.githubusercontent.com/rage/tmc-langs-rust/0.39.4/crates/tmc-langs-cli/bindings.d.ts export type Locale = string; @@ -189,6 +189,10 @@ export type TmcProjectYml = { * Overrides the default sandbox image. e.g. `eu.gcr.io/moocfi-public/tmc-sandbox-python:latest` */ sandbox_image?: string; + /** + * Overrides the default archive size limit (500 Mb). + */ + submission_size_limit_mb?: number; }; export type PythonVer = { major: number; minor: number | null; patch: number | null }; diff --git a/shared/lib.ts b/shared/lib.ts index 23301b77..d37a79ad 100644 --- a/shared/lib.ts +++ b/shared/lib.ts @@ -1,3 +1,5 @@ +// types shared between the extension and webview code + /* * ======== state ======== */ @@ -7,12 +9,154 @@ import * as util from "node:util"; import { Course, + CourseInstance, Organization, RunResult, StyleValidationResult, SubmissionFinished, } from "./langsSchema"; import { createIs } from "typia"; +import { MoocLocalCourseExercise, TmcLocalCourseExercise } from "../storage/data"; + +// for now, these are just copied from the data module +// todo: think of better way to do this... +export interface SharedTmcCourseData { + id: number; + name: string; + title: string; + description: string; + organization: string; + exercises: Array; + availablePoints: number; + awardedPoints: number; + perhapsExamMode: boolean; + newExercises: Array; + notifyAfter: number; + disabled: boolean; + materialUrl: string | null; +} + +export interface SharedTmcCourseExercise { + id: number; + availablePoints: number; + awardedPoints: number; + /// Equivalent to exercise slug + name: string; + deadline: string | null; + passed: boolean; + softDeadline: string | null; +} + +export interface SharedMoocCourseData { + // instance id + id: string; + courseId: string; + // course slug + name: string; + instanceName: string | null; + title: string; + description: string | null; + courseDescription: string | null; + organization: string; + exercises: Array; + availablePoints: number; + awardedPoints: number; + perhapsExamMode: boolean; + newExercises: Array; + notifyAfter: number; + disabled: boolean; + materialUrl: string | null; +} + +export interface SharedMoocCourseExercise { + id: string; + availablePoints: number; + awardedPoints: number; + /// Equivalent to exercise slug + name: 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.name, + ); + } + + export function unwrap( + lce: LocalCourseExercise, + ): SharedTmcCourseExercise | SharedMoocCourseExercise { + 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.name, + ); + } + + 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 +184,8 @@ export type Panel = | SelectCoursePanel | ExerciseTestsPanel | ExerciseSubmissionPanel + | SelectPlatformPanel + | SelectMoocCoursePanel | InitializationErrorHelpPanel; export type PanelType = Panel["type"]; @@ -73,6 +219,7 @@ export type MyCoursesPanel = { id: number; type: "MyCourses"; courses?: Array; + moocCourses?: Array; tmcDataPath?: string; tmcDataSize?: string; courseDeadlines: Record; @@ -81,13 +228,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 +257,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 +266,8 @@ export type ExerciseTestsPanel = { export type ExerciseSubmissionPanel = { id: number; type: "ExerciseSubmission"; - course: TestCourse; - exercise: TestExercise; + course: LocalCourseData; + exercise: LocalCourseExercise; }; export type InitializationErrorHelpPanel = { @@ -126,6 +275,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 +309,7 @@ export type ExtensionToWebview = | { type: "setMyCourses"; target: TargetPanel; - courses: Array; + courses: Array; } | { type: "setTmcDataPath"; @@ -158,7 +319,7 @@ export type ExtensionToWebview = | { type: "setNextCourseDeadline"; target: TargetPanel; - courseId: number; + courseId: CourseIdentifier; deadline: string; } | { @@ -174,7 +335,7 @@ export type ExtensionToWebview = | { type: "setCourseData"; target: TargetPanel; - courseData: CourseData; + courseData: LocalCourseData; } | { type: "setCourseGroups"; @@ -185,19 +346,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 +425,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 +469,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 +531,7 @@ export type WebviewToExtension = } | { type: "removeCourse"; - id: number; + id: CourseIdentifier; } | { type: "openCourseWorkspace"; @@ -358,44 +539,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 +585,7 @@ export type WebviewToExtension = | { type: "addCourse"; organizationSlug: string; - courseId: number; + courseId: CourseIdentifier; requestingPanel: TargetPanel; } | { @@ -423,14 +602,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 +619,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 +663,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 +685,7 @@ export type ExerciseGroup = { }; export type Exercise = { - id: number; + id: ExerciseIdentifier; name: string; isHard: boolean; hardDeadlineString: string; @@ -502,7 +715,7 @@ export type TestExercise = { export type TestResultData = { testResult: RunResult; - id: number; + id: ExerciseIdentifier; courseSlug: string; exerciseName: string; tmcLogs: { @@ -515,7 +728,7 @@ export type TestResultData = { }; export type TestCourse = { - id: number; + id: CourseIdentifier; name: string; title: string; description: string; @@ -662,3 +875,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..68af90b9 100644 --- a/src/actions/addNewCourse.ts +++ b/src/actions/addNewCourse.ts @@ -1,61 +1,104 @@ 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, match } from "../shared/shared"; +import { MoocLocalCourseData, TmcLocalCourseData } from "../storage/data"; /** * 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: TmcLocalCourseData = { + 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: MoocLocalCourseData = { + id: courseInstance.id, + courseId: courseInstance.course_id, + name: courseInstance.course_slug, + instanceName: courseInstance.instance_name, + description: courseInstance.instance_description, + courseDescription: courseInstance.course_description, + title: courseInstance.course_name, + organization: courseInstance.organization_name, + 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.id, localData.name, { + command: "tmc.courseDetails", + title: "Go To Course Details", + arguments: [CourseIdentifier.from(localData.id)], + }); + workspaceManager.val.createWorkspaceFile(courseInstance.course_slug); + return refreshLocalExercises(actionContext); + }, + ); } diff --git a/src/actions/checkForExerciseUpdates.ts b/src/actions/checkForExerciseUpdates.ts index 09daedc7..fc360b8c 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.id), + exerciseId: ExerciseIdentifier.from(x.id), + exerciseName: x.name, + })); + } + 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..0fb36a9b --- /dev/null +++ b/src/actions/extension.ts @@ -0,0 +1,318 @@ +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 "../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 { 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 storage.migrateToLatest( + context, + 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..2101614c 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.name, + ); + 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.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: "mooc", + 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; + } + 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..d0074b35 100644 --- a/src/actions/updateCourse.ts +++ b/src/actions/updateCourse.ts @@ -2,12 +2,22 @@ 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"; +import { MoocLocalCourseExercise } from "../storage/data"; /** * Updates the given course by re-fetching all data from the server. Handles authorization and @@ -18,15 +28,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 +61,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 +97,60 @@ 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) => { + const localExercise: MoocLocalCourseExercise = { + id: t.task_id, + name: s.exercise_name, + deadline: s.deadline, + passed: false, + softDeadline: s.deadline, + availablePoints: 0, + awardedPoints: 0, + }; + return localExercise; + }), + ) + .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 +158,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..da8020b2 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.submitTmcExerciseAndWaitForResults( + 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..ce3981a6 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.name, + ); 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..3f6763fe 100644 --- a/src/api/tmc.ts +++ b/src/api/langs.ts @@ -1,5 +1,5 @@ import * as cp from "child_process"; -import * as kill from "tree-kill"; +import kill from "tree-kill"; import { Err, Ok, Result } from "ts-results"; import { validate } from "typia"; @@ -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, ); @@ -790,8 +959,8 @@ export default class TMC { * @param exerciseId Id of the exercise. * @param progressCallback Optional callback function that can be used to get status reports. */ - public async submitExerciseAndWaitForResults( - exerciseId: number, + public async submitTmcExerciseAndWaitForResults( + 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 deleted file mode 100644 index 12f98f52..00000000 --- a/src/api/storage.ts +++ /dev/null @@ -1,96 +0,0 @@ -import * as vscode from "vscode"; - -export interface ExtensionSettings { - downloadOldSubmission: boolean; - hideMetaFiles: boolean; - insiderVersion: boolean; - logLevel: "none" | "errors" | "verbose"; - 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[]; -} - -export interface SessionState { - extensionVersion?: string | undefined; -} - -/** - * Interface class for accessing stored TMC configuration and data. - */ -export default class Storage { - private static readonly _extensionSettingsKey = "extension-settings-v1"; - private static readonly _userDataKey = "user-data-v1"; - private static readonly _sessionStateKey = "session-state-v1"; - - private _context: vscode.ExtensionContext; - - /** - * Creates new instance of the TMC storage access object. - * @param context context of the extension where all data is stored - */ - constructor(context: vscode.ExtensionContext) { - this._context = context; - } - - public getUserData(): UserData | undefined { - return this._context.globalState.get(Storage._userDataKey); - } - - /** - * @deprecated Extension Settings will be stored in VSCode, remove on major 3.0 release. - */ - public getExtensionSettings(): ExtensionSettings | undefined { - return this._context.globalState.get(Storage._extensionSettingsKey); - } - - public getSessionState(): SessionState | undefined { - return this._context.globalState.get(Storage._sessionStateKey); - } - - public async updateUserData(userData: UserData | undefined): Promise { - await this._context.globalState.update(Storage._userDataKey, userData); - } - - public async updateExtensionSettings(settings: ExtensionSettings | undefined): Promise { - await this._context.globalState.update(Storage._extensionSettingsKey, settings); - } - - public async updateSessionState(sessionState: SessionState | undefined): Promise { - await this._context.globalState.update(Storage._sessionStateKey, sessionState); - } - - public async wipeStorage(): Promise { - await this.updateExtensionSettings(undefined); - await this.updateSessionState(undefined); - await this.updateUserData(undefined); - } -} 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/settings.ts b/src/config/settings.ts index 6622221d..0ab5f673 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -1,18 +1,9 @@ import * as vscode from "vscode"; -import Storage, { SessionState } from "../api/storage"; +import Storage from "../storage"; +import * as data from "../storage/data"; import { Logger, LogLevel } from "../utilities/logger"; - -/** - * @deprecated Default values are now implemented in package.json / VSCode settings. - */ -export interface ExtensionSettings { - downloadOldSubmission: boolean; - hideMetaFiles: boolean; - insiderVersion: boolean; - logLevel: LogLevel; - updateExercisesAutomatically: boolean; -} +import { SessionState } from "../storage/data"; /** * Class to manage VSCode setting changes and trigger events based on changes. @@ -85,13 +76,14 @@ export default class Settings implements vscode.Disposable { * @deprecated Storage dependency should be removed when major 3.0 release. */ public async updateExtensionSettingsToStorage(): Promise { - const settings: ExtensionSettings = { + const settings: data.ExtensionSettings = { downloadOldSubmission: this._getUserSettingValue("downloadOldSubmission"), hideMetaFiles: this._getUserSettingValue("hideMetaFiles"), updateExercisesAutomatically: this._getUserSettingValue("updateExercisesAutomatically"), logLevel: vscode.workspace.getConfiguration().get("testMyCode.logLevel") ?? LogLevel.Errors, insiderVersion: this._getUserSettingValue("insiderVersion"), + javaHome: this._getUserSettingString("javaHome"), }; await this._storage.updateExtensionSettings(settings); } @@ -121,6 +113,10 @@ export default class Settings implements vscode.Disposable { await this.updateExtensionSettingsToStorage(); } + public getJavaHome(): string { + return this._getWorkspaceSettingString("javaHome"); + } + /** * Used to fetch boolean values from VSCode settings API Workspace scope * @@ -150,4 +146,34 @@ export default class Settings implements vscode.Disposable { return scopeSettings.globalValue; } } + + /** + * Used to fetch string values from VSCode settings API User Scope + */ + private _getUserSettingString(section: string): string { + const configuration = vscode.workspace.getConfiguration("testMyCode"); + const scopeSettings = configuration.inspect(section); + if (scopeSettings?.globalValue === undefined) { + return scopeSettings?.defaultValue ?? ""; + } else { + return scopeSettings.globalValue; + } + } + + /** + * Used to fetch string values from VSCode settings API Workspace scope + * + * workspaceValue is undefined in multi-root workspace if it matches defaultValue + * We want to "force" the value in the multi-root workspace, because then + * the workspace scope > user scope. + */ + private _getWorkspaceSettingString(section: string): string { + const configuration = vscode.workspace.getConfiguration("testMyCode"); + const scopeSettings = configuration.inspect(section); + if (scopeSettings?.workspaceValue === undefined) { + return scopeSettings?.defaultValue ?? ""; + } else { + return scopeSettings.workspaceValue; + } + } } diff --git a/src/config/userdata.ts b/src/config/userdata.ts index e68be7de..15d595e6 100644 --- a/src/config/userdata.ts +++ b/src/config/userdata.ts @@ -1,49 +1,141 @@ import * as _ from "lodash"; import { Err, Ok, Result } from "ts-results"; -import Storage, { LocalCourseData, LocalCourseExercise } from "../api/storage"; +import Storage from "../storage"; +import { + assertUnreachable, + CourseIdentifier, + ExerciseIdentifier, + makeMoocKind, + makeTmcKind, + match, + LocalCourseData, + LocalCourseExercise, +} from "../shared/shared"; import { Logger } from "../utilities/logger"; +import { + MoocLocalCourseData, + MoocLocalCourseExercise, + TmcLocalCourseData, + TmcLocalCourseExercise, +} from "../storage/data"; 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.courses.map((x) => [x.id, x])); + this._moocCourses = new Map(persistentData.mooc_courses.map((x) => [x.id, x])); persistentData.courses.forEach((x) => x.exercises.forEach((y) => { if (y.passed) { - this._passedExercises.add(y.id); + this._passedExercises.add(ExerciseIdentifier.from(y.id)); + } + }), + ); + persistentData.mooc_courses.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(): TmcLocalCourseData[] { + return Array.from(this._tmcCourses.values()); + } + + public getMoocCourses(): MoocLocalCourseData[] { + return Array.from(this._moocCourses.values()); + } + + 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 getCourse(id: number): Readonly { - const course = this._courses.get(id); - return course as LocalCourseData; + public getCourseBySlug(slug: string): LocalCourseData { + throw "todo"; } - public getCourseByName(name: string): Readonly { - return this.getCourses().filter((x) => x.name === name)[0]; + public getTmcCourse(id: number): Readonly { + const course = this._tmcCourses.get(id); + return course as TmcLocalCourseData; + } + + 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.name === courseSlug) { + const exercise = course.exercises.find((x) => x.name === 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.name === courseSlug) { return course.exercises.find((x) => x.name === exerciseName); } @@ -51,7 +143,7 @@ export class UserData { } public async setExerciseAsPassed(courseSlug: string, exerciseName: string): Promise { - for (const course of this._courses.values()) { + for (const course of this._tmcCourses.values()) { if (course.name === courseSlug) { const exercise = course.exercises.find((x) => x.name === exerciseName); if (exercise) { @@ -64,54 +156,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.id)) { + throw new Error("Trying to add an already existing course"); + } + Logger.info(`Adding course ${course.data.name} to My Courses`); + this._moocCourses.set(course.data.id, course.data); + break; + } + default: { + assertUnreachable(data); + } + } + this._updatePersistentData(); + } + + public addMoocCourse(data: MoocLocalCourseData): void { + if (this._moocCourses.has(data.id)) { 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); + this._moocCourses.set(data.id, 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.id)) { + throw new Error("Trying to fetch course that doesn't exist."); + } + this._moocCourses.set(course.data.id, 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 +310,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 +334,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 +378,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 +403,9 @@ export class UserData { } private _updatePersistentData(): Promise { - return this._storage.updateUserData({ courses: Array.from(this._courses.values()) }); + return this._storage.updateUserData({ + courses: Array.from(this._tmcCourses.values()), + mooc_courses: Array.from(this._moocCourses.values()), + }); } } diff --git a/src/extension.ts b/src/extension.ts index ec87cdd1..bdb132ae 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,8 +6,8 @@ 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/tmc"; +import Storage from "./storage"; +import Langs from "./api/langs"; import WorkspaceManager from "./api/workspaceManager"; import { CLIENT_NAME, @@ -20,7 +20,6 @@ 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"; @@ -70,13 +69,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 +83,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 +108,11 @@ async function activateInner(context: vscode.ExtensionContext): Promise { // migrate data between versions const storage = new Storage(context); - if (tmc.ok) { - const migrationResult = await migrateExtensionDataFromPreviousVersions( + if (langs.ok) { + const migrationResult = await storage.migrateToLatest( context, - storage, dialog, - tmc.val, + langs.val, vscode.workspace.getConfiguration(), ); if (migrationResult.err) { @@ -131,8 +129,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 +169,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 +245,7 @@ async function activateInner(context: vscode.ExtensionContext): Promise { exerciseDecorationProvider, resources, settings, - tmc, + langs, ui, userData, workspaceManager, @@ -279,7 +277,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 +297,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..9ecba5f4 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.title, + { kind: "mooc", data: { instanceId: c.data.id } }, + ]; + } + } + }), + ); + 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..bdc6c5fc 100644 --- a/src/init/resources.ts +++ b/src/init/resources.ts @@ -3,7 +3,7 @@ import * as path from "path"; import { Ok, Result } from "ts-results"; import * as vscode from "vscode"; -import Storage from "../api/storage"; +import Storage from "../storage"; import { EXTENSION_ID, WORKSPACE_ROOT_FILE_TEXT, WORKSPACE_SETTINGS } from "../config/constants"; import Resources from "../config/resources"; import { Logger } from "../utilities/logger"; @@ -55,6 +55,16 @@ export async function resourceInitialization( Logger.info(`Created tmc workspace file at ${tmcWorkspaceFilePath}`); } }); + userData?.mooc_courses.forEach((course) => { + const tmcWorkspaceFilePath = path.join( + workspaceFileFolder, + course.name + ".code-workspace", + ); + if (!fs.existsSync(tmcWorkspaceFilePath)) { + fs.writeFileSync(tmcWorkspaceFilePath, JSON.stringify(WORKSPACE_SETTINGS)); + Logger.info(`Created tmc workspace file at ${tmcWorkspaceFilePath}`); + } + }); // Verify that .tmc folder and its contents exists fs.ensureDirSync(resources.workspaceRootFolder.fsPath); diff --git a/src/init/ui.ts b/src/init/ui.ts index 39d4326f..864cb748 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.title, + id: moocCourse.id, + command: { + command: "tmc.courseDetails", + title: "Go to course details", + arguments: [moocCourse.id], + }, + }; + } + 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 deleted file mode 100644 index c40e1298..00000000 --- a/src/migrate/index.ts +++ /dev/null @@ -1,108 +0,0 @@ -import * as fs from "fs-extra"; -import { concat, last } from "lodash"; -import * as path from "path"; -import { Err, Ok, Result } from "ts-results"; -import * as vscode from "vscode"; - -import Dialog from "../api/dialog"; -import Storage from "../api/storage"; -import TMC from "../api/tmc"; -import { - WORKSPACE_ROOT_FILE_NAME, - WORKSPACE_ROOT_FILE_TEXT, - WORKSPACE_ROOT_FOLDER_NAME, - WORKSPACE_SETTINGS, -} from "../config/constants"; -import { HaltForReloadError } from "../errors"; - -import migrateExerciseData from "./migrateExerciseData"; -import migrateExtensionSettings from "./migrateExtensionSettings"; -import migrateSessionState from "./migrateSessionState"; -import migrateUserData from "./migrateUserData"; - -/** - * Migrates extension data from previous versions to the current one. - * - * @param context - * @param storage Storage object used to determinate if migration is necessary. - */ -export async function migrateExtensionDataFromPreviousVersions( - context: vscode.ExtensionContext, - storage: Storage, - dialog: Dialog, - tmc: TMC, - settings: vscode.WorkspaceConfiguration, -): Promise> { - const memento = context.globalState; - - const activeOldWorkspaceName = getActiveOldWorkspaceName(context.globalState); - if (activeOldWorkspaceName) { - const workspaceFileFolder = path.join(context.globalStoragePath, "workspaces"); - createInitializationFiles(workspaceFileFolder, activeOldWorkspaceName); - await vscode.commands.executeCommand( - "vscode.openFolder", - vscode.Uri.file(path.join(workspaceFileFolder, activeOldWorkspaceName)), - ); - return Err(new HaltForReloadError("Restart to start migration.")); - } - - try { - const migratedExtensionSettings = await migrateExtensionSettings(memento, settings); - const migratedSessionState = migrateSessionState(memento); - const migratedUserData = migrateUserData(memento); - - // Workspace data migration - this one is a bit more tricky so do it last. - const migratedExerciseData = await migrateExerciseData(memento, dialog, tmc); - - await storage.updateExtensionSettings(migratedExtensionSettings.data); - await storage.updateSessionState(migratedSessionState.data); - await storage.updateUserData(migratedUserData.data); - - const keysToRemove = concat( - migratedExerciseData.obsoleteKeys, - migratedExtensionSettings.obsoleteKeys, - migratedSessionState.obsoleteKeys, - migratedUserData.obsoleteKeys, - ); - for (const key of keysToRemove) { - await memento.update(key, undefined); - } - } catch (e) { - // Typing change from update - return Err(e as Error); - } - - return Ok.EMPTY; -} - -function getActiveOldWorkspaceName(memento: vscode.Memento): string | undefined { - interface ExtensionSettingsPartial { - dataPath: string; - } - - const workspaceFile = vscode.workspace.workspaceFile; - const dataPath = memento.get("extensionSettings")?.dataPath; - - if (!workspaceFile || !dataPath) { - return undefined; - } - - return path.relative(workspaceFile.fsPath, vscode.Uri.file(dataPath).fsPath) === - path.join("..", "..") - ? last(workspaceFile?.fsPath.split(path.sep)) - : undefined; -} - -// Copypaste code from resource initialization because that code isn't accessed yet. -function createInitializationFiles(workspaceFileFolder: string, workspaceName: string): void { - fs.ensureDirSync(workspaceFileFolder); - - const workspaceFile = path.join(workspaceFileFolder, workspaceName); - fs.writeFileSync(workspaceFile, JSON.stringify(WORKSPACE_SETTINGS)); - - const rootFolder = path.join(workspaceFileFolder, WORKSPACE_ROOT_FOLDER_NAME); - fs.ensureDirSync(rootFolder); - - const rootFile = path.join(rootFolder, WORKSPACE_ROOT_FILE_NAME); - fs.writeFileSync(rootFile, WORKSPACE_ROOT_FILE_TEXT); -} diff --git a/src/migrate/migrateExtensionSettings.ts b/src/migrate/migrateExtensionSettings.ts deleted file mode 100644 index 1aec1015..00000000 --- a/src/migrate/migrateExtensionSettings.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { createIs } from "typia"; -import * as vscode from "vscode"; - -import { semVerCompare } from "../utilities"; - -import { MigratedData } from "./types"; -import validateData from "./validateData"; - -const EXTENSION_SETTINGS_KEY_V0 = "extensionSettings"; -const EXTENSION_SETTINGS_KEY_V1 = "extension-settings-v1"; -const UNSTABLE_EXTENSION_VERSION_KEY = "extensionVersion"; -const SESSION_STATE_KEY_V1 = "session-state-v1"; - -export enum LogLevelV0 { - None = "none", - Errors = "errors", - Verbose = "verbose", - Debug = "debug", -} - -export type LogLevelV1 = "none" | "errors" | "verbose"; - -export interface ExtensionSettingsV0 { - dataPath: string; - downloadOldSubmission?: boolean; - hideMetaFiles?: boolean; - insiderVersion?: boolean; - logLevel?: LogLevelV0; - oldDataPath?: { path: string; timestamp: number } | undefined; - updateExercisesAutomatically?: boolean; -} - -export interface ExtensionSettingsV1 { - downloadOldSubmission: boolean; - hideMetaFiles: boolean; - insiderVersion: boolean; - logLevel: "none" | "errors" | "verbose"; - updateExercisesAutomatically: boolean; -} - -interface SessionStatePartial { - extensionVersion: string | undefined; -} - -function logLevelV0toV1(logLevel: LogLevelV0): LogLevelV1 { - switch (logLevel) { - case LogLevelV0.Debug: - case LogLevelV0.Verbose: - return "verbose"; - case LogLevelV0.Errors: - return "errors"; - case LogLevelV0.None: - return "none"; - } -} - -async function extensionDataFromV0toV1( - unstableData: ExtensionSettingsV0, -): Promise { - const logLevel = unstableData.logLevel ? logLevelV0toV1(unstableData.logLevel) : "errors"; - - return { - downloadOldSubmission: unstableData.downloadOldSubmission ?? true, - hideMetaFiles: unstableData.hideMetaFiles ?? true, - insiderVersion: unstableData.insiderVersion ?? false, - logLevel, - updateExercisesAutomatically: unstableData.updateExercisesAutomatically ?? true, - }; -} - -async function migrateSettingsToVSCodeSettingsAPI( - memento: vscode.Memento, - storageSettings: ExtensionSettingsV1, - settings: vscode.WorkspaceConfiguration, -): Promise { - let version = memento.get(UNSTABLE_EXTENSION_VERSION_KEY); - if (!version) { - version = memento.get(SESSION_STATE_KEY_V1)?.extensionVersion; - } - const compareVersions = semVerCompare(version ?? "0.0.0", "2.1.0", "minor"); - if (!compareVersions || compareVersions < 0) { - await settings.update( - "testMyCode.downloadOldSubmission", - storageSettings.downloadOldSubmission, - true, - ); - await settings.update("testMyCode.hideMetaFiles", storageSettings.hideMetaFiles, true); - await settings.update( - "testMyCode.updateExercisesAutomatically", - storageSettings.updateExercisesAutomatically, - true, - ); - await settings.update("testMyCode.insiderVersion", storageSettings.insiderVersion, true); - await settings.update("testMyCode.logLevel", storageSettings.logLevel, true); - } -} - -export default async function migrateExtensionSettings( - memento: vscode.Memento, - settings: vscode.WorkspaceConfiguration, -): Promise> { - const obsoleteKeys: string[] = []; - const dataV0 = validateData( - memento.get(EXTENSION_SETTINGS_KEY_V0), - createIs(), - ); - if (dataV0) { - obsoleteKeys.push(EXTENSION_SETTINGS_KEY_V0); - } - - const dataV1 = dataV0 - ? await extensionDataFromV0toV1(dataV0) - : validateData(memento.get(EXTENSION_SETTINGS_KEY_V1), createIs()); - - if (dataV1) { - await migrateSettingsToVSCodeSettingsAPI(memento, dataV1, settings); - } - - return { data: dataV1, obsoleteKeys }; -} diff --git a/src/migrate/migrateSessionState.ts b/src/migrate/migrateSessionState.ts deleted file mode 100644 index 14b8f675..00000000 --- a/src/migrate/migrateSessionState.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createIs } from "typia"; -import * as vscode from "vscode"; - -import { MigratedData } from "./types"; -import validateData from "./validateData"; - -const SESSION_STATE_KEY_V1 = "session-state-v1"; -const UNSTABLE_EXTENSION_VERSION_KEY = "extensionVersion"; - -export interface SessionStateV1 { - extensionVersion?: string | undefined; -} - -/** - * Searches initial values from previous data keys. - * @param memento Memento object used to search for keys - */ -function resolveInitialData(memento: vscode.Memento): SessionStateV1 { - return { - extensionVersion: memento.get(UNSTABLE_EXTENSION_VERSION_KEY), - }; -} - -export default function migrateSessionState(memento: vscode.Memento): MigratedData { - const obsoleteKeys: string[] = []; - const dataV1 = validateData( - memento.get(SESSION_STATE_KEY_V1) ?? resolveInitialData(memento), - createIs(), - ); - - return { data: dataV1, obsoleteKeys }; -} diff --git a/src/migrate/migrateUserData.ts b/src/migrate/migrateUserData.ts deleted file mode 100644 index 36bd9672..00000000 --- a/src/migrate/migrateUserData.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { createIs } from "typia"; -import * as vscode from "vscode"; - -import { LocalCourseData } from "../api/storage"; -import { - LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, - LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER, - LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER, -} from "../config/constants"; - -import { MigratedData } from "./types"; -import validateData from "./validateData"; - -const UNSTABLE_EXERCISE_DATA_KEY = "exerciseData"; -const USER_DATA_KEY_V0 = "userData"; -const USER_DATA_KEY_V1 = "user-data-v1"; - -export interface LocalCourseDataV0 { - id: number; - name: string; - description: string; - organization: string; - availablePoints?: number; - awardedPoints?: number; - disabled?: boolean; - exercises: Array<{ - id: number; - passed: boolean; - name?: string; - deadline?: string | null; - softDeadline?: string | null; - }>; - newExercises?: number[]; - perhapsExamMode?: boolean; - title?: string; - notifyAfter?: number; - material_url?: string | null; -} - -export interface LocalCourseDataV1 { - id: number; - name: string; - title: string; - description: string; - organization: string; - exercises: Array<{ - id: number; - awardedPoints?: number; - availablePoints?: number; - name: string; - deadline: string | null; - passed: boolean; - softDeadline: string | null; - }>; - availablePoints: number; - awardedPoints: number; - perhapsExamMode: boolean; - newExercises: number[]; - notifyAfter: number; - disabled: boolean; - materialUrl: string | null; -} - -function courseDataFromV0ToV1( - unstableData: LocalCourseDataV0[], - memento: vscode.Memento, -): LocalCourseDataV1[] { - interface LocalExerciseDataPartial { - id: number; - deadline?: string | undefined; - name?: string; - softDeadline?: string | undefined; - } - - const localExerciseData = memento.get(UNSTABLE_EXERCISE_DATA_KEY); - const courseExercises = localExerciseData && new Map(localExerciseData.map((x) => [x.id, x])); - - return unstableData.map((x) => { - const exercises = x.exercises.map((e) => { - const fallback = courseExercises?.get(e.id); - return { - ...e, - deadline: e.deadline ?? fallback?.deadline ?? null, - name: e.name ?? fallback?.name ?? e.id.toString(), - softDeadline: e.softDeadline ?? fallback?.softDeadline ?? null, - }; - }); - - return { - ...x, - availablePoints: x.availablePoints ?? 0, - awardedPoints: x.awardedPoints ?? 0, - description: x.description, - disabled: x.disabled ?? false, - exercises: exercises, - materialUrl: x.material_url ?? null, - newExercises: x.newExercises ?? [], - notifyAfter: x.notifyAfter ?? 0, - organization: x.organization, - perhapsExamMode: x.perhapsExamMode ?? false, - title: x.title ?? x.name, - }; - }); -} - -export function resolveMissingFields(localCourseData: LocalCourseDataV1[]): LocalCourseData[] { - return localCourseData.map((course) => { - const exercises = course.exercises.map((x) => { - const resolvedAwardedPoints = x.passed - ? LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER - : LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER; - return { - ...x, - availablePoints: x.availablePoints ?? LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, - awardedPoints: x.awardedPoints ?? resolvedAwardedPoints, - }; - }); - return { ...course, exercises }; - }); -} - -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); - } - - const dataV1 = dataV0 - ? { courses: courseDataFromV0ToV1(dataV0.courses, memento) } - : validateData(memento.get(USER_DATA_KEY_V1), createIs<{ courses: LocalCourseDataV1[] }>()); - - const data = dataV1 ? { ...dataV1, courses: resolveMissingFields(dataV1?.courses) } : undefined; - - return { data, obsoleteKeys }; -} diff --git a/src/migrate/types.ts b/src/migrate/types.ts deleted file mode 100644 index 480b3706..00000000 --- a/src/migrate/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface MigratedData { - data: T | undefined; - obsoleteKeys: string[]; -} 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/storage/data/data_v0.ts b/src/storage/data/data_v0.ts new file mode 100644 index 00000000..d2932486 --- /dev/null +++ b/src/storage/data/data_v0.ts @@ -0,0 +1,82 @@ +import path = require("path"); + +// global storage keys +export const EXERCISE_DATA_KEY = "exerciseData"; +export const USER_DATA_KEY = "userData"; +export const EXTENSION_SETTINGS_KEY = "extensionSettings"; +export const EXTENSION_VERSION_KEY = "extensionVersion"; + +// data path +export function exercisesDataPath(dataPath: string): string { + return path.join(dataPath, "TMC workspace", "Exercises"); +} + +export function closedExerciseDataPath(dataPath: string, id: string): string { + return path.join(dataPath, "TMC workspace", "closed-exercises", id.toString()); +} + +// data types +export interface LocalCourseDataExercise { + id: number; + passed: boolean; + name?: string; + deadline?: string | null; + softDeadline?: string | null; +} + +export interface LocalCourseData { + id: number; + name: string; + description: string; + organization: string; + availablePoints?: number; + awardedPoints?: number; + disabled?: boolean; + exercises: Array; + newExercises?: Array; + perhapsExamMode?: boolean; + title?: string; + notifyAfter?: number; + material_url?: string | null; +} + +export enum ExerciseStatus { + OPEN = 0, + CLOSED = 1, + MISSING = 2, +} + +export interface LocalExerciseData { + id: number; + checksum: string; + name: string; + course: string; + deadline?: string | null; + isOpen?: boolean; + organization: string; + path?: string; + softDeadline?: string | null; + status?: ExerciseStatus; + updateAvailable?: boolean; +} + +export enum LogLevel { + None = "none", + Errors = "errors", + Verbose = "verbose", + Debug = "debug", +} + +export interface ExtensionSettings { + dataPath: string; + downloadOldSubmission?: boolean; + hideMetaFiles?: boolean; + insiderVersion?: boolean; + logLevel?: LogLevel; + oldDataPath?: { path: string; timestamp: number } | undefined; + updateExercisesAutomatically?: boolean; +} + +export interface UserData { + courses: Array; +} diff --git a/src/storage/data/data_v1.ts b/src/storage/data/data_v1.ts new file mode 100644 index 00000000..fdb2bfd8 --- /dev/null +++ b/src/storage/data/data_v1.ts @@ -0,0 +1,55 @@ +// global storage keys +export const USER_DATA_KEY = "user-data-v1"; +export const EXTENSION_SETTINGS_KEY = "extension-settings-v1"; +export const SESSION_STATE_KEY = "session-state-v1"; + +// data types +export interface LocalCourseDataExercise { + id: number; + awardedPoints?: number; + availablePoints?: number; + name: string; + deadline: string | null; + passed: boolean; + softDeadline: string | null; +} + +export interface LocalCourseData { + id: number; + name: string; + title: string; + description: string; + organization: string; + exercises: Array; + availablePoints: number; + awardedPoints: number; + perhapsExamMode: boolean; + newExercises: Array; + notifyAfter: number; + disabled: boolean; + materialUrl: string | null; +} + +export enum ExerciseStatus { + OPEN = "open", + CLOSED = "closed", + MISSING = "missing", +} + +export interface ExtensionSettings { + downloadOldSubmission: boolean; + hideMetaFiles: boolean; + insiderVersion: boolean; + logLevel: LogLevel; + updateExercisesAutomatically: boolean; +} + +export type LogLevel = "none" | "errors" | "verbose"; + +export interface SessionState { + extensionVersion?: string | undefined; +} + +export interface UserData { + courses: Array; +} diff --git a/src/storage/data/data_v2.ts b/src/storage/data/data_v2.ts new file mode 100644 index 00000000..fa8e8f08 --- /dev/null +++ b/src/storage/data/data_v2.ts @@ -0,0 +1,55 @@ +import * as v1 from "./data_v1"; + +// global storage keys +export import USER_DATA_KEY = v1.USER_DATA_KEY; +export import EXTENSION_SETTINGS_KEY = v1.EXTENSION_SETTINGS_KEY; +export import SESSION_STATE_KEY = v1.SESSION_STATE_KEY; + +// extension settings keys +export const TMC_DOWNLOAD_OLD_SUBMISSION_KEY = "testMyCode.downloadOldSubmission"; +export const TMC_HIDE_META_FILES_KEY = "testMyCode.hideMetaFiles"; +export const TMC_UPDATE_EXERCISES_AUTOMATICALLY_KEY = "testMyCode.updateExercisesAutomatically"; +export const TMC_INSIDER_VERSION_KEY = "testMyCode.insiderVersion"; +export const TMC_LOG_LEVEL_KEY = "testMyCode.logLevel"; + +// langs settings +export function langsClosedExercisesKey(exerciseId: string): string { + return `closed-exercises-for:${exerciseId}`; +} + +// data types +export import LogLevel = v1.LogLevel; +export import SessionState = v1.SessionState; +export import ExtensionSettings = v1.ExtensionSettings; +export import ExerciseStatus = v1.ExerciseStatus; + +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 LocalCourseData { + id: number; + name: string; + title: string; + description: string; + organization: string; + exercises: Array; + availablePoints: number; + awardedPoints: number; + perhapsExamMode: boolean; + newExercises: Array; + notifyAfter: number; + disabled: boolean; + materialUrl: string | null; +} + +export interface UserData { + courses: Array; +} diff --git a/src/storage/data/data_v3.ts b/src/storage/data/data_v3.ts new file mode 100644 index 00000000..5996176d --- /dev/null +++ b/src/storage/data/data_v3.ts @@ -0,0 +1,60 @@ +import * as v2 from "./data_v2"; + +// global storage keys +export const USER_DATA_KEY = "user-data-v3"; +export const EXTENSION_SETTINGS_KEY = "extension-settings-v3"; +export import SESSION_STATE_KEY = v2.SESSION_STATE_KEY; + +export import LogLevel = v2.LogLevel; +export import SessionState = v2.SessionState; +export import ExerciseStatus = v2.ExerciseStatus; +export import TmcLocalCourseExercise = v2.LocalCourseExercise; +export import TmcLocalCourseData = v2.LocalCourseData; + +// data types +export interface MoocLocalCourseExercise { + id: string; + availablePoints: number; + awardedPoints: number; + /// Equivalent to exercise slug + name: string; + deadline: string | null; + passed: boolean; + softDeadline: string | null; +} + +export interface MoocLocalCourseData { + // instance id + id: string; + courseId: string; + // course slug + name: string; + instanceName: string | null; + title: string; + description: string | null; + courseDescription: string | null; + organization: string; + exercises: Array; + availablePoints: number; + awardedPoints: number; + perhapsExamMode: boolean; + newExercises: Array; + notifyAfter: number; + disabled: boolean; + materialUrl: string | null; +} + +export interface UserData { + // tmc_courses + courses: Array; + mooc_courses: Array; +} + +export interface ExtensionSettings { + downloadOldSubmission: boolean; + hideMetaFiles: boolean; + insiderVersion: boolean; + logLevel: LogLevel; + updateExercisesAutomatically: boolean; + javaHome: string; +} diff --git a/src/storage/data/index.ts b/src/storage/data/index.ts new file mode 100644 index 00000000..bbfed749 --- /dev/null +++ b/src/storage/data/index.ts @@ -0,0 +1,12 @@ +// All types that are stored in VSCode's storage should be defined under here +// to ensure they're versioned correctly. +import * as v0 from "./data_v0"; +import * as v1 from "./data_v1"; +import * as v2 from "./data_v2"; +import * as v3 from "./data_v3"; + +// export all versions +export { v0, v1, v2, v3 }; + +// export everything from latest version +export * from "./data_v3"; diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 00000000..46da103f --- /dev/null +++ b/src/storage/index.ts @@ -0,0 +1,157 @@ +// All access to VSCode's storage should be done through this module. + +import Dialog from "../api/dialog"; +import Langs from "../api/langs"; +import { + WORKSPACE_ROOT_FILE_NAME, + WORKSPACE_ROOT_FILE_TEXT, + WORKSPACE_ROOT_FOLDER_NAME, + WORKSPACE_SETTINGS, +} from "../config/constants"; +import { HaltForReloadError } from "../errors"; +import * as storage from "./data"; +import { v0 } from "./data"; +import migrateExerciseDataToLatest from "./migration/exerciseData"; +import migrateExtensionSettingsToLatest from "./migration/extensionSettings"; +import migrateSessionState from "./migration/sessionState"; +import migrateUserDataToLatest from "./migration/userData"; +import * as fs from "fs-extra"; +import { concat, last } from "lodash"; +import * as path from "path"; +import { Err, Ok, Result } from "ts-results"; +import * as vscode from "vscode"; + +/** + * Interface class for accessing stored TMC configuration and data. + */ +export default class Storage { + private _context: vscode.ExtensionContext; + + /** + * Creates new instance of the TMC storage access object. + * @param context context of the extension where all data is stored + */ + constructor(context: vscode.ExtensionContext) { + this._context = context; + } + + public getUserData(): storage.UserData | undefined { + return this._context.globalState.get(storage.USER_DATA_KEY); + } + + /** + * @deprecated Extension Settings will be stored in VSCode, remove on major 3.0 release. + */ + public getExtensionSettings(): storage.ExtensionSettings | undefined { + return this._context.globalState.get( + storage.EXTENSION_SETTINGS_KEY, + ); + } + + public getSessionState(): storage.SessionState | undefined { + return this._context.globalState.get(storage.SESSION_STATE_KEY); + } + + public async updateUserData(userData: storage.UserData | undefined): Promise { + await this._context.globalState.update(storage.USER_DATA_KEY, userData); + } + + public async updateExtensionSettings( + settings: storage.ExtensionSettings | undefined, + ): Promise { + await this._context.globalState.update(storage.EXTENSION_SETTINGS_KEY, settings); + } + + public async updateSessionState(sessionState: storage.SessionState | undefined): Promise { + await this._context.globalState.update(storage.SESSION_STATE_KEY, sessionState); + } + + public async wipeStorage(): Promise { + await this.updateExtensionSettings(undefined); + await this.updateSessionState(undefined); + await this.updateUserData(undefined); + } + + public async migrateToLatest( + context: vscode.ExtensionContext, + dialog: Dialog, + tmc: Langs, + settings: vscode.WorkspaceConfiguration, + ): Promise> { + const memento = context.globalState; + + const activeOldWorkspaceName = getActiveOldWorkspaceName(context.globalState); + if (activeOldWorkspaceName) { + const workspaceFileFolder = path.join(context.globalStoragePath, "workspaces"); + createInitializationFiles(workspaceFileFolder, activeOldWorkspaceName); + await vscode.commands.executeCommand( + "vscode.openFolder", + vscode.Uri.file(path.join(workspaceFileFolder, activeOldWorkspaceName)), + ); + return Err(new HaltForReloadError("Restart to start migration.")); + } + + try { + const migratedExtensionSettings = await migrateExtensionSettingsToLatest( + memento, + settings, + ); + const migratedSessionState = migrateSessionState(memento); + const migratedUserData = migrateUserDataToLatest(memento); + + // Workspace data migration - this one is a bit more tricky so do it last. + const migratedExerciseData = await migrateExerciseDataToLatest(memento, dialog, tmc); + + await this.updateExtensionSettings(migratedExtensionSettings.data); + await this.updateSessionState(migratedSessionState.data); + await this.updateUserData(migratedUserData.data); + + const keysToRemove = concat( + migratedExerciseData.obsoleteKeys, + migratedExtensionSettings.obsoleteKeys, + migratedSessionState.obsoleteKeys, + migratedUserData.obsoleteKeys, + ); + for (const key of keysToRemove) { + await memento.update(key, undefined); + } + } catch (e) { + // Typing change from update + return Err(e as Error); + } + + return Ok.EMPTY; + } +} + +function getActiveOldWorkspaceName(memento: vscode.Memento): string | undefined { + interface ExtensionSettingsPartial { + dataPath: string; + } + + const workspaceFile = vscode.workspace.workspaceFile; + const dataPath = memento.get(v0.EXTENSION_SETTINGS_KEY)?.dataPath; + + if (!workspaceFile || !dataPath) { + return undefined; + } + + return path.relative(workspaceFile.fsPath, vscode.Uri.file(dataPath).fsPath) === + path.join("..", "..") + ? last(workspaceFile?.fsPath.split(path.sep)) + : undefined; +} + +// Copypaste code from resource initialization because that code isn't accessed yet. +function createInitializationFiles(workspaceFileFolder: string, workspaceName: string): void { + fs.ensureDirSync(workspaceFileFolder); + + const workspaceFile = path.join(workspaceFileFolder, workspaceName); + fs.writeFileSync(workspaceFile, JSON.stringify(WORKSPACE_SETTINGS)); + + const rootFolder = path.join(workspaceFileFolder, WORKSPACE_ROOT_FOLDER_NAME); + fs.ensureDirSync(rootFolder); + + const rootFile = path.join(rootFolder, WORKSPACE_ROOT_FILE_NAME); + fs.writeFileSync(rootFile, WORKSPACE_ROOT_FILE_TEXT); +} diff --git a/src/migrate/migrateExerciseData.ts b/src/storage/migration/exerciseData.ts similarity index 62% rename from src/migrate/migrateExerciseData.ts rename to src/storage/migration/exerciseData.ts index f2d5ee6b..f02d4a57 100644 --- a/src/migrate/migrateExerciseData.ts +++ b/src/storage/migration/exerciseData.ts @@ -1,47 +1,19 @@ +import validateData, { MigratedData } from "."; +import Dialog from "../../api/dialog"; +import Langs from "../../api/langs"; +import { Logger } from "../../utilities"; +import * as data from "../data"; import * as fs from "fs-extra"; import * as path from "path"; import { Err, Ok, Result } from "ts-results"; import { createIs } from "typia"; import * as vscode from "vscode"; -import Dialog from "../api/dialog"; -import TMC from "../api/tmc"; -import { Logger } from "../utilities"; - -import { MigratedData } from "./types"; -import validateData from "./validateData"; - -const EXERCISE_DATA_KEY_V0 = "exerciseData"; -const UNSTABLE_EXTENSION_SETTINGS_KEY = "extensionSettings"; - -export enum ExerciseStatusV0 { - OPEN = 0, - CLOSED = 1, - MISSING = 2, -} - -export enum ExerciseStatusV1 { - OPEN = "open", - CLOSED = "closed", - MISSING = "missing", -} - -export interface LocalExerciseDataV0 { - id: number; - checksum: string; - name: string; - course: string; - deadline?: string | null; - isOpen?: boolean; - organization: string; - path?: string; - softDeadline?: string | null; - status?: ExerciseStatusV0; - updateAvailable?: boolean; -} - -function exerciseIsClosedV0(exerciseStatus?: ExerciseStatusV0, isOpen?: boolean): boolean { - if (exerciseStatus === ExerciseStatusV0.CLOSED) { +export function v0_exerciseIsClosed( + exerciseStatus?: data.v0.ExerciseStatus, + isOpen?: boolean, +): boolean { + if (exerciseStatus === data.v0.ExerciseStatus.CLOSED) { return true; } else if (isOpen === false) { return true; @@ -50,7 +22,7 @@ function exerciseIsClosedV0(exerciseStatus?: ExerciseStatusV0, isOpen?: boolean) return false; } -function resolveExercisePathV0( +export function v0_resolveExercisePath( id: number, name: string, course: string, @@ -58,11 +30,11 @@ function resolveExercisePathV0( exercisePath?: string, dataPath?: string, ): Result { - const workspacePath = dataPath && path.join(dataPath, "TMC workspace", "Exercises"); + const workspacePath = dataPath && data.v0.exercisesDataPath(dataPath); const candidates = [ exercisePath, workspacePath && path.join(workspacePath, organization, course, name), - dataPath && path.join(dataPath, "TMC workspace", "closed-exercises", id.toString()), + dataPath && data.v0.closedExerciseDataPath(dataPath, id.toString()), ]; for (const candidate of candidates) { if (candidate && fs.existsSync(candidate)) { @@ -81,25 +53,26 @@ function resolveExercisePathV0( ); } -async function exerciseDataFromV0toV1( - exerciseData: LocalExerciseDataV0[], +// from v1, the directory for the exercises is managed by langs +export async function v1_migrateFromV0( + exerciseData: data.v0.LocalExerciseData[], memento: vscode.Memento, dialog: Dialog, - tmc: TMC, + langs: Langs, ): Promise { interface ExtensionSettingsPartial { dataPath: string; } const dataPath = memento.get( - UNSTABLE_EXTENSION_SETTINGS_KEY, + data.v0.EXTENSION_SETTINGS_KEY, )?.dataPath; const closedExercises: { [key: string]: string[] } = {}; - const exercisesToMigrate: Array<[LocalExerciseDataV0, string]> = []; + const exercisesToMigrate: Array<[data.v0.LocalExerciseData, string]> = []; for (const exercise of exerciseData) { const { id, course, isOpen, name, path, organization, status } = exercise; - if (exerciseIsClosedV0(status, isOpen)) { + if (v0_exerciseIsClosed(status, isOpen)) { if (closedExercises[course]) { closedExercises[course].push(name); } else { @@ -107,7 +80,7 @@ async function exerciseDataFromV0toV1( } } - const pathResult = resolveExercisePathV0(id, name, course, organization, path, dataPath); + const pathResult = v0_resolveExercisePath(id, name, course, organization, path, dataPath); if (pathResult.err) { Logger.error(`Have to discard exercise ${course}/${name}:`, pathResult.val); continue; @@ -127,7 +100,7 @@ async function exerciseDataFromV0toV1( let index = 0; for (const [exercise, path] of exercisesToMigrate) { const { checksum, course, id, name } = exercise; - const migrationResult = await tmc.migrateExercise(course, checksum, id, path, name); + const migrationResult = await langs.migrateExercise(course, checksum, id, path, name); if (migrationResult.ok) { atLeastOneSuccess = true; } else { @@ -151,8 +124,8 @@ async function exerciseDataFromV0toV1( } for (const key of Object.keys(closedExercises)) { - const closeExercisesResult = await tmc.setSetting( - `closed-exercises-for:${key}`, + const closeExercisesResult = await langs.setSetting( + data.v2.langsClosedExercisesKey(key), closedExercises[key], ); if (closeExercisesResult.err) { @@ -161,21 +134,27 @@ async function exerciseDataFromV0toV1( } } -export default async function migrateExerciseData( +export default async function migrateExerciseDataToLatest( memento: vscode.Memento, dialog: Dialog, - tmc: TMC, + tmc: Langs, ): Promise> { const obsoleteKeys: string[] = []; + // v0 => v1 const dataV0 = validateData( - memento.get(EXERCISE_DATA_KEY_V0), - createIs(), + memento.get(data.v0.EXERCISE_DATA_KEY), + createIs(), ); if (dataV0) { - await exerciseDataFromV0toV1(dataV0, memento, dialog, tmc); - obsoleteKeys.push(EXERCISE_DATA_KEY_V0); + await v1_migrateFromV0(dataV0, memento, dialog, tmc); + obsoleteKeys.push(data.v0.EXERCISE_DATA_KEY); } + // to support the mooc backend, langs stores new courses in distinct tmc and mooc dirs + // but it also supports the old way of storing courses so there's no need to do anything here + // though we can still do the migration later if we want to just for consistency + // await v3_migrateFromV1(); + return { data: undefined, obsoleteKeys }; } diff --git a/src/storage/migration/extensionSettings.ts b/src/storage/migration/extensionSettings.ts new file mode 100644 index 00000000..2418cc85 --- /dev/null +++ b/src/storage/migration/extensionSettings.ts @@ -0,0 +1,111 @@ +import validateData, { MigratedData } from "."; +import { semVerCompare } from "../../utilities"; +import * as data from "../data"; +import { createIs } from "typia"; +import * as vscode from "vscode"; + +function v1_logLevelFromV0(logLevel: data.v0.LogLevel): data.v1.LogLevel { + switch (logLevel) { + case data.v0.LogLevel.Debug: + case data.v0.LogLevel.Verbose: + return "verbose"; + case data.v0.LogLevel.Errors: + return "errors"; + case data.v0.LogLevel.None: + return "none"; + } +} + +export async function v1_migrateFromV0( + unstableData: data.v0.ExtensionSettings, +): Promise { + const logLevel = unstableData.logLevel ? v1_logLevelFromV0(unstableData.logLevel) : "errors"; + + return { + downloadOldSubmission: unstableData.downloadOldSubmission ?? true, + hideMetaFiles: unstableData.hideMetaFiles ?? true, + insiderVersion: unstableData.insiderVersion ?? false, + logLevel, + updateExercisesAutomatically: unstableData.updateExercisesAutomatically ?? true, + }; +} + +interface V1SessionStatePartial { + extensionVersion: string | undefined; +} + +// extension settings are no longer managed manually +export async function vscodeapi_migrateFromV1( + memento: vscode.Memento, + storageSettings: data.v1.ExtensionSettings, + settings: vscode.WorkspaceConfiguration, +): Promise { + let version = memento.get(data.v0.EXTENSION_VERSION_KEY); + if (!version) { + version = memento.get(data.v1.SESSION_STATE_KEY)?.extensionVersion; + } + const compareVersions = semVerCompare(version ?? "0.0.0", "2.1.0", "minor"); + if (!compareVersions || compareVersions < 0) { + await settings.update( + data.v2.TMC_DOWNLOAD_OLD_SUBMISSION_KEY, + storageSettings.downloadOldSubmission, + true, + ); + await settings.update(data.v2.TMC_HIDE_META_FILES_KEY, storageSettings.hideMetaFiles, true); + await settings.update( + data.v2.TMC_UPDATE_EXERCISES_AUTOMATICALLY_KEY, + storageSettings.updateExercisesAutomatically, + true, + ); + await settings.update( + data.v2.TMC_INSIDER_VERSION_KEY, + storageSettings.insiderVersion, + true, + ); + await settings.update(data.v2.TMC_LOG_LEVEL_KEY, storageSettings.logLevel, true); + + return { + downloadOldSubmission: storageSettings.downloadOldSubmission, + hideMetaFiles: storageSettings.hideMetaFiles, + updateExercisesAutomatically: storageSettings.updateExercisesAutomatically, + insiderVersion: storageSettings.insiderVersion, + logLevel: storageSettings.logLevel, + }; + } +} + +export interface VscodeApiSettings { + downloadOldSubmission: boolean; + updateExercisesAutomatically: boolean; + insiderVersion: boolean; + logLevel: data.v1.LogLevel; + hideMetaFiles: boolean; +} + +export default async function migrateExtensionSettingsToLatest( + memento: vscode.Memento, + settings: vscode.WorkspaceConfiguration, +): Promise> { + const obsoleteKeys: string[] = []; + const dataV0 = validateData( + memento.get(data.v0.EXTENSION_SETTINGS_KEY), + createIs(), + ); + if (dataV0) { + obsoleteKeys.push(data.v0.EXTENSION_SETTINGS_KEY); + } + + const dataV1 = dataV0 + ? await v1_migrateFromV0(dataV0) + : validateData( + memento.get(data.v1.EXTENSION_SETTINGS_KEY), + createIs(), + ); + + let vscodeApiSettings; + if (dataV1) { + vscodeApiSettings = await vscodeapi_migrateFromV1(memento, dataV1, settings); + } + + return { data: vscodeApiSettings, obsoleteKeys }; +} diff --git a/src/migrate/validateData.ts b/src/storage/migration/index.ts similarity index 76% rename from src/migrate/validateData.ts rename to src/storage/migration/index.ts index 5ee6557e..50f008ea 100644 --- a/src/migrate/validateData.ts +++ b/src/storage/migration/index.ts @@ -1,3 +1,8 @@ +export interface MigratedData { + data: T | undefined; + obsoleteKeys: string[]; +} + export default function validateData( data: unknown, validator: (object: unknown) => object is T, diff --git a/src/storage/migration/sessionState.ts b/src/storage/migration/sessionState.ts new file mode 100644 index 00000000..6bbc6651 --- /dev/null +++ b/src/storage/migration/sessionState.ts @@ -0,0 +1,31 @@ +import validateData, { MigratedData } from "."; +import * as data from "../data"; +import { createIs } from "typia"; +import * as vscode from "vscode"; + +export function v0_getVersion(memento: vscode.Memento): string | undefined { + return validateData(memento.get(data.v0.EXTENSION_VERSION_KEY), createIs()); +} + +export function v1_migrateFromV0(version: string | undefined): data.v1.SessionState { + return { + extensionVersion: version, + }; +} + +export default function migrateSessionState( + memento: vscode.Memento, +): MigratedData { + const obsoleteKeys: string[] = []; + + let dataV1 = validateData( + memento.get(data.v1.SESSION_STATE_KEY), + createIs(), + ); + if (!dataV1) { + const oldVersionData = v0_getVersion(memento); + dataV1 = v1_migrateFromV0(oldVersionData); + } + + return { data: dataV1, obsoleteKeys }; +} diff --git a/src/storage/migration/userData.ts b/src/storage/migration/userData.ts new file mode 100644 index 00000000..f09732ce --- /dev/null +++ b/src/storage/migration/userData.ts @@ -0,0 +1,103 @@ +import validateData, { MigratedData } from "."; +import { + LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, + LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER, + LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER, +} from "../../config/constants"; +import * as data from "../data"; +import { createIs } from "typia"; +import * as vscode from "vscode"; + +export function v1_migrateFromV0( + unstableData: data.v0.LocalCourseData[], + memento: vscode.Memento, +): data.v1.LocalCourseData[] { + interface LocalExerciseDataPartial { + id: number; + deadline?: string | undefined; + name?: string; + softDeadline?: string | undefined; + } + + const localExerciseData = memento.get(data.v0.EXERCISE_DATA_KEY); + const courseExercises = localExerciseData && new Map(localExerciseData.map((x) => [x.id, x])); + + return unstableData.map((x) => { + const exercises = x.exercises.map((e) => { + const fallback = courseExercises?.get(e.id); + return { + ...e, + deadline: e.deadline ?? fallback?.deadline ?? null, + name: e.name ?? fallback?.name ?? e.id.toString(), + softDeadline: e.softDeadline ?? fallback?.softDeadline ?? null, + }; + }); + + return { + ...x, + availablePoints: x.availablePoints ?? 0, + awardedPoints: x.awardedPoints ?? 0, + description: x.description, + disabled: x.disabled ?? false, + exercises: exercises, + materialUrl: x.material_url ?? null, + newExercises: x.newExercises ?? [], + notifyAfter: x.notifyAfter ?? 0, + organization: x.organization, + perhapsExamMode: x.perhapsExamMode ?? false, + title: x.title ?? x.name, + }; + }); +} + +export function v1_resolveMissingFields( + localCourseData: data.v1.LocalCourseData[], +): data.v2.LocalCourseData[] { + return localCourseData.map((course) => { + const exercises = course.exercises.map((x) => { + const resolvedAwardedPoints = x.passed + ? LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER + : LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER; + return { + ...x, + availablePoints: x.availablePoints ?? LOCAL_EXERCISE_AVAILABLE_POINTS_PLACEHOLDER, + awardedPoints: x.awardedPoints ?? resolvedAwardedPoints, + }; + }); + return { ...course, exercises }; + }); +} + +export function v3_migrateFromV2(data: data.v2.UserData): data.v3.UserData { + return { + courses: data.courses, + mooc_courses: [], + }; +} + +export default function migrateUserDataToLatest( + memento: vscode.Memento, +): MigratedData { + const obsoleteKeys: string[] = []; + + // v0 => v1 + const dataV0 = validateData(memento.get(data.v0.USER_DATA_KEY), createIs()); + if (dataV0) { + obsoleteKeys.push(data.v0.USER_DATA_KEY); + } + const dataV1 = dataV0 + ? { courses: v1_migrateFromV0(dataV0.courses, memento) } + : validateData(memento.get(data.v1.USER_DATA_KEY), createIs()); + + // v1 => v2 + const dataV2 = dataV1 + ? { ...dataV1, courses: v1_resolveMissingFields(dataV1?.courses) } + : validateData(memento.get(data.v2.USER_DATA_KEY), createIs()); + + // v2 => v3 + const dataV3 = dataV2 + ? v3_migrateFromV2(dataV2) + : validateData(memento.get(data.v3.USER_DATA_KEY), createIs()); + + return { data: dataV3, obsoleteKeys }; +} diff --git a/src/test-integration/tmc_langs_cli.spec.ts b/src/test-integration/tmc_langs_cli.spec.ts index d546894e..6fb9b609 100644 --- a/src/test-integration/tmc_langs_cli.spec.ts +++ b/src/test-integration/tmc_langs_cli.spec.ts @@ -4,14 +4,15 @@ import { deleteSync } from "del"; import * as fs from "fs-extra"; import { first } from "lodash"; import * as path from "path"; -import * as kill from "tree-kill"; +import 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,53 +242,57 @@ 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); }); test("should be able to submit the exercise for evaluation", async function () { let url: string | undefined; const results = await unwrapResult( - tmc.submitExerciseAndWaitForResults( - 1, + tmc.submitTmcExerciseAndWaitForResults( + 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.submitTmcExerciseAndWaitForResults( + ExerciseIdentifier.from(1), + exercisePath, + ); + const second = tmc.submitTmcExerciseAndWaitForResults( + 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.submitTmcExerciseAndWaitForResults( + 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.submitTmcExerciseAndWaitForResults( + 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/api/storage.test.ts b/src/test/api/storage.test.ts index 6ec878ba..66ba5d46 100644 --- a/src/test/api/storage.test.ts +++ b/src/test/api/storage.test.ts @@ -1,11 +1,12 @@ import { expect } from "chai"; -import Storage, { ExtensionSettings, SessionState } from "../../api/storage"; -import { v2_1_0 as userData } from "../fixtures/userData"; +import Storage from "../../storage"; +import { v3 } from "../../storage/data"; +import { v3_0_0 as userData } from "../fixtures/userData"; import { createMockContext } from "../mocks/vscode"; suite("Storage class", function () { - const extensionSettings: ExtensionSettings = { + const extensionSettings: v3.ExtensionSettings = { downloadOldSubmission: true, hideMetaFiles: true, insiderVersion: false, @@ -13,7 +14,7 @@ suite("Storage class", function () { updateExercisesAutomatically: true, }; - const sessionState: SessionState = { + const sessionState: v3.SessionState = { extensionVersion: "2.0.0", }; 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/exerciseData.ts b/src/test/fixtures/exerciseData.ts index e0dc831b..439b55c7 100644 --- a/src/test/fixtures/exerciseData.ts +++ b/src/test/fixtures/exerciseData.ts @@ -1,6 +1,6 @@ -import { ExerciseStatusV0, LocalExerciseDataV0 } from "../../migrate/migrateExerciseData"; +import { v0 } from "../../storage/data"; -const v0_1_0 = (root: string): LocalExerciseDataV0[] => { +const v0_1_0 = (root: string): v0.LocalExerciseData[] => { return [ { id: 1, @@ -25,7 +25,7 @@ const v0_1_0 = (root: string): LocalExerciseDataV0[] => { ]; }; -const v0_2_0 = (root: string): LocalExerciseDataV0[] => [ +const v0_2_0 = (root: string): v0.LocalExerciseData[] => [ { id: 1, checksum: "abc123", @@ -35,7 +35,7 @@ const v0_2_0 = (root: string): LocalExerciseDataV0[] => [ organization: "test", path: root + "/TMC workspace/Exercises/test/test-python-course/hello_world", softDeadline: "20201212", - status: ExerciseStatusV0.OPEN, + status: v0.ExerciseStatus.OPEN, updateAvailable: true, }, { @@ -47,12 +47,12 @@ const v0_2_0 = (root: string): LocalExerciseDataV0[] => [ organization: "test", path: root + "/TMC workspace/closed-exercises/2", softDeadline: "20201212", - status: ExerciseStatusV0.CLOSED, + status: v0.ExerciseStatus.CLOSED, updateAvailable: false, }, ]; -const v0_3_0: LocalExerciseDataV0[] = [ +const v0_3_0: v0.LocalExerciseData[] = [ { id: 1, checksum: "abc123", @@ -61,7 +61,7 @@ const v0_3_0: LocalExerciseDataV0[] = [ name: "hello_world", organization: "test", softDeadline: "20201212", - status: ExerciseStatusV0.OPEN, + status: v0.ExerciseStatus.OPEN, updateAvailable: true, }, { @@ -72,12 +72,12 @@ const v0_3_0: LocalExerciseDataV0[] = [ name: "other_world", organization: "test", softDeadline: "20201212", - status: ExerciseStatusV0.CLOSED, + status: v0.ExerciseStatus.CLOSED, updateAvailable: false, }, ]; -const v0_9_0: LocalExerciseDataV0[] = [ +const v0_9_0: v0.LocalExerciseData[] = [ { id: 1, checksum: "abc123", @@ -86,7 +86,7 @@ const v0_9_0: LocalExerciseDataV0[] = [ name: "hello_world", organization: "test", softDeadline: "20201212", - status: ExerciseStatusV0.OPEN, + status: v0.ExerciseStatus.OPEN, }, { id: 2, @@ -96,7 +96,7 @@ const v0_9_0: LocalExerciseDataV0[] = [ name: "other_world", organization: "test", softDeadline: "20201212", - status: ExerciseStatusV0.CLOSED, + status: v0.ExerciseStatus.CLOSED, }, ]; diff --git a/src/test/fixtures/extensionSettings.ts b/src/test/fixtures/extensionSettings.ts index 2e124037..d7a5069f 100644 --- a/src/test/fixtures/extensionSettings.ts +++ b/src/test/fixtures/extensionSettings.ts @@ -1,55 +1,51 @@ -import { - ExtensionSettingsV0, - ExtensionSettingsV1, - LogLevelV0, -} from "../../migrate/migrateExtensionSettings"; +import { v0, v1 } from "../../storage/data"; -const v0_3_0 = (root: string): ExtensionSettingsV0 => { +const v0_3_0 = (root: string): v0.ExtensionSettings => { return { dataPath: root }; }; -const v0_5_0 = (root: string): ExtensionSettingsV0 => { +const v0_5_0 = (root: string): v0.ExtensionSettings => { return { dataPath: root, - logLevel: LogLevelV0.Verbose, + logLevel: v0.LogLevel.Verbose, hideMetaFiles: true, }; }; -const v0_9_0 = (root: string): ExtensionSettingsV0 => { +const v0_9_0 = (root: string): v0.ExtensionSettings => { return { dataPath: root, hideMetaFiles: true, insiderVersion: true, - logLevel: LogLevelV0.Verbose, + logLevel: v0.LogLevel.Verbose, oldDataPath: { path: "/old/path/to/exercises", timestamp: 1234 }, }; }; -const v1_0_0 = (root: string): ExtensionSettingsV0 => { +const v1_0_0 = (root: string): v0.ExtensionSettings => { return { dataPath: root, downloadOldSubmission: false, hideMetaFiles: true, insiderVersion: true, - logLevel: LogLevelV0.Verbose, + logLevel: v0.LogLevel.Verbose, oldDataPath: { path: "/old/path/to/exercises", timestamp: 1234 }, }; }; -const v1_2_0 = (root: string): ExtensionSettingsV0 => { +const v1_2_0 = (root: string): v0.ExtensionSettings => { return { dataPath: root, downloadOldSubmission: false, hideMetaFiles: true, insiderVersion: true, - logLevel: LogLevelV0.Verbose, + logLevel: v0.LogLevel.Verbose, oldDataPath: { path: "/old/path/to/exercises", timestamp: 1234 }, updateExercisesAutomatically: false, }; }; -const v2_0_0: ExtensionSettingsV1 = { +const v2_0_0: v1.ExtensionSettings = { downloadOldSubmission: false, hideMetaFiles: true, insiderVersion: true, diff --git a/src/test/fixtures/sessionState.ts b/src/test/fixtures/sessionState.ts index ab22cbbd..792e7095 100644 --- a/src/test/fixtures/sessionState.ts +++ b/src/test/fixtures/sessionState.ts @@ -1,6 +1,6 @@ -import { SessionStateV1 } from "../../migrate/migrateSessionState"; +import { v1 } from "../../storage/data"; -const v2_0_0: SessionStateV1 = { +const v2_0_0: v1.SessionState = { extensionVersion: "2.0.0", }; diff --git a/src/test/fixtures/userData.ts b/src/test/fixtures/userData.ts index cc16b56c..91970480 100644 --- a/src/test/fixtures/userData.ts +++ b/src/test/fixtures/userData.ts @@ -1,7 +1,6 @@ -import { LocalCourseExercise, UserData } from "../../api/storage"; -import { LocalCourseDataV0, LocalCourseDataV1 } from "../../migrate/migrateUserData"; +import { TmcLocalCourseExercise, v0, v1, v2, v3 } from "../../storage/data"; -export const userDataExerciseHelloWorld: LocalCourseExercise = { +export const userDataExerciseHelloWorld: TmcLocalCourseExercise = { id: 1, availablePoints: 1, awardedPoints: 0, @@ -15,15 +14,7 @@ export const userDataExerciseHelloWorld: LocalCourseExercise = { // Previous version snapshots // ------------------------------------------------------------------------------------------------- -interface UserDataV0 { - courses: LocalCourseDataV0[]; -} - -interface UserDataV1 { - courses: LocalCourseDataV1[]; -} - -export const v0_1_0: UserDataV0 = { +export const v0_1_0: v0.UserData = { courses: [ { id: 0, @@ -38,7 +29,7 @@ export const v0_1_0: UserDataV0 = { ], }; -export const v0_2_0: UserDataV0 = { +export const v0_2_0: v0.UserData = { courses: [ { id: 0, @@ -55,7 +46,7 @@ export const v0_2_0: UserDataV0 = { ], }; -export const v0_3_0: UserDataV0 = { +export const v0_3_0: v0.UserData = { courses: [ { id: 0, @@ -74,7 +65,7 @@ export const v0_3_0: UserDataV0 = { ], }; -export const v0_4_0: UserDataV0 = { +export const v0_4_0: v0.UserData = { courses: [ { id: 0, @@ -94,7 +85,7 @@ export const v0_4_0: UserDataV0 = { ], }; -export const v0_6_0: UserDataV0 = { +export const v0_6_0: v0.UserData = { courses: [ { id: 0, @@ -114,7 +105,7 @@ export const v0_6_0: UserDataV0 = { ], }; -export const v0_8_0: UserDataV0 = { +export const v0_8_0: v0.UserData = { courses: [ { id: 0, @@ -135,7 +126,7 @@ export const v0_8_0: UserDataV0 = { ], }; -export const v0_9_0: UserDataV0 = { +export const v0_9_0: v0.UserData = { courses: [ { id: 0, @@ -158,7 +149,7 @@ export const v0_9_0: UserDataV0 = { ], }; -export const v1_0_0: UserDataV0 = { +export const v1_0_0: v0.UserData = { courses: [ { id: 0, @@ -193,7 +184,42 @@ export const v1_0_0: UserDataV0 = { ], }; -export const v2_0_0: UserDataV1 = { +export const v2_0_0: v1.UserData = { + courses: [ + { + id: 0, + availablePoints: 3, + awardedPoints: 0, + description: "Python Course", + disabled: true, + exercises: [ + { + id: 1, + deadline: null, + name: "hello_world", + passed: false, + softDeadline: null, + }, + { + id: 2, + deadline: "20201214", + name: "other_world", + passed: false, + softDeadline: "20201212", + }, + ], + materialUrl: "mooc.fi", + name: "test-python-course", + newExercises: [2, 3, 4], + notifyAfter: 1234, + organization: "test", + perhapsExamMode: true, + title: "The Python Course", + }, + ], +}; + +export const v2_1_0: v2.UserData = { courses: [ { id: 0, @@ -204,6 +230,8 @@ export const v2_0_0: UserDataV1 = { exercises: [ { id: 1, + availablePoints: 1, + awardedPoints: 0, deadline: null, name: "hello_world", passed: false, @@ -211,6 +239,8 @@ export const v2_0_0: UserDataV1 = { }, { id: 2, + availablePoints: 1, + awardedPoints: 0, deadline: "20201214", name: "other_world", passed: false, @@ -228,7 +258,7 @@ export const v2_0_0: UserDataV1 = { ], }; -export const v2_1_0: UserData = { +export const v3_0_0: v3.UserData = { courses: [ { id: 0, @@ -265,4 +295,5 @@ export const v2_1_0: UserData = { title: "The Python Course", }, ], + mooc_courses: [], }; 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..eb3fd5c1 100644 --- a/src/test/migrate/migrate.test.ts +++ b/src/test/migrate/migrate.test.ts @@ -4,9 +4,8 @@ import { IMock } from "typemoq"; import * as vscode from "vscode"; import Dialog from "../../api/dialog"; -import Storage from "../../api/storage"; -import TMC from "../../api/tmc"; -import { migrateExtensionDataFromPreviousVersions } from "../../migrate"; +import Storage from "../../storage"; +import Langs from "../../api/langs"; import { Logger, LogLevel } from "../../utilities"; import * as exerciseData from "../fixtures/exerciseData"; import * as extensionSettings from "../fixtures/extensionSettings"; @@ -16,14 +15,7 @@ import { createDialogMock } from "../mocks/dialog"; import { createFailingTMCMock, createTMCMock } from "../mocks/tmc"; import { createMockContext, createMockWorkspaceConfiguration } from "../mocks/vscode"; import { makeTmpDirs } from "../utils"; - -const UNSTABLE_EXERCISE_DATA_KEY = "exerciseData"; -const UNSTABLE_EXTENSION_SETTINGS_KEY = "extensionSettings"; -const UNSTABLE_USER_DATA_KEY = "userData"; - -const EXTENSION_SETTINGS_KEY_V1 = "extension-settings-v1"; -const SESSION_STATE_KEY_V1 = "session-state-v1"; -const USER_DATA_KEY_V1 = "user-data-v1"; +import { v0, v1 } from "../../storage/data"; suite("Extension data migration", function () { const virtualFileSystem = { @@ -36,7 +28,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; @@ -51,9 +43,8 @@ suite("Extension data migration", function () { }); test("should succeed without any data", async function () { - const result = await migrateExtensionDataFromPreviousVersions( + const result = await storage.migrateToLatest( context, - storage, dialogMock.object, tmcMock.object, settingsMock.object, @@ -65,93 +56,84 @@ suite("Extension data migration", function () { suite("from version 0.1.0", function () { test("should succeed with valid data", async function () { - await context.globalState.update(UNSTABLE_EXERCISE_DATA_KEY, exerciseData.v0_1_0(root)); - await context.globalState.update(UNSTABLE_USER_DATA_KEY, userData.v0_1_0); - const result = await migrateExtensionDataFromPreviousVersions( + await context.globalState.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_1_0(root)); + await context.globalState.update(v0.USER_DATA_KEY, userData.v0_1_0); + const result = await storage.migrateToLatest( context, - storage, dialogMock.object, tmcMock.object, settingsMock.object, ); expect(result).to.be.equal(Ok.EMPTY); expect(storage.getUserData()).to.not.be.undefined; - expect(context.globalState.get(UNSTABLE_EXERCISE_DATA_KEY)).to.be.undefined; + expect(context.globalState.get(v0.EXERCISE_DATA_KEY)).to.be.undefined; }); test("should not change anything if Langs fails", async function () { [tmcMock] = createFailingTMCMock(); - await context.globalState.update(UNSTABLE_EXERCISE_DATA_KEY, exerciseData.v0_1_0(root)); - await context.globalState.update(UNSTABLE_USER_DATA_KEY, userData.v0_1_0); - const result = await migrateExtensionDataFromPreviousVersions( + await context.globalState.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_1_0(root)); + await context.globalState.update(v0.USER_DATA_KEY, userData.v0_1_0); + const result = await storage.migrateToLatest( context, - storage, dialogMock.object, tmcMock.object, settingsMock.object, ); expect(result.val).to.be.instanceOf(Error); expect(storage.getUserData()).to.be.undefined; - console.log("a", context.globalState.get(UNSTABLE_EXERCISE_DATA_KEY)); + console.log("a", context.globalState.get(v0.EXERCISE_DATA_KEY)); console.log("b", exerciseData.v0_1_0(root)); - expect(context.globalState.get(UNSTABLE_EXERCISE_DATA_KEY)).to.be.deep.equal( + expect(context.globalState.get(v0.EXERCISE_DATA_KEY)).to.be.deep.equal( exerciseData.v0_1_0(root), ); - expect(context.globalState.get(UNSTABLE_USER_DATA_KEY)).to.be.deep.equal( - userData.v0_1_0, - ); + expect(context.globalState.get(v0.USER_DATA_KEY)).to.be.deep.equal(userData.v0_1_0); }); }); suite("from version 0.2.0", function () { test("should succeed with valid data", async function () { - await context.globalState.update(UNSTABLE_EXERCISE_DATA_KEY, exerciseData.v0_2_0(root)); - await context.globalState.update(UNSTABLE_USER_DATA_KEY, userData.v0_2_0); - const result = await migrateExtensionDataFromPreviousVersions( + await context.globalState.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_2_0(root)); + await context.globalState.update(v0.USER_DATA_KEY, userData.v0_2_0); + const result = await storage.migrateToLatest( context, - storage, dialogMock.object, tmcMock.object, settingsMock.object, ); expect(result).to.be.equal(Ok.EMPTY); expect(storage.getUserData()).to.not.be.undefined; - expect(context.globalState.get(UNSTABLE_EXERCISE_DATA_KEY)).to.be.undefined; + expect(context.globalState.get(v0.EXERCISE_DATA_KEY)).to.be.undefined; }); test("should not modify data if Langs fails", async function () { [tmcMock] = createFailingTMCMock(); - await context.globalState.update(UNSTABLE_EXERCISE_DATA_KEY, exerciseData.v0_2_0(root)); - await context.globalState.update(UNSTABLE_USER_DATA_KEY, userData.v0_2_0); - const result = await migrateExtensionDataFromPreviousVersions( + await context.globalState.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_2_0(root)); + await context.globalState.update(v0.USER_DATA_KEY, userData.v0_2_0); + const result = await storage.migrateToLatest( context, - storage, dialogMock.object, tmcMock.object, settingsMock.object, ); expect(result.val).to.be.instanceOf(Error); expect(storage.getUserData()).to.be.undefined; - expect(context.globalState.get(UNSTABLE_EXERCISE_DATA_KEY)).to.be.deep.equal( + expect(context.globalState.get(v0.EXERCISE_DATA_KEY)).to.be.deep.equal( exerciseData.v0_2_0(root), ); - expect(context.globalState.get(UNSTABLE_USER_DATA_KEY)).to.be.deep.equal( - userData.v0_2_0, - ); + expect(context.globalState.get(v0.USER_DATA_KEY)).to.be.deep.equal(userData.v0_2_0); }); }); suite("from version 0.3.0", function () { test("should succeed with valid data", async function () { - await context.globalState.update(UNSTABLE_EXERCISE_DATA_KEY, exerciseData.v0_3_0); - await context.globalState.update(UNSTABLE_USER_DATA_KEY, userData.v0_3_0); + await context.globalState.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_3_0); + await context.globalState.update(v0.USER_DATA_KEY, userData.v0_3_0); await context.globalState.update( - UNSTABLE_EXTENSION_SETTINGS_KEY, + v0.EXTENSION_SETTINGS_KEY, extensionSettings.v0_3_0(root), ); - const result = await migrateExtensionDataFromPreviousVersions( + const result = await storage.migrateToLatest( context, - storage, dialogMock.object, tmcMock.object, settingsMock.object, @@ -159,22 +141,21 @@ suite("Extension data migration", function () { expect(result).to.be.equal(Ok.EMPTY); expect(storage.getUserData()).to.not.be.undefined; expect(storage.getExtensionSettings()).to.not.be.undefined; - expect(context.globalState.get(UNSTABLE_EXERCISE_DATA_KEY)).to.be.undefined; - expect(context.globalState.get(UNSTABLE_EXTENSION_SETTINGS_KEY)).to.be.undefined; - expect(context.globalState.get(UNSTABLE_USER_DATA_KEY)).to.be.undefined; + expect(context.globalState.get(v0.EXERCISE_DATA_KEY)).to.be.undefined; + expect(context.globalState.get(v0.EXTENSION_SETTINGS_KEY)).to.be.undefined; + expect(context.globalState.get(v0.USER_DATA_KEY)).to.be.undefined; }); test("should not modify data if Langs fails", async function () { [tmcMock] = createFailingTMCMock(); - await context.globalState.update(UNSTABLE_EXERCISE_DATA_KEY, exerciseData.v0_3_0); - await context.globalState.update(UNSTABLE_USER_DATA_KEY, userData.v0_3_0); + await context.globalState.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_3_0); + await context.globalState.update(v0.USER_DATA_KEY, userData.v0_3_0); await context.globalState.update( - UNSTABLE_EXTENSION_SETTINGS_KEY, + v0.EXTENSION_SETTINGS_KEY, extensionSettings.v0_3_0(root), ); - const result = await migrateExtensionDataFromPreviousVersions( + const result = await storage.migrateToLatest( context, - storage, dialogMock.object, tmcMock.object, settingsMock.object, @@ -182,29 +163,26 @@ suite("Extension data migration", function () { expect(result.val).to.be.instanceOf(Error); expect(storage.getUserData()).to.be.undefined; expect(storage.getExtensionSettings()).to.be.undefined; - expect(context.globalState.get(UNSTABLE_EXERCISE_DATA_KEY)).to.be.deep.equal( + expect(context.globalState.get(v0.EXERCISE_DATA_KEY)).to.be.deep.equal( exerciseData.v0_3_0, ); - expect(context.globalState.get(UNSTABLE_EXTENSION_SETTINGS_KEY)).to.be.deep.equal( + expect(context.globalState.get(v0.EXTENSION_SETTINGS_KEY)).to.be.deep.equal( extensionSettings.v0_3_0(root), ); - expect(context.globalState.get(UNSTABLE_USER_DATA_KEY)).to.be.deep.equal( - userData.v0_3_0, - ); + expect(context.globalState.get(v0.USER_DATA_KEY)).to.be.deep.equal(userData.v0_3_0); }); }); suite("from version 0.9.0", function () { test("should succeed with valid data", async function () { - await context.globalState.update(UNSTABLE_EXERCISE_DATA_KEY, exerciseData.v0_9_0); - await context.globalState.update(UNSTABLE_USER_DATA_KEY, userData.v0_9_0); + await context.globalState.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_9_0); + await context.globalState.update(v0.USER_DATA_KEY, userData.v0_9_0); await context.globalState.update( - UNSTABLE_EXTENSION_SETTINGS_KEY, + v0.EXTENSION_SETTINGS_KEY, extensionSettings.v0_9_0(root), ); - const result = await migrateExtensionDataFromPreviousVersions( + const result = await storage.migrateToLatest( context, - storage, dialogMock.object, tmcMock.object, settingsMock.object, @@ -212,22 +190,21 @@ suite("Extension data migration", function () { expect(result).to.be.equal(Ok.EMPTY); expect(storage.getUserData()).to.not.be.undefined; expect(storage.getExtensionSettings()).to.not.be.undefined; - expect(context.globalState.get(UNSTABLE_EXERCISE_DATA_KEY)).to.be.undefined; - expect(context.globalState.get(UNSTABLE_EXTENSION_SETTINGS_KEY)).to.be.undefined; - expect(context.globalState.get(UNSTABLE_USER_DATA_KEY)).to.be.undefined; + expect(context.globalState.get(v0.EXERCISE_DATA_KEY)).to.be.undefined; + expect(context.globalState.get(v0.EXTENSION_SETTINGS_KEY)).to.be.undefined; + expect(context.globalState.get(v0.USER_DATA_KEY)).to.be.undefined; }); test("should not modify data if Langs fails", async function () { [tmcMock] = createFailingTMCMock(); - await context.globalState.update(UNSTABLE_EXERCISE_DATA_KEY, exerciseData.v0_9_0); - await context.globalState.update(UNSTABLE_USER_DATA_KEY, userData.v0_9_0); + await context.globalState.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_9_0); + await context.globalState.update(v0.USER_DATA_KEY, userData.v0_9_0); await context.globalState.update( - UNSTABLE_EXTENSION_SETTINGS_KEY, + v0.EXTENSION_SETTINGS_KEY, extensionSettings.v0_9_0(root), ); - const result = await migrateExtensionDataFromPreviousVersions( + const result = await storage.migrateToLatest( context, - storage, dialogMock.object, tmcMock.object, settingsMock.object, @@ -235,26 +212,23 @@ suite("Extension data migration", function () { expect(result.val).to.be.instanceOf(Error); expect(storage.getUserData()).to.be.undefined; expect(storage.getExtensionSettings()).to.be.undefined; - expect(context.globalState.get(UNSTABLE_EXERCISE_DATA_KEY)).to.be.deep.equal( + expect(context.globalState.get(v0.EXERCISE_DATA_KEY)).to.be.deep.equal( exerciseData.v0_9_0, ); - expect(context.globalState.get(UNSTABLE_EXTENSION_SETTINGS_KEY)).to.be.deep.equal( + expect(context.globalState.get(v0.EXTENSION_SETTINGS_KEY)).to.be.deep.equal( extensionSettings.v0_9_0(root), ); - expect(context.globalState.get(UNSTABLE_USER_DATA_KEY)).to.be.deep.equal( - userData.v0_9_0, - ); + expect(context.globalState.get(v0.USER_DATA_KEY)).to.be.deep.equal(userData.v0_9_0); }); }); suite("from version 2.0.0", function () { test("should succeed with valid data", async function () { - await context.globalState.update(USER_DATA_KEY_V1, userData.v2_0_0); - await context.globalState.update(EXTENSION_SETTINGS_KEY_V1, extensionSettings.v2_0_0); - await context.globalState.update(SESSION_STATE_KEY_V1, sessionState.v2_0_0); - const result = await migrateExtensionDataFromPreviousVersions( + await context.globalState.update(v1.USER_DATA_KEY, userData.v2_0_0); + await context.globalState.update(v1.EXTENSION_SETTINGS_KEY, extensionSettings.v2_0_0); + await context.globalState.update(v1.SESSION_STATE_KEY, sessionState.v2_0_0); + const result = await storage.migrateToLatest( context, - storage, dialogMock.object, tmcMock.object, settingsMock.object, diff --git a/src/test/migrate/migrateExerciseData.test.ts b/src/test/migrate/migrateExerciseData.test.ts index d335e60f..c522d04d 100644 --- a/src/test/migrate/migrateExerciseData.test.ts +++ b/src/test/migrate/migrateExerciseData.test.ts @@ -3,17 +3,15 @@ import { IMock, It, Times } from "typemoq"; import * as vscode from "vscode"; import Dialog from "../../api/dialog"; -import TMC from "../../api/tmc"; -import migrateExerciseData from "../../migrate/migrateExerciseData"; +import Langs from "../../api/langs"; import { Logger, LogLevel } from "../../utilities"; import * as exerciseData from "../fixtures/exerciseData"; import { createDialogMock } from "../mocks/dialog"; import { createTMCMock } from "../mocks/tmc"; import { createMockMemento } from "../mocks/vscode"; import { makeTmpDirs } from "../utils"; - -const EXERCISE_DATA_KEY_V0 = "exerciseData"; -const UNSTABLE_EXTENSION_SETTINGS_KEY = "extensionSettings"; +import migrateExerciseDataToLatest from "../../storage/migration/exerciseData"; +import { v0 } from "../../storage/data"; suite("Exercise data migration", function () { const virtualFileSystem = { @@ -25,7 +23,7 @@ suite("Exercise data migration", function () { let dialogMock: IMock; let memento: vscode.Memento; - let tmcMock: IMock; + let tmcMock: IMock; setup(function () { [dialogMock] = createDialogMock(); @@ -36,59 +34,79 @@ suite("Exercise data migration", function () { suite("between versions", function () { test("should succeed without any data", async function () { - const migrated = await migrateExerciseData(memento, dialogMock.object, tmcMock.object); + const migrated = await migrateExerciseDataToLatest( + memento, + dialogMock.object, + tmcMock.object, + ); expect(migrated.data).to.be.undefined; expect(migrated.obsoleteKeys).to.be.deep.equal([]); }); test("should succeed with version 0.1.0 data", async function () { const dataPath = makeTmpDirs(virtualFileSystem); - await memento.update(EXERCISE_DATA_KEY_V0, exerciseData.v0_1_0(dataPath)); - const migrated = await migrateExerciseData(memento, dialogMock.object, tmcMock.object); + await memento.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_1_0(dataPath)); + const migrated = await migrateExerciseDataToLatest( + memento, + dialogMock.object, + tmcMock.object, + ); expect(migrated.data).to.be.undefined; - expect(migrated.obsoleteKeys).to.be.deep.equal([EXERCISE_DATA_KEY_V0]); + expect(migrated.obsoleteKeys).to.be.deep.equal([v0.EXERCISE_DATA_KEY]); }); test("should succeed with version 0.2.0 data", async function () { const dataPath = makeTmpDirs(virtualFileSystem); - await memento.update(EXERCISE_DATA_KEY_V0, exerciseData.v0_2_0(dataPath)); - const migrated = await migrateExerciseData(memento, dialogMock.object, tmcMock.object); + await memento.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_2_0(dataPath)); + const migrated = await migrateExerciseDataToLatest( + memento, + dialogMock.object, + tmcMock.object, + ); expect(migrated.data).to.be.undefined; - expect(migrated.obsoleteKeys).to.be.deep.equal([EXERCISE_DATA_KEY_V0]); + expect(migrated.obsoleteKeys).to.be.deep.equal([v0.EXERCISE_DATA_KEY]); }); test("should succeed with version 0.3.0 data", async function () { const dataPath = makeTmpDirs(virtualFileSystem); - await memento.update(UNSTABLE_EXTENSION_SETTINGS_KEY, { dataPath }); - await memento.update(EXERCISE_DATA_KEY_V0, exerciseData.v0_3_0); - const migrated = await migrateExerciseData(memento, dialogMock.object, tmcMock.object); + await memento.update(v0.EXTENSION_SETTINGS_KEY, { dataPath }); + await memento.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_3_0); + const migrated = await migrateExerciseDataToLatest( + memento, + dialogMock.object, + tmcMock.object, + ); expect(migrated.data).to.be.undefined; - expect(migrated.obsoleteKeys).to.be.deep.equal([EXERCISE_DATA_KEY_V0]); + expect(migrated.obsoleteKeys).to.be.deep.equal([v0.EXERCISE_DATA_KEY]); }); test("should succeed with version 0.9.0 data", async function () { const dataPath = makeTmpDirs(virtualFileSystem); - await memento.update(UNSTABLE_EXTENSION_SETTINGS_KEY, { dataPath }); - await memento.update(EXERCISE_DATA_KEY_V0, exerciseData.v0_9_0); - const migrated = await migrateExerciseData(memento, dialogMock.object, tmcMock.object); + await memento.update(v0.EXTENSION_SETTINGS_KEY, { dataPath }); + await memento.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_9_0); + const migrated = await migrateExerciseDataToLatest( + memento, + dialogMock.object, + tmcMock.object, + ); expect(migrated.data).to.be.undefined; - expect(migrated.obsoleteKeys).to.be.deep.equal([EXERCISE_DATA_KEY_V0]); + expect(migrated.obsoleteKeys).to.be.deep.equal([v0.EXERCISE_DATA_KEY]); }); }); suite("with unstable data", function () { test("should fail if data is garbage", async function () { - await memento.update(EXERCISE_DATA_KEY_V0, { ironman: "Tony Stark" }); + await memento.update(v0.EXERCISE_DATA_KEY, { ironman: "Tony Stark" }); expect( - migrateExerciseData(memento, dialogMock.object, tmcMock.object), + migrateExerciseDataToLatest(memento, dialogMock.object, tmcMock.object), ).to.be.rejectedWith(/mismatch/); }); test("should set closed exercises to TMC-langs", async function () { const dataPath = makeTmpDirs(virtualFileSystem); - await memento.update(UNSTABLE_EXTENSION_SETTINGS_KEY, { dataPath }); - await memento.update(EXERCISE_DATA_KEY_V0, exerciseData.v0_3_0); - await migrateExerciseData(memento, dialogMock.object, tmcMock.object); + await memento.update(v0.EXTENSION_SETTINGS_KEY, { dataPath }); + await memento.update(v0.EXERCISE_DATA_KEY, exerciseData.v0_3_0); + await migrateExerciseDataToLatest(memento, dialogMock.object, tmcMock.object); const testValue = ["other_world"]; tmcMock.verify( (x) => diff --git a/src/test/migrate/migrateExtensionSettings.test.ts b/src/test/migrate/migrateExtensionSettings.test.ts index 7fcc2686..12e7147e 100644 --- a/src/test/migrate/migrateExtensionSettings.test.ts +++ b/src/test/migrate/migrateExtensionSettings.test.ts @@ -1,17 +1,14 @@ import { expect, use } from "chai"; -import * as chaiAsPromised from "chai-as-promised"; +import chaiAsPromised from "chai-as-promised"; import * as tmp from "tmp"; import { IMock, It, Times } from "typemoq"; import * as vscode from "vscode"; -import migrateExtensionSettings, { - ExtensionSettingsV0, - LogLevelV0, - LogLevelV1, -} from "../../migrate/migrateExtensionSettings"; +import migrateExtensionSettings from "../../storage/migration/extensionSettings"; import { LogLevel } from "../../utilities"; import * as extensionSettings from "../fixtures/extensionSettings"; import { createMockMemento, createMockWorkspaceConfiguration } from "../mocks/vscode"; +import { v0, v1 } from "../../storage/data"; use(chaiAsPromised); @@ -189,14 +186,14 @@ suite("Extension settings migration", function () { }); test("should remap logger values properly", async function () { - const expectedRemappings: [LogLevelV0, LogLevelV1][] = [ - [LogLevelV0.Debug, "verbose"], - [LogLevelV0.Errors, "errors"], - [LogLevelV0.None, "none"], - [LogLevelV0.Verbose, "verbose"], + const expectedRemappings: [v0.LogLevel, v1.LogLevel][] = [ + [v0.LogLevel.Debug, "verbose"], + [v0.LogLevel.Errors, "errors"], + [v0.LogLevel.None, "none"], + [v0.LogLevel.Verbose, "verbose"], ]; for (const [oldLevel, expectedLevel] of expectedRemappings) { - const oldSettings: ExtensionSettingsV0 = { dataPath: root, logLevel: oldLevel }; + const oldSettings: v0.ExtensionSettings = { dataPath: root, logLevel: oldLevel }; await memento.update(EXTENSION_SETTINGS_KEY_V0, oldSettings); const migrated = (await migrateExtensionSettings(memento, settingsMock.object)) .data; diff --git a/src/test/migrate/migrateSessionState.test.ts b/src/test/migrate/migrateSessionState.test.ts index ba838bef..fbfb4684 100644 --- a/src/test/migrate/migrateSessionState.test.ts +++ b/src/test/migrate/migrateSessionState.test.ts @@ -1,12 +1,10 @@ import { expect } from "chai"; import * as vscode from "vscode"; -import migrateSessionState from "../../migrate/migrateSessionState"; import * as sessionState from "../fixtures/sessionState"; import { createMockMemento } from "../mocks/vscode"; - -const EXTENSION_VERSION_KEY = "extensionVersion"; -const SESSION_STATE_KEY_V1 = "session-state-v1"; +import migrateSessionState from "../../storage/migration/sessionState"; +import { v0, v1 } from "../../storage/data"; suite("Session state migration", function () { let memento: vscode.Memento; @@ -24,14 +22,14 @@ suite("Session state migration", function () { }); test("succeeds with version 2.0.0 data", async function () { - await memento.update(SESSION_STATE_KEY_V1, sessionState.v2_0_0); + await memento.update(v1.SESSION_STATE_KEY, sessionState.v2_0_0); const migrated = migrateSessionState(memento).data; expect(migrated).to.be.deep.equal(sessionState.v2_0_0); }); test("should succeed with backwards compatible future data", async function () { const data = { ...sessionState.v2_0_0, wonderwoman: "Diana Prince" }; - await memento.update(SESSION_STATE_KEY_V1, data); + await memento.update(v1.SESSION_STATE_KEY, data); const migrated = migrateSessionState(memento).data; expect(migrated).to.be.deep.equal(data); }); @@ -39,12 +37,12 @@ suite("Session state migration", function () { suite("with unstable data", function () { test("fails with garbage data", async function () { - await memento.update(EXTENSION_VERSION_KEY, { wonderwoman: "Diana Prince" }); + await memento.update(v0.EXTENSION_VERSION_KEY, { wonderwoman: "Diana Prince" }); expect(() => migrateSessionState(memento)).to.throw(/mismatch/); }); test("finds extension version", async function () { - await memento.update(EXTENSION_VERSION_KEY, "1.3.4"); + await memento.update(v0.EXTENSION_VERSION_KEY, "1.3.4"); const migrated = migrateSessionState(memento).data; expect(migrated?.extensionVersion).to.be.equal("1.3.4"); }); @@ -52,7 +50,7 @@ suite("Session state migration", function () { suite("with stable data", function () { test("fails with garbage version 1 data", async function () { - await memento.update(SESSION_STATE_KEY_V1, { extensionVersion: 1 }); + await memento.update(v1.SESSION_STATE_KEY, { extensionVersion: 1 }); expect(() => migrateSessionState(memento)).to.throw(/mismatch/); }); }); diff --git a/src/test/migrate/migrateUserData.test.ts b/src/test/migrate/migrateUserData.test.ts index 928dbb75..d42e5a86 100644 --- a/src/test/migrate/migrateUserData.test.ts +++ b/src/test/migrate/migrateUserData.test.ts @@ -7,10 +7,11 @@ import { LOCAL_EXERCISE_AWARDED_POINTS_PLACEHOLDER, LOCAL_EXERCISE_UNAWARDED_POINTS_PLACEHOLDER, } from "../../config/constants"; -import migrateUserData, { LocalCourseDataV0 } from "../../migrate/migrateUserData"; +import migrateUserData from "../../storage/migration/userData"; import * as exerciseData from "../fixtures/exerciseData"; import * as userData from "../fixtures/userData"; import { createMockMemento } from "../mocks/vscode"; +import { v0 } from "../../storage/data"; const UNSTABLE_EXERCISE_DATA_KEY = "exerciseData"; const USER_DATA_KEY_V0 = "userData"; @@ -148,7 +149,7 @@ suite("User data migration", function () { }); test("should successfully map unstable data with multiple courses", async function () { - const courses: LocalCourseDataV0[] = [ + const courses: v0.LocalCourseData[] = [ { id: 0, description: "", 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..97e5a503 100644 --- a/src/test/mocks/userdata.ts +++ b/src/test/mocks/userdata.ts @@ -1,17 +1,18 @@ 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, makeTmcKind } from "../../shared/shared"; +import { TmcLocalCourseExercise } from "../../storage/data"; export interface UserDataMockValues { getCourses: LocalCourseData[]; - getExerciseByName: Readonly | undefined; + getExerciseByName: Readonly | undefined; } export function createUserDataMock(): [IMock, UserDataMockValues] { const values: UserDataMockValues = { - getCourses: userData.courses, + getCourses: userData.courses.map(makeTmcKind), getExerciseByName: undefined, }; const mock = setupMockValues(values); @@ -24,7 +25,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..72aa8b1b 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 "../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..87daa754 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 { TmcLocalCourseExercise } from "../storage/data"; /** * 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[] { +): TmcLocalCourseExercise[] { 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: TmcLocalCourseExercise = { 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/tsconfig.json b/tsconfig.json index 2ade64af..faf57cae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "typeRoots": ["./types", "./node_modules/@types"], "sourceMap": true, "rootDirs": ["src", "shared"], + "esModuleInterop": true, "strict": true /* enable all strict type-checking options */ /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 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}