diff --git a/src/App.tsx b/src/App.tsx
index a2569fbbf..2a2327a42 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -50,7 +50,7 @@ import ImportPage from "./pages/ImportPage";
import NewPage from "./pages/NewPage";
import TestingModelPage from "./pages/TestingModelPage";
import OpenSharedProjectPage from "./pages/OpenSharedProjectPage";
-import { loadProjectFromStorage, useStore } from "./store";
+import { getAllProjects, loadProjectFromStorage, useStore } from "./store";
import {
createCodePageUrl,
createDataSamplesPageUrl,
@@ -185,6 +185,10 @@ const createRouter = () => {
{
path: createNewPageUrl(),
element: ,
+ loader: () => {
+ const allProjectData = getAllProjects();
+ return defer({ allProjectData });
+ },
},
{ path: createImportPageUrl(), element: },
{
diff --git a/src/e2e/new-page.spec.ts b/src/e2e/new-page.spec.ts
index 99744b6b3..65ed027b5 100644
--- a/src/e2e/new-page.spec.ts
+++ b/src/e2e/new-page.spec.ts
@@ -28,7 +28,7 @@ test.describe("new page", () => {
await newPage.startNewSession();
await dataSamplesPage.navbar.home();
await homePage.getStarted();
- await newPage.expectResumeButtonToShowProjectName("Untitled");
+ // await newPage.expectResumeButtonToShowProjectName("Untitled");
await newPage.resumeSession();
await dataSamplesPage.expectOnPage();
});
diff --git a/src/ml.test.ts b/src/ml.test.ts
index afc5c9b20..8607c1257 100644
--- a/src/ml.test.ts
+++ b/src/ml.test.ts
@@ -19,18 +19,25 @@ import {
import actionDataBadLabels from "./test-fixtures/shake-still-circle-legacy-bad-labels.json";
import actionData from "./test-fixtures/shake-still-circle-data-samples-legacy.json";
import testData from "./test-fixtures/shake-still-circle-legacy-test-data.json";
-import { currentDataWindow, migrateLegacyActionData } from "./project-utils";
+import {
+ currentDataWindow,
+ migrateLegacyActionDataAndAssignNewIds,
+} from "./project-utils";
const fixUpTestData = (data: Partial[]): OldActionData[] => {
data.forEach((action) => (action.icon = "Heart"));
return data as OldActionData[];
};
-const migratedActionData = migrateLegacyActionData(fixUpTestData(actionData));
-const migratedActionDataBadLabels = migrateLegacyActionData(
+const migratedActionData = migrateLegacyActionDataAndAssignNewIds(
+ fixUpTestData(actionData)
+);
+const migratedActionDataBadLabels = migrateLegacyActionDataAndAssignNewIds(
fixUpTestData(actionDataBadLabels)
);
-const migratedTestData = migrateLegacyActionData(fixUpTestData(testData));
+const migratedTestData = migrateLegacyActionDataAndAssignNewIds(
+ fixUpTestData(testData)
+);
let trainingResult: TrainingResult;
beforeAll(async () => {
diff --git a/src/pages/DataSamplesPage.tsx b/src/pages/DataSamplesPage.tsx
index a8dccd9ad..c4b1be6f7 100644
--- a/src/pages/DataSamplesPage.tsx
+++ b/src/pages/DataSamplesPage.tsx
@@ -26,8 +26,13 @@ const DataSamplesPage = () => {
const actions = useStore((s) => s.actions);
const addNewAction = useStore((s) => s.addNewAction);
const model = useStore((s) => s.model);
+ const updateOrCreateProject = useStore((s) => s.updateOrCreateProject);
const [selectedActionIdx, setSelectedActionIdx] = useState(0);
+ useEffect(() => {
+ void updateOrCreateProject();
+ }, [updateOrCreateProject]);
+
const navigate = useNavigate();
const trainModelFlowStart = useStore((s) => s.trainModelFlowStart);
diff --git a/src/pages/NewPage.tsx b/src/pages/NewPage.tsx
index 14ee40549..24e8f1422 100644
--- a/src/pages/NewPage.tsx
+++ b/src/pages/NewPage.tsx
@@ -6,18 +6,23 @@
*/
import {
Box,
+ Card,
+ CardBody,
Container,
+ Grid,
+ GridItem,
Heading,
HStack,
Icon,
+ IconButton,
Stack,
Text,
VStack,
} from "@chakra-ui/react";
-import { ReactNode, useCallback, useRef } from "react";
+import { ReactNode, Suspense, useCallback, useRef, useState } from "react";
import { RiAddLine, RiFolderOpenLine, RiRestartLine } from "react-icons/ri";
import { FormattedMessage, useIntl } from "react-intl";
-import { useNavigate } from "react-router";
+import { Await, useAsyncValue, useLoaderData, useNavigate } from "react-router";
import DefaultPageLayout, {
HomeMenuItem,
HomeToolbarItem,
@@ -27,9 +32,12 @@ import LoadProjectInput, {
} from "../components/LoadProjectInput";
import NewPageChoice from "../components/NewPageChoice";
import { useLogging } from "../logging/logging-hooks";
-import { useStore } from "../store";
+import { loadProjectFromStorage, useStore } from "../store";
import { createDataSamplesPageUrl } from "../urls";
import { useProjectName } from "../hooks/project-hooks";
+import LoadingAnimation from "../components/LoadingAnimation";
+import { ProjectData, ProjectDataWithActions, StoreAction } from "../storage";
+import { DeleteIcon } from "@chakra-ui/icons";
const NewPage = () => {
const existingSessionTimestamp = useStore((s) => s.timestamp);
@@ -37,6 +45,9 @@ const NewPage = () => {
const newSession = useStore((s) => s.newSession);
const navigate = useNavigate();
const logging = useLogging();
+ const { allProjectData } = useLoaderData() as {
+ allProjectData: ProjectData[];
+ };
const handleOpenLastSession = useCallback(() => {
logging.event({
@@ -165,6 +176,11 @@ const NewPage = () => {
+ }>
+
+
+
+
@@ -172,4 +188,108 @@ const NewPage = () => {
);
};
+const ProjectsList = () => {
+ const data = useAsyncValue() as ProjectDataWithActions[];
+ const [projects, setProjects] = useState(data);
+ const deleteProject = useStore((s) => s.deleteProject);
+
+ const handleDeleteProject = useCallback(
+ async (id: string) => {
+ setProjects((prev) => prev.filter((p) => p.id !== id));
+ await deleteProject(id);
+ },
+ [deleteProject]
+ );
+
+ return (
+ <>
+
+ Projects
+
+
+ {projects.map((projectData) => (
+
+
+
+ ))}
+
+ >
+ );
+};
+
+interface ProjectCard {
+ id: string;
+ name: string;
+ actions: StoreAction[];
+ updatedAt: number;
+ onDeleteProject: (id: string) => Promise;
+}
+
+const ProjectCard = ({
+ id,
+ name,
+ actions,
+ updatedAt,
+ onDeleteProject,
+}: ProjectCard) => {
+ const navigate = useNavigate();
+
+ const handleLoadProject = useCallback(
+ async (_e: React.MouseEvent) => {
+ await loadProjectFromStorage(id);
+ navigate(createDataSamplesPageUrl());
+ },
+ [id, navigate]
+ );
+
+ const handleDeleteProject = useCallback(
+ async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ await onDeleteProject(id);
+ },
+ [id, onDeleteProject]
+ );
+
+ return (
+
+ }
+ position="absolute"
+ right={1}
+ top={1}
+ borderRadius="sm"
+ border="none"
+ />
+
+
+
+ {name}
+
+
+ Actions:{" "}
+ {actions.length > 0
+ ? actions.map((a) => a.name).join(", ")
+ : "none"}
+
+ {`Last edited: ${new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "medium",
+ }).format(updatedAt)}`}
+
+ id: {id}
+
+
+
+
+ );
+};
+
export default NewPage;
diff --git a/src/project-utils.ts b/src/project-utils.ts
index 276676aa9..ecfa67af3 100644
--- a/src/project-utils.ts
+++ b/src/project-utils.ts
@@ -67,7 +67,7 @@ export const createUntitledProject = (): MakeCodeProject => ({
),
});
-export const migrateLegacyActionData = (
+export const migrateLegacyActionDataAndAssignNewIds = (
actions: OldActionData[] | ActionData[]
): ActionData[] => {
return actions.map((a) => {
@@ -85,6 +85,15 @@ export const migrateLegacyActionData = (
createdAt: (a as OldActionData).ID,
};
}
- return a as ActionData;
+ // Assign new unique ids to actions and recordings.
+ // This is required if the user imports the same dataset / project twice.
+ return {
+ ...a,
+ id: uuid(),
+ recordings: a.recordings.map((r) => ({
+ ...r,
+ id: uuid(),
+ })),
+ } as ActionData;
});
};
diff --git a/src/storage.ts b/src/storage.ts
index 87492b863..8f58809f3 100644
--- a/src/storage.ts
+++ b/src/storage.ts
@@ -3,16 +3,18 @@ import { DBSchema, IDBPDatabase, IDBPTransaction, openDB } from "idb";
import orderBy from "lodash.orderby";
import { Action, ActionData, RecordingData } from "./model";
import {
- createUntitledProject,
- migrateLegacyActionData,
+ migrateLegacyActionDataAndAssignNewIds,
+ untitledProjectName,
} from "./project-utils";
import { defaultSettings, Settings } from "./settings";
import { prepActionForStorage } from "./storageUtils";
import { v4 as uuid } from "uuid";
+import * as tf from "@tensorflow/tfjs";
const DATABASE_NAME = "ml";
interface PersistedProjectData {
+ id: string;
actions: ActionData[];
project: MakeCodeProject;
projectEdited: boolean;
@@ -33,51 +35,40 @@ enum DatabaseStore {
SETTINGS = "settings",
}
+const oldModelUrl = "indexeddb://micro:bit-ai-creator-model";
+
export class StorageError extends Error {}
const defaultCreatedAt = Date.now();
const defaultProjectId = uuid();
const defaultStoreData: Record<
- | DatabaseStore.PROJECT_DATA
- | DatabaseStore.SETTINGS
- | DatabaseStore.MAKECODE_DATA,
- { key: string; value: ProjectData | Settings | MakeCodeData }
+ DatabaseStore.SETTINGS,
+ { key: string; value: Settings }
> = {
- [DatabaseStore.PROJECT_DATA]: {
- value: {
- id: defaultProjectId,
- createdAt: defaultCreatedAt,
- updatedAt: defaultCreatedAt,
- actionIds: [],
- },
- key: defaultProjectId,
- },
[DatabaseStore.SETTINGS]: {
value: defaultSettings,
key: DatabaseStore.SETTINGS,
},
- [DatabaseStore.MAKECODE_DATA]: {
- value: {
- project: createUntitledProject(),
- projectEdited: false,
- },
- key: defaultProjectId,
- },
};
export interface StoreAction extends Action {
recordingIds: string[];
}
-interface ProjectData {
+export interface ProjectData {
id: string;
+ name: string;
timestamp?: number;
actionIds: string[];
createdAt: number;
updatedAt: number;
}
+export interface ProjectDataWithActions extends ProjectData {
+ actions: StoreAction[];
+}
+
interface Schema extends DBSchema {
[DatabaseStore.PROJECT_DATA]: {
key: string;
@@ -103,7 +94,7 @@ interface Schema extends DBSchema {
export class Database {
dbPromise: Promise>;
- projectId: string = defaultProjectId;
+ projectId: string | undefined;
constructor() {
this.dbPromise = this.initialize();
}
@@ -112,6 +103,13 @@ export class Database {
return openDB(DATABASE_NAME, 1, {
async upgrade(db) {
const localStorageProject = getLocalStorageProject();
+ if (localStorageProject) {
+ const model = await tf.loadLayersModel(oldModelUrl);
+ if (model) {
+ await model.save(defaultProjectId);
+ await tf.io.removeModel(oldModelUrl);
+ }
+ }
for (const store of Object.values(DatabaseStore)) {
const objectStore = db.createObjectStore(store);
if (localStorageProject) {
@@ -150,6 +148,9 @@ export class Database {
createdAt: defaultCreatedAt,
updatedAt: defaultCreatedAt,
actionIds: [],
+ name:
+ localStorageProject.project.header?.name ??
+ untitledProjectName,
},
defaultProjectId
);
@@ -165,12 +166,8 @@ export class Database {
}
continue;
}
- // Set default values if there is are data to migrate.
- if (
- store === DatabaseStore.PROJECT_DATA ||
- store === DatabaseStore.MAKECODE_DATA ||
- store === DatabaseStore.SETTINGS
- ) {
+ // Set default values if there is are no data to migrate.
+ if (store === DatabaseStore.SETTINGS) {
const defaultData = defaultStoreData[store];
await objectStore.add(defaultData.value, defaultData.key);
}
@@ -180,35 +177,30 @@ export class Database {
});
}
+ assertProjectId(): string {
+ if (!this.projectId) {
+ throw new Error("Project id is unexpectedly undefined");
+ }
+ return this.projectId;
+ }
+
async newSession(
makeCodeData: MakeCodeData,
- projectData: Partial
+ projectData: { timestamp: number; name: string; id: string }
): Promise {
- this.projectId = uuid();
+ this.projectId = projectData.id;
const tx = (await this.dbPromise).transaction(
- [
- DatabaseStore.ACTIONS,
- DatabaseStore.RECORDINGS,
- DatabaseStore.MAKECODE_DATA,
- DatabaseStore.PROJECT_DATA,
- ],
+ [DatabaseStore.MAKECODE_DATA, DatabaseStore.PROJECT_DATA],
"readwrite"
);
- const recordingsStore = tx.objectStore(DatabaseStore.RECORDINGS);
- await recordingsStore.clear();
- const actionsStore = tx.objectStore(DatabaseStore.ACTIONS);
- await actionsStore.clear();
const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
- await makeCodeStore.clear();
await makeCodeStore.add(makeCodeData, this.projectId);
const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
- await projectDataStore.clear();
await projectDataStore.add(
{
- id: this.projectId,
actionIds: [],
- createdAt: projectData.timestamp!,
- updatedAt: projectData.timestamp!,
+ createdAt: projectData.timestamp,
+ updatedAt: projectData.timestamp,
...projectData,
},
this.projectId
@@ -217,6 +209,7 @@ export class Database {
}
async loadProject(id: string): Promise {
+ this.projectId = id;
const tx = (await this.dbPromise).transaction(
[
DatabaseStore.ACTIONS,
@@ -228,7 +221,7 @@ export class Database {
"readwrite"
);
const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
- const projectData = assertData(await projectDataStore.get(id));
+ const projectData = assertData(await projectDataStore.get(this.projectId));
// Ensure that this project will be loaded by default during next page load.
await projectDataStore.put(
{ ...projectData, updatedAt: Date.now() },
@@ -262,13 +255,15 @@ export class Database {
})
);
const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
- const makeCodeData = assertData(await makeCodeStore.get(id));
+ const makeCodeData = assertData(await makeCodeStore.get(this.projectId));
const settingsStore = tx.objectStore(DatabaseStore.SETTINGS);
const settings = assertData(
await settingsStore.get(DatabaseStore.SETTINGS)
);
+ makeCodeData.project.header?.name;
await tx.done;
return {
+ id,
actions,
project: makeCodeData.project,
projectEdited: makeCodeData.projectEdited,
@@ -280,10 +275,10 @@ export class Database {
async importProject(
actions: ActionData[],
makeCodeData: MakeCodeData,
- projectData: Partial,
+ projectData: { timestamp?: number; id: string },
settings: Settings
): Promise {
- this.projectId = uuid();
+ this.projectId = projectData.id;
const tx = (await this.dbPromise).transaction(
[
DatabaseStore.ACTIONS,
@@ -295,9 +290,7 @@ export class Database {
"readwrite"
);
const recordingsStore = tx.objectStore(DatabaseStore.RECORDINGS);
- await recordingsStore.clear();
const actionsStore = tx.objectStore(DatabaseStore.ACTIONS);
- await actionsStore.clear();
await Promise.all(
actions
.flatMap((a) => a.recordings)
@@ -307,18 +300,17 @@ export class Database {
actions.map((a) => actionsStore.add(prepActionForStorage(a), a.id))
);
const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
- await makeCodeStore.clear();
await makeCodeStore.add(makeCodeData, this.projectId);
const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
- await projectDataStore.clear();
- const createdAt = Date.now();
+ projectData.timestamp = projectData.timestamp ?? Date.now();
await projectDataStore.add(
{
id: this.projectId,
+ name: makeCodeData.project.header?.name ?? untitledProjectName,
actionIds: actions.map((a) => a.id),
- createdAt,
- updatedAt: createdAt,
- ...projectData,
+ createdAt: projectData.timestamp,
+ updatedAt: projectData.timestamp,
+ timestamp: projectData.timestamp,
},
this.projectId
);
@@ -328,19 +320,47 @@ export class Database {
return tx.done;
}
- async getLatestProjectId(): Promise {
+ async getLatestProjectId(): Promise {
const projectData = await (
await this.dbPromise
).getAll(DatabaseStore.PROJECT_DATA);
+ if (!projectData.length) {
+ this.projectId = undefined;
+ return undefined;
+ }
const latestProjectData = orderBy(projectData, "updatedAt", "desc")[0];
this.projectId = latestProjectData.id;
return this.projectId;
}
+ async getAllProjectData(): Promise {
+ const tx = (await this.dbPromise).transaction(
+ [DatabaseStore.ACTIONS, DatabaseStore.PROJECT_DATA],
+ "readonly"
+ );
+ const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
+ const projectData = orderBy(
+ await projectDataStore.getAll(),
+ "updatedAt",
+ "desc"
+ );
+ const actionsStore = tx.objectStore(DatabaseStore.ACTIONS);
+ const projectDataWithActions = await Promise.all(
+ projectData.map(async (p) => ({
+ ...p,
+ actions: assertDataArray(
+ await Promise.all(p.actionIds.map((id) => actionsStore.get(id)))
+ ),
+ }))
+ );
+ return projectDataWithActions;
+ }
+
async addAction(
action: ActionData,
makeCodeData: MakeCodeData
): Promise {
+ const projectId = this.assertProjectId();
const tx = (await this.dbPromise).transaction(
[
DatabaseStore.ACTIONS,
@@ -353,12 +373,12 @@ export class Database {
const actionsStore = tx.objectStore(DatabaseStore.ACTIONS);
await actionsStore.add(actionToStore, actionToStore.id);
const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
- await makeCodeStore.put(makeCodeData, this.projectId);
+ await makeCodeStore.put(makeCodeData, projectId);
const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
- const projectData = assertData(await projectDataStore.get(this.projectId));
+ const projectData = assertData(await projectDataStore.get(projectId));
projectData.actionIds.push(action.id);
projectData.updatedAt = Date.now();
- await projectDataStore.put(projectData, this.projectId);
+ await projectDataStore.put(projectData, projectId);
return tx.done;
}
@@ -366,6 +386,7 @@ export class Database {
action: ActionData,
makeCodeData: MakeCodeData
): Promise {
+ const projectId = this.assertProjectId();
const tx = (await this.dbPromise).transaction(
[
DatabaseStore.ACTIONS,
@@ -378,9 +399,9 @@ export class Database {
const actionsStore = tx.objectStore(DatabaseStore.ACTIONS);
await actionsStore.put(actionToStore, actionToStore.id);
const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
- await makeCodeStore.put(makeCodeData, this.projectId);
+ await makeCodeStore.put(makeCodeData, projectId);
const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
- const projectData = assertData(await projectDataStore.get(this.projectId));
+ const projectData = assertData(await projectDataStore.get(projectId));
const updatedActionIds = Array.from(
new Set([action.id, ...projectData.actionIds])
);
@@ -390,7 +411,7 @@ export class Database {
actionIds: updatedActionIds,
updatedAt: Date.now(),
},
- this.projectId
+ projectId
);
return tx.done;
}
@@ -399,6 +420,7 @@ export class Database {
actions: ActionData[],
makeCodeData: MakeCodeData
): Promise {
+ const projectId = this.assertProjectId();
const tx = (await this.dbPromise).transaction(
[
DatabaseStore.ACTIONS,
@@ -414,9 +436,9 @@ export class Database {
)
);
const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
- await makeCodeStore.put(makeCodeData, this.projectId);
+ await makeCodeStore.put(makeCodeData, projectId);
const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
- const projectData = assertData(await projectDataStore.get(this.projectId));
+ const projectData = assertData(await projectDataStore.get(projectId));
const updatedActionIds = Array.from(
new Set(...actions.map((a) => a.id), projectData.actionIds)
);
@@ -426,7 +448,7 @@ export class Database {
actionIds: updatedActionIds,
updatedAt: Date.now(),
},
- this.projectId
+ projectId
);
return tx.done;
}
@@ -435,6 +457,7 @@ export class Database {
action: ActionData,
makeCodeData: MakeCodeData
): Promise {
+ const projectId = this.assertProjectId();
const tx = (await this.dbPromise).transaction(
[
DatabaseStore.ACTIONS,
@@ -451,20 +474,21 @@ export class Database {
action.recordings.map((r) => recordingsStore.delete(r.id))
);
const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
- await makeCodeStore.put(makeCodeData, this.projectId);
+ await makeCodeStore.put(makeCodeData, projectId);
const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
- const projectData = assertData(await projectDataStore.get(this.projectId));
+ const projectData = assertData(await projectDataStore.get(projectId));
const updatedActionIds = projectData.actionIds.filter(
(id) => id !== action.id
);
await projectDataStore.put(
{ ...projectData, actionIds: updatedActionIds, updatedAt: Date.now() },
- this.projectId
+ projectId
);
return tx.done;
}
async deleteAllActions(makeCodeData: MakeCodeData): Promise {
+ const projectId = this.assertProjectId();
const tx = (await this.dbPromise).transaction(
[
DatabaseStore.ACTIONS,
@@ -475,7 +499,7 @@ export class Database {
"readwrite"
);
const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
- const projectData = assertData(await projectDataStore.get(this.projectId));
+ const projectData = assertData(await projectDataStore.get(projectId));
const actionsStore = tx.objectStore(DatabaseStore.ACTIONS);
const actions = assertDataArray(
@@ -491,10 +515,10 @@ export class Database {
.map((id) => recordingsStore.delete(id))
);
const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
- await makeCodeStore.put(makeCodeData, this.projectId);
+ await makeCodeStore.put(makeCodeData, projectId);
await projectDataStore.put(
{ ...projectData, actionIds: [], updatedAt: Date.now() },
- this.projectId
+ projectId
);
return tx.done;
}
@@ -504,6 +528,7 @@ export class Database {
action: ActionData,
makeCodeData: MakeCodeData
): Promise {
+ const projectId = this.assertProjectId();
const tx = (await this.dbPromise).transaction(
[
DatabaseStore.ACTIONS,
@@ -519,7 +544,7 @@ export class Database {
const recordingsStore = tx.objectStore(DatabaseStore.RECORDINGS);
await recordingsStore.add(recording, recording.id);
const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
- await makeCodeStore.put(makeCodeData, this.projectId);
+ await makeCodeStore.put(makeCodeData, projectId);
await this.updateProjectInternal(tx);
return tx.done;
}
@@ -529,6 +554,7 @@ export class Database {
action: ActionData,
makeCodeData: MakeCodeData
): Promise {
+ const projectId = this.assertProjectId();
const tx = (await this.dbPromise).transaction(
[
DatabaseStore.ACTIONS,
@@ -544,65 +570,131 @@ export class Database {
const recordingsStore = tx.objectStore(DatabaseStore.RECORDINGS);
await recordingsStore.delete(key);
const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
- await makeCodeStore.put(makeCodeData, this.projectId);
+ await makeCodeStore.put(makeCodeData, projectId);
await this.updateProjectInternal(tx);
return tx.done;
}
async updateMakeCodeProject(makeCodeData: MakeCodeData): Promise {
+ const projectId = this.assertProjectId();
const tx = (await this.dbPromise).transaction(
- [
- DatabaseStore.ACTIONS,
- DatabaseStore.MAKECODE_DATA,
- DatabaseStore.PROJECT_DATA,
- DatabaseStore.RECORDINGS,
- ],
+ [DatabaseStore.MAKECODE_DATA, DatabaseStore.PROJECT_DATA],
"readwrite"
);
const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
- await makeCodeStore.put(makeCodeData, this.projectId);
- await this.updateProjectInternal(tx);
+ await makeCodeStore.put(makeCodeData, projectId);
+ const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
+ const projectData = assertData(await projectDataStore.get(projectId));
+ await projectDataStore.put(
+ {
+ ...projectData,
+ updatedAt: Date.now(),
+ name: makeCodeData.project.header?.name ?? untitledProjectName,
+ },
+ projectId
+ );
return tx.done;
}
// TODO: TypeScript to ensure that a transaction with DatabaseStore.PROJECT_DATA is passed in.
private async updateProjectInternal(
- tx: IDBPTransaction,
- projectUpdates?: Partial
+ tx: IDBPTransaction
): Promise {
+ const projectId = this.assertProjectId();
const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
- const projectData = assertData(await projectDataStore.get(this.projectId));
+ const projectData = assertData(await projectDataStore.get(projectId));
await projectDataStore.put(
{
...projectData,
updatedAt: Date.now(),
- ...projectUpdates,
},
- this.projectId
+ projectId
);
}
- // Currently unused.
- async updateProject(project: Partial): Promise {
+ async updateOrCreateProject(
+ projectData: { timestamp: number; id: string },
+ makeCodeData: MakeCodeData
+ ): Promise {
const tx = (await this.dbPromise).transaction(
- DatabaseStore.PROJECT_DATA,
+ [DatabaseStore.MAKECODE_DATA, DatabaseStore.PROJECT_DATA],
"readwrite"
);
const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
- const storedProjectData = assertData(
- await projectDataStore.get(this.projectId)
- );
+ if (this.projectId === projectData.id) {
+ const projectData = assertData(
+ await projectDataStore.get(this.projectId)
+ );
+ await projectDataStore.put(
+ {
+ ...projectData,
+ updatedAt: Date.now(),
+ },
+ this.projectId
+ );
+ return tx.done;
+ }
+ this.projectId = projectData.id;
await projectDataStore.put(
{
- ...storedProjectData,
- ...project,
- updatedAt: Date.now(),
+ name: makeCodeData.project.header?.name ?? untitledProjectName,
+ actionIds: [],
+ createdAt: projectData.timestamp,
+ updatedAt: projectData.timestamp,
+ ...projectData,
},
this.projectId
);
+ const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
+ await makeCodeStore.put(makeCodeData, this.projectId);
return tx.done;
}
+ async deleteProject(id: string): Promise {
+ const tx = (await this.dbPromise).transaction(
+ [
+ DatabaseStore.ACTIONS,
+ DatabaseStore.RECORDINGS,
+ DatabaseStore.MAKECODE_DATA,
+ DatabaseStore.PROJECT_DATA,
+ ],
+ "readwrite"
+ );
+ const projectDataStore = tx.objectStore(DatabaseStore.PROJECT_DATA);
+ const projectData = assertData(await projectDataStore.get(id));
+ const actionsStore = tx.objectStore(DatabaseStore.ACTIONS);
+ const actions = assertDataArray(
+ await Promise.all(projectData.actionIds.map((id) => actionsStore.get(id)))
+ );
+ await Promise.all(
+ projectData.actionIds.map((id) => actionsStore.delete(id))
+ );
+ const recordingsStore = tx.objectStore(DatabaseStore.RECORDINGS);
+ await Promise.all(
+ actions
+ .flatMap((a) => a.recordingIds)
+ .map((id) => recordingsStore.delete(id))
+ );
+ const makeCodeStore = tx.objectStore(DatabaseStore.MAKECODE_DATA);
+ await makeCodeStore.delete(id);
+ await projectDataStore.delete(id);
+ await tx.done;
+ if (this.projectId === id) {
+ // You've just deleted the project you had loaded, load the next available project.
+ const latestProjectId = await this.getLatestProjectId();
+ if (!latestProjectId) {
+ // There are no other projects in storage. Clear state.
+ this.projectId = undefined;
+ return true;
+ }
+ this.projectId = latestProjectId;
+ const latestProject = await this.loadProject(latestProjectId);
+ return latestProject;
+ }
+ // Do nothing. You've deleted a project that isn't loaded.
+ return false;
+ }
+
async updateSettings(settings: Settings): Promise {
return (await this.dbPromise).put(
DatabaseStore.SETTINGS,
@@ -610,6 +702,22 @@ export class Database {
DatabaseStore.SETTINGS
);
}
+
+ async saveModel(model: tf.LayersModel) {
+ const projectId = this.assertProjectId();
+ await model.save(`indexeddb://${projectId}`);
+ }
+
+ async removeModel() {
+ console.trace("when");
+ const projectId = this.assertProjectId();
+ await tf.io.removeModel(`indexeddb://${projectId}`);
+ }
+
+ async loadModel(): Promise {
+ const projectId = this.assertProjectId();
+ return tf.loadLayersModel(`indexeddb://${projectId}`);
+ }
}
const assertData = (data: T) => {
@@ -636,6 +744,8 @@ export const getLocalStorageProject = (): PersistedProjectData | undefined => {
const dataToMigrate = JSON.parse(data) as { state: PersistedProjectData };
return {
...dataToMigrate.state,
- actions: migrateLegacyActionData(dataToMigrate.state.actions),
+ actions: migrateLegacyActionDataAndAssignNewIds(
+ dataToMigrate.state.actions
+ ),
};
};
diff --git a/src/store.ts b/src/store.ts
index 5c75c1ac8..dcd450ce3 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -46,7 +46,7 @@ import {
currentDataWindow,
DataWindow,
legacyDataWindow,
- migrateLegacyActionData,
+ migrateLegacyActionDataAndAssignNewIds,
untitledProjectName,
} from "./project-utils";
import { defaultSettings, Settings } from "./settings";
@@ -65,8 +65,6 @@ const broadcastChannel = new BroadcastChannel("ml");
const storage = new Database();
-export const modelUrl = "indexeddb://micro:bit-ai-creator-model";
-
const createFirstAction = (): ActionData => ({
icon: defaultIcons[0],
id: uuid(),
@@ -110,6 +108,7 @@ const updateProject = (
};
export interface State {
+ id: string | undefined;
actions: ActionData[];
dataWindow: DataWindow;
model: tf.LayersModel | undefined;
@@ -190,6 +189,8 @@ export interface ConnectOptions {
export interface Actions {
loadProjectFromStorage(id: string): Promise;
+ updateOrCreateProject(): Promise;
+ deleteProject(id: string): Promise;
addNewAction(): Promise;
addActionRecording(id: string, recording: RecordingData): Promise;
deleteAction(action: ActionData): Promise;
@@ -281,6 +282,7 @@ const createMlStore = (logging: Logging) => {
return create()(
devtools(
(set, get) => ({
+ id: undefined,
timestamp: undefined,
actions: [],
dataWindow: currentDataWindow,
@@ -380,8 +382,10 @@ const createMlStore = (logging: Logging) => {
const newProject = projectName
? renameProject(untitledProject, projectName)
: untitledProject;
+ const id = uuid();
set(
{
+ id,
actions: [],
dataWindow: currentDataWindow,
model: undefined,
@@ -399,7 +403,7 @@ const createMlStore = (logging: Logging) => {
project: newProject,
projectEdited,
},
- { timestamp }
+ { timestamp, name: projectName ?? untitledProjectName, id }
)
);
},
@@ -426,7 +430,9 @@ const createMlStore = (logging: Logging) => {
},
async loadProjectFromStorage(id: string) {
- const persistedData = await storage.loadProject(id);
+ const persistedData = await storageWithErrHandling(() =>
+ storage.loadProject(id)
+ );
set({
// Get data window from actions on app load.
dataWindow: getDataWindowFromActions(persistedData.actions),
@@ -434,6 +440,51 @@ const createMlStore = (logging: Logging) => {
});
},
+ async updateOrCreateProject() {
+ const { id, project, projectEdited, timestamp } = get();
+ const existingOrNewTimestamp = timestamp ?? Date.now();
+ const existingOrNewId = id ?? uuid();
+ set({
+ id: existingOrNewId,
+ timestamp: existingOrNewTimestamp,
+ });
+ await storageWithErrHandling(() =>
+ storage.updateOrCreateProject(
+ {
+ timestamp: existingOrNewTimestamp,
+ id: existingOrNewId,
+ },
+ { project, projectEdited }
+ )
+ );
+ },
+
+ async deleteProject(id) {
+ const result = await storageWithErrHandling(() =>
+ storage.deleteProject(id)
+ );
+ if (typeof result === "boolean") {
+ if (result) {
+ // Clear state. No projects remaining in storage.
+ const untitledProject = createUntitledProject();
+ set({
+ id: undefined,
+ actions: [],
+ dataWindow: currentDataWindow,
+ model: undefined,
+ project: untitledProject,
+ projectEdited: false,
+ appEditNeedsFlushToEditor: true,
+ timestamp: undefined,
+ });
+ }
+ // No action required. Deleted a project that isn't currently loaded.
+ return;
+ }
+ // Deleted a project that was loaded. Load the next most recent project.
+ set(result);
+ },
+
async addNewAction() {
const { actions, dataWindow, project, projectEdited } = get();
const newAction: ActionData = {
@@ -713,7 +764,8 @@ const createMlStore = (logging: Logging) => {
new Set([...settings.toursCompleted, "DataSamplesRecorded"])
),
};
- const newActionsWithIcons = migrateLegacyActionData(newActions);
+ const newActionsWithIcons =
+ migrateLegacyActionDataAndAssignNewIds(newActions);
// Older datasets did not have icons. Add icons to actions where these are missing.
newActionsWithIcons.forEach((a) => {
if (!a.icon) {
@@ -723,6 +775,7 @@ const createMlStore = (logging: Logging) => {
});
}
});
+ const id = uuid();
const timestamp = Date.now();
const dataWindow = getDataWindowFromActions(newActionsWithIcons);
const updatedProject = updateProject(
@@ -733,6 +786,7 @@ const createMlStore = (logging: Logging) => {
dataWindow
);
set({
+ id,
settings: updatedSettings,
actions: newActionsWithIcons,
dataWindow,
@@ -749,6 +803,7 @@ const createMlStore = (logging: Logging) => {
},
{
timestamp,
+ id,
},
updatedSettings
)
@@ -768,6 +823,7 @@ const createMlStore = (logging: Logging) => {
),
};
const newActions = getActionsFromProject(project);
+ const id = uuid();
const timestamp = Date.now();
const projectEdited = true;
set(({ project: prevProject }) => {
@@ -781,6 +837,7 @@ const createMlStore = (logging: Logging) => {
},
};
return {
+ id,
settings: updatedSettings,
actions: newActions,
dataWindow: getDataWindowFromActions(newActions),
@@ -796,7 +853,7 @@ const createMlStore = (logging: Logging) => {
storage.importProject(
newActions,
{ project, projectEdited },
- { timestamp },
+ { timestamp, id },
updatedSettings
)
);
@@ -1011,6 +1068,7 @@ const createMlStore = (logging: Logging) => {
let newActions: ActionData[] | undefined;
let importProject = false;
let updateMakeCodeProject = false;
+ const id = uuid();
set(
(state) => {
const {
@@ -1048,6 +1106,7 @@ const createMlStore = (logging: Logging) => {
updatedProjectEdited = true;
importProject = true;
return {
+ id,
settings: updatedSettings,
project: newProject,
projectLoadTimestamp: updatedTimestamp,
@@ -1091,7 +1150,7 @@ const createMlStore = (logging: Logging) => {
project: newProject,
projectEdited: updatedProjectEdited,
},
- { timestamp: updatedTimestamp },
+ { timestamp: updatedTimestamp, id },
updatedSettings
)
);
@@ -1428,7 +1487,7 @@ const getDataWindowFromActions = (actions: ActionData[]): DataWindow => {
const loadModelFromStorage = async () => {
try {
- const model = await tf.loadLayersModel(modelUrl);
+ const model = await storage.loadModel();
if (model) {
useStore.setState({ model }, false, "loadModel");
}
@@ -1438,19 +1497,19 @@ const loadModelFromStorage = async () => {
};
useStore.subscribe(async (state, prevState) => {
- const { model: newModel } = state;
- const { model: previousModel } = prevState;
+ const { model: newModel, id: newId } = state;
+ const { model: previousModel, id: prevId } = prevState;
if (newModel !== previousModel) {
- if (!newModel) {
+ if (!newModel && newId === prevId) {
try {
- await tf.io.removeModel(modelUrl);
+ await storage.removeModel();
broadcastChannel.postMessage(BroadcastChannelMessages.REMOVE_MODEL);
} catch (err) {
// IndexedDB not available?
}
- } else {
+ } else if (newModel) {
try {
- await newModel.save(modelUrl);
+ await storage.saveModel(newModel);
} catch (err) {
// IndexedDB not available?
}
@@ -1529,7 +1588,7 @@ const getActionsFromProject = (project: MakeCodeProject): ActionData[] => {
if (typeof dataset !== "object" || !("data" in dataset)) {
return [];
}
- return migrateLegacyActionData(
+ return migrateLegacyActionDataAndAssignNewIds(
dataset.data as OldActionData[] | ActionData[]
);
};
@@ -1559,8 +1618,9 @@ const renameProject = (
const storageWithErrHandling = async (callback: () => Promise) => {
try {
- await callback();
+ const value = await callback();
broadcastChannel.postMessage(BroadcastChannelMessages.RELOAD_PROJECT);
+ return value;
} catch (err) {
if (err instanceof DOMException && err.name === "QuotaExceededError") {
console.error("Storage quota exceeded!", err);
@@ -1570,19 +1630,28 @@ const storageWithErrHandling = async (callback: () => Promise) => {
} else {
console.error(err);
}
+ // Throw for now to improve typing.
+ throw err;
// We can in theory set error state here with useStore.setState.
}
};
-export const loadProjectFromStorage = async () => {
- // When multiple projects are supported, the latest project should only be
- // fetched when not on the homepage / projects page.
- const lastestProjectId = await storage.getLatestProjectId();
- await useStore.getState().loadProjectFromStorage(lastestProjectId);
+export const loadProjectFromStorage = async (id?: string) => {
+ if (!id) {
+ id = await storageWithErrHandling(() => storage.getLatestProjectId());
+ }
+ if (!id) {
+ return true;
+ }
+ await useStore.getState().loadProjectFromStorage(id);
await loadModelFromStorage();
return true;
};
+export const getAllProjects = async () => {
+ return storageWithErrHandling(() => storage.getAllProjectData());
+};
+
broadcastChannel.onmessage = async (event) => {
switch (event.data) {
case BroadcastChannelMessages.RELOAD_PROJECT: {