Skip to content

Commit d981b28

Browse files
Handle opening project from microbit.org (first cut for testing) (#388)
Merging to test the integration.
1 parent a26b446 commit d981b28

File tree

9 files changed

+162
-8
lines changed

9 files changed

+162
-8
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
- run: npm ci
3737
env:
3838
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39-
- run: npm install --no-save @microbit-foundation/[email protected].10 @microbit-foundation/[email protected] @microbit-foundation/[email protected]
39+
- run: npm install --no-save @microbit-foundation/[email protected].13 @microbit-foundation/[email protected] @microbit-foundation/[email protected]
4040
if: github.repository_owner == 'microbit-foundation'
4141
env:
4242
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

lang/ui.en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@
8787
"defaultMessage": "Close",
8888
"description": "Close button text or label"
8989
},
90+
"code-download-error": {
91+
"defaultMessage": "Error downloading the project code",
92+
"description": "Title for error message relating to project loading"
93+
},
9094
"coming-soon": {
9195
"defaultMessage": "Coming soon",
9296
"description": "Placeholder text for future projects"

src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ import { LoggingProvider } from "./logging/logging-hooks";
2222
import TranslationProvider from "./messages/TranslationProvider";
2323
import DataSamplesPage from "./pages/DataSamplesPage";
2424
import HomePage from "./pages/HomePage";
25+
import ImportPage from "./pages/ImportPage";
2526
import NewPage from "./pages/NewPage";
2627
import TestingModelPage from "./pages/TestingModelPage";
2728
import {
2829
createDataSamplesPageUrl,
2930
createHomePageUrl,
31+
createImportPageUrl,
3032
createNewPageUrl,
3133
createTestingModelPageUrl,
3234
} from "./urls";
@@ -96,6 +98,7 @@ const createRouter = () => {
9698
path: createNewPageUrl(),
9799
element: <NewPage />,
98100
},
101+
{ path: createImportPageUrl(), element: <ImportPage /> },
99102
{
100103
path: createDataSamplesPageUrl(),
101104
element: <DataSamplesPage />,

src/deployment/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface DeploymentConfig {
5656

5757
termsOfUseLink?: string;
5858
privacyPolicyLink?: string;
59+
activitiesBaseUrl?: string;
5960

6061
logging: Logging;
6162
}

src/messages/ui.en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,12 @@
173173
"value": "Close"
174174
}
175175
],
176+
"code-download-error": [
177+
{
178+
"type": 0,
179+
"value": "Error downloading the project code"
180+
}
181+
],
176182
"coming-soon": [
177183
{
178184
"type": 0,

src/model.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,28 @@ export enum TourId {
171171
CollectDataToTrainModel = "collectDataToTrainModel",
172172
TestModelPage = "testModelPage",
173173
}
174+
175+
/**
176+
* Information passed omn the URL from microbit.org.
177+
* We call back into microbit.org to grab a JSON file with
178+
* full details.
179+
*/
180+
export type MicrobitOrgResource = {
181+
/**
182+
* ID that can be used when fetching the code from microbit.org.
183+
*/
184+
id: string;
185+
186+
/**
187+
* Name of the microbit.org project or lesson.
188+
*
189+
* We use this to load the target code.
190+
*/
191+
project: string;
192+
193+
/**
194+
* Name of the actual code snippet.
195+
* Due to a data issue this can often be the same as the project name.
196+
*/
197+
name: string;
198+
};

src/pages/ImportPage.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Project } from "@microbit/makecode-embed/react";
2+
import { useEffect } from "react";
3+
import { IntlShape, useIntl } from "react-intl";
4+
import { useNavigate } from "react-router";
5+
import { useSearchParams } from "react-router-dom";
6+
import { useDeployment } from "../deployment";
7+
import { MicrobitOrgResource } from "../model";
8+
import { useStore } from "../store";
9+
import { createDataSamplesPageUrl } from "../urls";
10+
11+
const ImportPage = () => {
12+
const navigate = useNavigate();
13+
const intl = useIntl();
14+
const { activitiesBaseUrl } = useDeployment();
15+
const resource = useMicrobitResourceSearchParams();
16+
const loadProject = useStore((s) => s.loadProject);
17+
18+
useEffect(() => {
19+
const updateAsync = async () => {
20+
if (!resource || !activitiesBaseUrl) {
21+
return;
22+
}
23+
const code = await fetchMicrobitOrgResourceTargetCode(
24+
activitiesBaseUrl,
25+
resource,
26+
intl
27+
);
28+
loadProject(code);
29+
navigate(createDataSamplesPageUrl());
30+
};
31+
void updateAsync();
32+
}, [activitiesBaseUrl, intl, loadProject, navigate, resource]);
33+
34+
return <></>;
35+
};
36+
37+
const useMicrobitResourceSearchParams = (): MicrobitOrgResource | undefined => {
38+
const [params] = useSearchParams();
39+
const id = params.get("id");
40+
const project = params.get("project");
41+
const name = params.get("name");
42+
return id && name && project ? { id, project, name } : undefined;
43+
};
44+
45+
const isValidProject = (content: Project): content is Project => {
46+
return (
47+
content &&
48+
typeof content === "object" &&
49+
"text" in content &&
50+
!!content.text
51+
);
52+
};
53+
54+
const fetchMicrobitOrgResourceTargetCode = async (
55+
activitiesBaseUrl: string,
56+
resource: MicrobitOrgResource,
57+
intl: IntlShape
58+
): Promise<Project> => {
59+
const url = `${activitiesBaseUrl}${encodeURIComponent(
60+
resource.id
61+
)}-makecode.json`;
62+
let json;
63+
try {
64+
const response = await fetch(url);
65+
if (!response.ok) {
66+
throw new Error(`Unexpected response ${response.status}`);
67+
}
68+
json = (await response.json()) as object;
69+
} catch (e) {
70+
const rethrow = new Error(
71+
intl.formatMessage({ id: "code-download-error" })
72+
);
73+
rethrow.stack = e instanceof Error ? e.stack : undefined;
74+
throw rethrow;
75+
}
76+
if (
77+
!("editorContent" in json) ||
78+
typeof json.editorContent !== "object" ||
79+
!json.editorContent ||
80+
!isValidProject(json.editorContent)
81+
) {
82+
throw new Error(intl.formatMessage({ id: "code-format-error" }));
83+
}
84+
return json.editorContent;
85+
};
86+
87+
export default ImportPage;

src/store.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
} from "./makecode/utils";
1212
import { trainModel } from "./ml";
1313
import {
14-
DatasetEditorJsonFormat,
1514
DownloadState,
1615
DownloadStep,
1716
Gesture,
@@ -131,6 +130,7 @@ export interface Actions {
131130
downloadDataset(): void;
132131
dataCollectionMicrobitConnected(): void;
133132
loadDataset(gestures: GestureData[]): void;
133+
loadProject(project: Project): void;
134134
setEditorOpen(open: boolean): void;
135135
recordingStarted(): void;
136136
recordingStopped(): void;
@@ -410,6 +410,25 @@ export const useStore = create<Store>()(
410410
});
411411
},
412412

