diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5e0d48603..a273ef706 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -36,7 +36,7 @@ jobs:
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - run: npm install --no-save @microbit-foundation/ml-trainer-microbit@0.2.0-dev.10 @microbit-foundation/website-deploy-aws@0.6 @microbit-foundation/website-deploy-aws-config@0.9
+ - run: npm install --no-save @microbit-foundation/ml-trainer-microbit@0.2.0-dev.13 @microbit-foundation/website-deploy-aws@0.6 @microbit-foundation/website-deploy-aws-config@0.9
if: github.repository_owner == 'microbit-foundation'
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/lang/ui.en.json b/lang/ui.en.json
index 0e71c77e3..3a49a5ee0 100644
--- a/lang/ui.en.json
+++ b/lang/ui.en.json
@@ -87,6 +87,10 @@
"defaultMessage": "Close",
"description": "Close button text or label"
},
+ "code-download-error": {
+ "defaultMessage": "Error downloading the project code",
+ "description": "Title for error message relating to project loading"
+ },
"coming-soon": {
"defaultMessage": "Coming soon",
"description": "Placeholder text for future projects"
diff --git a/src/App.tsx b/src/App.tsx
index 79c9b9349..b0172d7b4 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -22,11 +22,13 @@ import { LoggingProvider } from "./logging/logging-hooks";
import TranslationProvider from "./messages/TranslationProvider";
import DataSamplesPage from "./pages/DataSamplesPage";
import HomePage from "./pages/HomePage";
+import ImportPage from "./pages/ImportPage";
import NewPage from "./pages/NewPage";
import TestingModelPage from "./pages/TestingModelPage";
import {
createDataSamplesPageUrl,
createHomePageUrl,
+ createImportPageUrl,
createNewPageUrl,
createTestingModelPageUrl,
} from "./urls";
@@ -96,6 +98,7 @@ const createRouter = () => {
path: createNewPageUrl(),
element: ,
},
+ { path: createImportPageUrl(), element: },
{
path: createDataSamplesPageUrl(),
element: ,
diff --git a/src/deployment/index.ts b/src/deployment/index.ts
index ecbf7ddc7..0356d3ebd 100644
--- a/src/deployment/index.ts
+++ b/src/deployment/index.ts
@@ -56,6 +56,7 @@ export interface DeploymentConfig {
termsOfUseLink?: string;
privacyPolicyLink?: string;
+ activitiesBaseUrl?: string;
logging: Logging;
}
diff --git a/src/messages/ui.en.json b/src/messages/ui.en.json
index e35f3f044..5c5d94f56 100644
--- a/src/messages/ui.en.json
+++ b/src/messages/ui.en.json
@@ -173,6 +173,12 @@
"value": "Close"
}
],
+ "code-download-error": [
+ {
+ "type": 0,
+ "value": "Error downloading the project code"
+ }
+ ],
"coming-soon": [
{
"type": 0,
diff --git a/src/model.ts b/src/model.ts
index be19a8822..b56f1c61d 100644
--- a/src/model.ts
+++ b/src/model.ts
@@ -171,3 +171,28 @@ export enum TourId {
CollectDataToTrainModel = "collectDataToTrainModel",
TestModelPage = "testModelPage",
}
+
+/**
+ * Information passed omn the URL from microbit.org.
+ * We call back into microbit.org to grab a JSON file with
+ * full details.
+ */
+export type MicrobitOrgResource = {
+ /**
+ * ID that can be used when fetching the code from microbit.org.
+ */
+ id: string;
+
+ /**
+ * Name of the microbit.org project or lesson.
+ *
+ * We use this to load the target code.
+ */
+ project: string;
+
+ /**
+ * Name of the actual code snippet.
+ * Due to a data issue this can often be the same as the project name.
+ */
+ name: string;
+};
diff --git a/src/pages/ImportPage.tsx b/src/pages/ImportPage.tsx
new file mode 100644
index 000000000..d34226e76
--- /dev/null
+++ b/src/pages/ImportPage.tsx
@@ -0,0 +1,87 @@
+import { Project } from "@microbit/makecode-embed/react";
+import { useEffect } from "react";
+import { IntlShape, useIntl } from "react-intl";
+import { useNavigate } from "react-router";
+import { useSearchParams } from "react-router-dom";
+import { useDeployment } from "../deployment";
+import { MicrobitOrgResource } from "../model";
+import { useStore } from "../store";
+import { createDataSamplesPageUrl } from "../urls";
+
+const ImportPage = () => {
+ const navigate = useNavigate();
+ const intl = useIntl();
+ const { activitiesBaseUrl } = useDeployment();
+ const resource = useMicrobitResourceSearchParams();
+ const loadProject = useStore((s) => s.loadProject);
+
+ useEffect(() => {
+ const updateAsync = async () => {
+ if (!resource || !activitiesBaseUrl) {
+ return;
+ }
+ const code = await fetchMicrobitOrgResourceTargetCode(
+ activitiesBaseUrl,
+ resource,
+ intl
+ );
+ loadProject(code);
+ navigate(createDataSamplesPageUrl());
+ };
+ void updateAsync();
+ }, [activitiesBaseUrl, intl, loadProject, navigate, resource]);
+
+ return <>>;
+};
+
+const useMicrobitResourceSearchParams = (): MicrobitOrgResource | undefined => {
+ const [params] = useSearchParams();
+ const id = params.get("id");
+ const project = params.get("project");
+ const name = params.get("name");
+ return id && name && project ? { id, project, name } : undefined;
+};
+
+const isValidProject = (content: Project): content is Project => {
+ return (
+ content &&
+ typeof content === "object" &&
+ "text" in content &&
+ !!content.text
+ );
+};
+
+const fetchMicrobitOrgResourceTargetCode = async (
+ activitiesBaseUrl: string,
+ resource: MicrobitOrgResource,
+ intl: IntlShape
+): Promise => {
+ const url = `${activitiesBaseUrl}${encodeURIComponent(
+ resource.id
+ )}-makecode.json`;
+ let json;
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Unexpected response ${response.status}`);
+ }
+ json = (await response.json()) as object;
+ } catch (e) {
+ const rethrow = new Error(
+ intl.formatMessage({ id: "code-download-error" })
+ );
+ rethrow.stack = e instanceof Error ? e.stack : undefined;
+ throw rethrow;
+ }
+ if (
+ !("editorContent" in json) ||
+ typeof json.editorContent !== "object" ||
+ !json.editorContent ||
+ !isValidProject(json.editorContent)
+ ) {
+ throw new Error(intl.formatMessage({ id: "code-format-error" }));
+ }
+ return json.editorContent;
+};
+
+export default ImportPage;
diff --git a/src/store.ts b/src/store.ts
index 84ca60502..2f39596d7 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -11,7 +11,6 @@ import {
} from "./makecode/utils";
import { trainModel } from "./ml";
import {
- DatasetEditorJsonFormat,
DownloadState,
DownloadStep,
Gesture,
@@ -131,6 +130,7 @@ export interface Actions {
downloadDataset(): void;
dataCollectionMicrobitConnected(): void;
loadDataset(gestures: GestureData[]): void;
+ loadProject(project: Project): void;
setEditorOpen(open: boolean): void;
recordingStarted(): void;
recordingStopped(): void;
@@ -410,6 +410,25 @@ export const useStore = create()(
});
},
+ /**
+ * Generally project loads go via MakeCode as it reads the hex but when we open projects
+ * from microbit.org we have the JSON already and use this route.
+ */
+ loadProject(project: Project) {
+ set(() => {
+ const timestamp = Date.now();
+ return {
+ gestures: getGesturesFromProject(project),
+ model: undefined,
+ project,
+ projectEdited: true,
+ appEditNeedsFlushToEditor: true,
+ timestamp,
+ projectLoadTimestamp: timestamp,
+ };
+ });
+ },
+
closeTrainModelDialogs() {
set({
trainModelDialogStage: TrainModelDialogStage.Closed,
@@ -547,11 +566,6 @@ export const useStore = create()(
// It's a new project. Thanks user. We'll update our state.
// This will cause another write to MakeCode but that's OK as it gives us
// a chance to validate/update the project
- const datasetString = newProject.text?.[filenames.datasetJson];
- const dataset = datasetString
- ? (JSON.parse(datasetString) as DatasetEditorJsonFormat)
- : { data: [] };
-
const timestamp = Date.now();
return {
project: newProject,
@@ -559,7 +573,7 @@ export const useStore = create()(
timestamp,
// New project loaded externally so we can't know whether its edited.
projectEdited: true,
- gestures: dataset.data,
+ gestures: getGesturesFromProject(newProject),
model: undefined,
isEditorOpen: false,
};
@@ -765,3 +779,15 @@ const gestureIcon = ({
}
return useableIcons[0];
};
+
+const getGesturesFromProject = (project: Project): GestureData[] => {
+ const { text } = project;
+ if (text === undefined || !("dataset.json" in text)) {
+ return [];
+ }
+ const dataset = JSON.parse(text["dataset.json"]) as object;
+ if (typeof dataset !== "object" || !("data" in dataset)) {
+ return [];
+ }
+ return dataset.data as GestureData[];
+};
diff --git a/src/urls.ts b/src/urls.ts
index 64a4843dc..a16748e76 100644
--- a/src/urls.ts
+++ b/src/urls.ts
@@ -8,6 +8,8 @@ export const createHomePageUrl = () => `${basepath}`;
export const createNewPageUrl = () => `${basepath}new`;
+export const createImportPageUrl = () => `${basepath}import`;
+
export const createDataSamplesPageUrl = () => `${basepath}data-samples`;
export const createTestingModelPageUrl = () => `${basepath}testing-model`;