diff --git a/lang/ui.en.json b/lang/ui.en.json index 0e71c77e3..3cd024858 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 target 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..13c2b7814 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 target 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/HomePage.tsx b/src/pages/HomePage.tsx index 23a2aed75..42908d8dd 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -10,8 +10,8 @@ import { useInterval, VStack, } from "@chakra-ui/react"; -import { ReactNode, useCallback, useState } from "react"; -import { FormattedMessage, useIntl } from "react-intl"; +import { ReactNode, useCallback, useEffect, useState } from "react"; +import { FormattedMessage, IntlShape, useIntl } from "react-intl"; import { useNavigate } from "react-router"; import DefaultPageLayout from "../components/DefaultPageLayout"; import PercentageDisplay from "../components/PercentageDisplay"; @@ -24,8 +24,13 @@ import { useDeployment } from "../deployment"; import blockImage from "../images/block.png"; import xyzGraph from "../images/xyz-graph.png"; import clap from "../images/clap-square.png"; -import { createNewPageUrl } from "../urls"; +import { createNewPageUrl, createSessionPageUrl } from "../urls"; import { flags } from "../flags"; +import { useSearchParams } from "react-router-dom"; +import { MicrobitOrgResource } from "../model"; +import { Project } from "@microbit/makecode-embed/react"; +import { useStore } from "../store"; +import { SessionPageId } from "../pages-config"; const graphData = { x: [ @@ -63,13 +68,88 @@ const graphData = { ], }; +const useMicrobitResourceSearchParams = (): MicrobitOrgResource | undefined => { + const [params] = useSearchParams(); + const id = params.get("id"); + const project = params.get("project"); + const name = params.get("name"); + + const resource: MicrobitOrgResource | undefined = + id && name && project + ? { + id, + project, + name, + } + : undefined; + return resource; +}; + +const isValidEditorContent = (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}${resource.id}-makecode.json`; + let json; + try { + const response = await fetch(url); + 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 || + !isValidEditorContent(json.editorContent) + ) { + throw new Error(intl.formatMessage({ id: "code-format-error" })); + } + return json.editorContent; +}; + const HomePage = () => { const navigate = useNavigate(); const handleGetStarted = useCallback(() => { navigate(createNewPageUrl()); }, [navigate]); const intl = useIntl(); - const { appNameFull } = useDeployment(); + const { appNameFull, activitiesBaseUrl } = useDeployment(); + const resource = useMicrobitResourceSearchParams(); + const loadProject = useStore((s) => s.loadProject); + console.log("resource", resource); + + useEffect(() => { + const updateAsync = async () => { + if (!resource || !activitiesBaseUrl) { + return; + } + const code = await fetchMicrobitOrgResourceTargetCode( + activitiesBaseUrl, + resource, + intl + ); + console.log("code", code); + loadProject(code); + navigate(createSessionPageUrl(SessionPageId.DataSamples)); + }; + void updateAsync(); + }, [activitiesBaseUrl, intl, loadProject, navigate, resource]); + return ( { + const navigate = useNavigate(); + const intl = useIntl(); + const { activitiesBaseUrl } = useDeployment(); + const resource = useMicrobitResourceSearchParams(); + const loadProject = useStore((s) => s.loadProject); + console.log("resource", resource); + + useEffect(() => { + const updateAsync = async () => { + if (!resource || !activitiesBaseUrl) { + return; + } + const code = await fetchMicrobitOrgResourceTargetCode( + activitiesBaseUrl, + resource, + intl + ); + console.log("code", code); + loadProject(code); + navigate(createSessionPageUrl(SessionPageId.DataSamples)); + }; + 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 isValidEditorContent = (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}${resource.id}-makecode.json`; + let json; + try { + const response = await fetch(url); + 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 || + !isValidEditorContent(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 a16dcafb0..2c0c97717 100644 --- a/src/store.ts +++ b/src/store.ts @@ -131,6 +131,7 @@ export interface Actions { downloadDataset(): void; dataCollectionMicrobitConnected(): void; loadDataset(gestures: GestureData[]): void; + loadProject(project: Project): void; setEditorOpen(open: boolean): void; recordingStarted(): void; recordingStopped(): void; @@ -409,6 +410,17 @@ export const useStore = create()( }); }, + loadProject(project: Project) { + set(({ projectEdited, gestures: prevGestures }) => { + const newGestures = getGesturesFromProject(project, prevGestures); + return { + gestures: newGestures, + model: undefined, + ...updateProject(project, projectEdited, newGestures, undefined), + }; + }); + }, + closeTrainModelDialogs() { set({ trainModelDialogStage: TrainModelDialogStage.Closed, @@ -762,3 +774,18 @@ const gestureIcon = ({ } return useableIcons[0]; }; + +const getGesturesFromProject = ( + project: Project, + prevGestures: GestureData[] +): GestureData[] => { + const { text } = project; + if (text === undefined || !("dataset.json" in text)) { + return prevGestures; + } + const dataset = JSON.parse(text["dataset.json"]) as object; + if (typeof dataset !== "object" || !("data" in dataset)) { + return prevGestures; + } + 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`;