413+
/**
414+
* Generally project loads go via MakeCode as it reads the hex but when we open projects
415+
* from microbit.org we have the JSON already and use this route.
416+
*/
417+
loadProject(project: Project) {
418+
set(() => {
419+
const timestamp = Date.now();
420+
return {
421+
gestures: getGesturesFromProject(project),
422+
model: undefined,
423+
project,
424+
projectEdited: true,
425+
appEditNeedsFlushToEditor: true,
426+
timestamp,
427+
projectLoadTimestamp: timestamp,
428+
};
429+
});
430+
},
431+
413432
closeTrainModelDialogs() {
414433
set({
415434
trainModelDialogStage: TrainModelDialogStage.Closed,
@@ -547,19 +566,14 @@ export const useStore = create<Store>()(
547566
// It's a new project. Thanks user. We'll update our state.
548567
// This will cause another write to MakeCode but that's OK as it gives us
549568
// a chance to validate/update the project
550-
const datasetString = newProject.text?.[filenames.datasetJson];
551-
const dataset = datasetString
552-
? (JSON.parse(datasetString) as DatasetEditorJsonFormat)
553-
: { data: [] };
554-
555569
const timestamp = Date.now();
556570
return {
557571
project: newProject,
558572
projectLoadTimestamp: timestamp,
559573
timestamp,
560574
// New project loaded externally so we can't know whether its edited.
561575
projectEdited: true,
562-
gestures: dataset.data,
576+
gestures: getGesturesFromProject(newProject),
563577
model: undefined,
564578
isEditorOpen: false,
565579
};
@@ -765,3 +779,15 @@ const gestureIcon = ({
765779
}
766780
return useableIcons[0];
767781
};
782+
783+
const getGesturesFromProject = (project: Project): GestureData[] => {
784+
const { text } = project;
785+
if (text === undefined || !("dataset.json" in text)) {
786+
return [];
787+
}
788+
const dataset = JSON.parse(text["dataset.json"]) as object;
789+
if (typeof dataset !== "object" || !("data" in dataset)) {
790+
return [];
791+
}
792+
return dataset.data as GestureData[];
793+
};

src/urls.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export const createHomePageUrl = () => `${basepath}`;
88

99
export const createNewPageUrl = () => `${basepath}new`;
1010

11+
export const createImportPageUrl = () => `${basepath}import`;
12+
1113
export const createDataSamplesPageUrl = () => `${basepath}data-samples`;
1214

1315
export const createTestingModelPageUrl = () => `${basepath}testing-model`;

0 commit comments

Comments
 (0)