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`;