diff --git a/package-lock.json b/package-lock.json
index b958b1b96..1c5c04e80 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,6 +35,9 @@
"react-intl": "^6.6.8",
"react-router": "^6.24.0",
"react-router-dom": "^6.24.0",
+ "y-indexeddb": "^9.0.12",
+ "y-protocols": "^1.0.6",
+ "yjs": "^13.6.27",
"zustand": "^4.5.5"
},
"devDependencies": {
@@ -8496,6 +8499,16 @@
"is-stream": "^1.0.1"
}
},
+ "node_modules/isomorphic.js": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
+ "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
+ "license": "MIT",
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/iterator.prototype": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz",
@@ -9223,6 +9236,27 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lib0": {
+ "version": "0.2.114",
+ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz",
+ "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==",
+ "license": "MIT",
+ "dependencies": {
+ "isomorphic.js": "^0.2.4"
+ },
+ "bin": {
+ "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
+ "0gentesthtml": "bin/gentesthtml.js",
+ "0serve": "bin/0serve.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -13149,6 +13183,46 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true
},
+ "node_modules/y-indexeddb": {
+ "version": "9.0.12",
+ "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz",
+ "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==",
+ "license": "MIT",
+ "dependencies": {
+ "lib0": "^0.2.74"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ },
+ "peerDependencies": {
+ "yjs": "^13.0.0"
+ }
+ },
+ "node_modules/y-protocols": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz",
+ "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "lib0": "^0.2.85"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ },
+ "peerDependencies": {
+ "yjs": "^13.0.0"
+ }
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -13188,6 +13262,23 @@
"node": ">=10"
}
},
+ "node_modules/yjs": {
+ "version": "13.6.27",
+ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
+ "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==",
+ "license": "MIT",
+ "dependencies": {
+ "lib0": "^0.2.99"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index acfc2076d..4ce4015f7 100644
--- a/package.json
+++ b/package.json
@@ -81,6 +81,9 @@
"react-intl": "^6.6.8",
"react-router": "^6.24.0",
"react-router-dom": "^6.24.0",
+ "y-indexeddb": "^9.0.12",
+ "y-protocols": "^1.0.6",
+ "yjs": "^13.6.27",
"zustand": "^4.5.5"
}
}
diff --git a/src/App.tsx b/src/App.tsx
index 838020655..aad8011ec 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -57,6 +57,7 @@ import {
createNewPageUrl,
createTestingModelPageUrl,
} from "./urls";
+import { ProjectStorageProvider } from "./project-persistence/ProjectStorageProvider";
export interface ProviderLayoutProps {
children: ReactNode;
@@ -93,7 +94,9 @@ const Providers = ({ children }: ProviderLayoutProps) => {
- {children}
+
+ {children}
+
diff --git a/src/components/DataSamplesMenu.tsx b/src/components/DataSamplesMenu.tsx
index 92a1a5d74..6ba3aed05 100644
--- a/src/components/DataSamplesMenu.tsx
+++ b/src/components/DataSamplesMenu.tsx
@@ -30,11 +30,12 @@ import LoadProjectMenuItem from "./LoadProjectMenuItem";
import { NameProjectDialog } from "./NameProjectDialog";
import ViewDataFeaturesMenuItem from "./ViewDataFeaturesMenuItem";
import { useProjectIsUntitled } from "../hooks/project-hooks";
+import { useActions } from "../store-persistence-hooks";
const DataSamplesMenu = () => {
const intl = useIntl();
const logging = useLogging();
- const actions = useStore((s) => s.actions);
+ const actions = useActions();
const downloadDataset = useStore((s) => s.downloadDataset);
const isDeleteAllActionsDialogOpen = useStore(
(s) => s.isDeleteAllActionsDialogOpen
diff --git a/src/components/DataSamplesTable.tsx b/src/components/DataSamplesTable.tsx
index 1f6d7ffcf..03e2a8821 100644
--- a/src/components/DataSamplesTable.tsx
+++ b/src/components/DataSamplesTable.tsx
@@ -40,6 +40,7 @@ import { ConfirmDialog } from "./ConfirmDialog";
import { actionNameInputId } from "./ActionNameCard";
import { recordButtonId } from "./ActionDataSamplesCard";
import { keyboardShortcuts, useShortcut } from "../keyboard-shortcut-hooks";
+import { useActions } from "../store-persistence-hooks";
const gridCommonProps: Partial = {
gridTemplateColumns: "290px 1fr",
@@ -74,7 +75,7 @@ const DataSamplesTable = ({
selectedActionIdx: selectedActionIdx,
setSelectedActionIdx: setSelectedActionIdx,
}: DataSamplesTableProps) => {
- const actions = useStore((s) => s.actions);
+ const actions = useActions().toJSON() as ActionData[];
// Default to first action being selected if last action is deleted.
const selectedAction: ActionData = actions[selectedActionIdx] ?? actions[0];
diff --git a/src/components/DownloadDialogs.tsx b/src/components/DownloadDialogs.tsx
index 55f74cac6..34750db50 100644
--- a/src/components/DownloadDialogs.tsx
+++ b/src/components/DownloadDialogs.tsx
@@ -7,6 +7,7 @@ import { useDownloadActions } from "../hooks/download-hooks";
import { useLogging } from "../logging/logging-hooks";
import { DownloadStep } from "../model";
import { useStore } from "../store";
+import { useActions } from "../store-persistence-hooks";
import { getTotalNumSamples } from "../utils/actions";
import ConnectCableDialog from "./ConnectCableDialog";
import ConnectRadioDataCollectionMicrobitDialog from "./ConnectRadioDataCollectionMicrobitDialog";
@@ -22,7 +23,7 @@ const DownloadDialogs = () => {
const downloadActions = useDownloadActions();
const stage = useStore((s) => s.download);
const flashingProgress = useStore((s) => s.downloadFlashingProgress);
- const actions = useStore((s) => s.actions);
+ const actions = useActions();
const logging = useLogging();
switch (stage.step) {
diff --git a/src/components/TestingModelTable.tsx b/src/components/TestingModelTable.tsx
index a6c77a459..da0d4384e 100644
--- a/src/components/TestingModelTable.tsx
+++ b/src/components/TestingModelTable.tsx
@@ -27,6 +27,8 @@ import ActionNameCard, { ActionCardNameViewMode } from "./ActionNameCard";
import CodeViewCard from "./CodeViewCard";
import CodeViewDefaultBlockCard from "./CodeViewDefaultBlockCard";
import HeadingGrid from "./HeadingGrid";
+import { useActions } from "../store-persistence-hooks";
+import { ActionData } from "../model";
const gridCommonProps: Partial = {
gridTemplateColumns: "290px 360px 40px auto",
@@ -52,7 +54,7 @@ const headings = [
];
const TestingModelTable = () => {
- const actions = useStore((s) => s.actions);
+ const actions = useActions().toJSON() as ActionData[];
const setRequiredConfidence = useStore((s) => s.setRequiredConfidence);
const { project, projectEdited } = useProject();
const { isConnected } = useConnectionStage();
diff --git a/src/hooks/project-hooks.tsx b/src/hooks/project-hooks.tsx
index 1108951d8..6abc3652d 100644
--- a/src/hooks/project-hooks.tsx
+++ b/src/hooks/project-hooks.tsx
@@ -42,6 +42,7 @@ import {
readFileAsText,
} from "../utils/fs-util";
import { useDownloadActions } from "./download-hooks";
+import { useActions } from "../store-persistence-hooks";
class CodeEditorError extends Error {}
@@ -361,7 +362,7 @@ export const ProjectProvider = ({
const setSave = useStore((s) => s.setSave);
const save = useStore((s) => s.save);
const settings = useStore((s) => s.settings);
- const actions = useStore((s) => s.actions);
+ const actions = useActions();
const saveNextDownloadRef = useRef(false);
const translatedUntitled = useDefaultProjectName();
const saveHex = useCallback(
diff --git a/src/model.ts b/src/model.ts
index 3e05c4828..51f42636b 100644
--- a/src/model.ts
+++ b/src/model.ts
@@ -9,6 +9,7 @@ import { MakeCodeIcon } from "./utils/icons";
import { ReactNode } from "react";
import { SpotlightStyle } from "./pages/TourOverlay";
import { PlacementWithLogical, ThemingProps } from "@chakra-ui/react";
+import * as Y from "yjs";
export interface XYZData {
x: number[];
@@ -32,6 +33,13 @@ export interface ActionData extends Action {
recordings: RecordingData[];
}
+// TODO: how much do we hate these types?
+// TODO: maybe use zod?
+export type RecordingDatumY = Y.Map;
+export type RecordingDataY = Y.Array;
+export type ActionDatumY = Y.Map;
+export type ActionDataY = Y.Array;
+
export interface DatasetEditorJsonFormat {
data: ActionData[];
}
diff --git a/src/pages/DataSamplesPage.tsx b/src/pages/DataSamplesPage.tsx
index 584aa3221..a8d7f1137 100644
--- a/src/pages/DataSamplesPage.tsx
+++ b/src/pages/DataSamplesPage.tsx
@@ -18,12 +18,14 @@ import LiveGraphPanel from "../components/LiveGraphPanel";
import TrainModelDialogs from "../components/TrainModelFlowDialogs";
import { useConnectionStage } from "../connection-stage-hooks";
import { keyboardShortcuts, useShortcut } from "../keyboard-shortcut-hooks";
-import { useHasSufficientDataForTraining, useStore } from "../store";
+import { useStore } from "../store";
import { tourElClassname } from "../tours";
import { createTestingModelPageUrl } from "../urls";
+import { forSomeAction, hasSufficientDataForTraining } from "../utils/actions";
+import { useActions } from "../store-persistence-hooks";
const DataSamplesPage = () => {
- const actions = useStore((s) => s.actions);
+ const actions = useActions();
const addNewAction = useStore((s) => s.addNewAction);
const model = useStore((s) => s.model);
const [selectedActionIdx, setSelectedActionIdx] = useState(0);
@@ -40,8 +42,11 @@ const DataSamplesPage = () => {
}
}, [isConnected, tourStart]);
- const hasSufficientData = useHasSufficientDataForTraining();
- const isAddNewActionDisabled = actions.some((a) => a.name.length === 0);
+ const hasSufficientData = hasSufficientDataForTraining(actions);
+ const isAddNewActionDisabled = forSomeAction(
+ actions,
+ (a) => (a.get("name") as string).length === 0
+ );
const handleNavigateToModel = useCallback(() => {
navigate(createTestingModelPageUrl());
diff --git a/src/pages/NewPage.tsx b/src/pages/NewPage.tsx
index 5c66d2bf7..1d406f0cb 100644
--- a/src/pages/NewPage.tsx
+++ b/src/pages/NewPage.tsx
@@ -5,17 +5,16 @@
* SPDX-License-Identifier: MIT
*/
import {
- Box,
Container,
+ Grid,
Heading,
HStack,
Icon,
- Stack,
Text,
VStack,
} from "@chakra-ui/react";
-import { ReactNode, useCallback, useRef } from "react";
-import { RiAddLine, RiFolderOpenLine, RiRestartLine } from "react-icons/ri";
+import { useCallback, useRef, useState } from "react";
+import { RiAddLine, RiFolderOpenLine } from "react-icons/ri";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate } from "react-router";
import DefaultPageLayout, {
@@ -29,39 +28,65 @@ import NewPageChoice from "../components/NewPageChoice";
import { useLogging } from "../logging/logging-hooks";
import { useStore } from "../store";
import { createDataSamplesPageUrl } from "../urls";
-import { useProjectName } from "../hooks/project-hooks";
+import { useStoreProjects } from "../store-persistence-hooks";
+import { ProjectItem } from "../project-persistence/ProjectItem";
+import ProjectHistoryModal from "../project-persistence/ProjectHistoryModal";
+import { ProjectEntry } from "../project-persistence/project-list-db";
+import RenameProjectModal from "../project-persistence/RenameProjectModal";
+import { useProjectList } from "../project-persistence/project-list-hooks";
+import { useProjectHistory } from "../project-persistence/project-history-hooks";
const NewPage = () => {
- const existingSessionTimestamp = useStore((s) => s.timestamp);
- const projectName = useProjectName();
const newSession = useStore((s) => s.newSession);
const navigate = useNavigate();
const logging = useLogging();
+ const { loadProject, newProject } = useStoreProjects();
+ const [showProjectHistory, setShowProjectHistory] =
+ useState(null);
+ const [showProjectRename, setShowProjectRename] =
+ useState(null);
- const handleOpenLastSession = useCallback(() => {
- logging.event({
- type: "session-open-last",
- });
- navigate(createDataSamplesPageUrl());
- }, [logging, navigate]);
+ const { projectList, deleteProject, setProjectName } = useProjectList();
+ const { loadRevision } = useProjectHistory();
+
+ const handleOpenSession = useCallback(
+ async (projectId: string) => {
+ logging.event({
+ type: "session-open-saved",
+ });
+ await loadProject(projectId);
+ navigate(createDataSamplesPageUrl());
+ },
+ [logging, navigate, loadProject]
+ );
+
+ const handleOpenRevision = useCallback(
+ async (projectId: string, revisionId: string) => {
+ logging.event({
+ type: "session-open-revision",
+ });
+
+ await loadRevision(projectId, revisionId);
+ navigate(createDataSamplesPageUrl());
+ },
+ [logging, navigate, loadRevision]
+ );
const loadProjectRef = useRef(null);
const handleContinueSessionFromFile = useCallback(() => {
loadProjectRef.current?.chooseFile();
}, []);
- const handleStartNewSession = useCallback(() => {
+ const handleStartNewSession = useCallback(async () => {
logging.event({
type: "session-open-new",
});
+ await newProject();
newSession();
navigate(createDataSamplesPageUrl());
- }, [logging, newSession, navigate]);
+ }, [logging, newSession, navigate, newProject]);
const intl = useIntl();
- const lastSessionTitle = intl.formatMessage({
- id: "newpage-last-session-title",
- });
const continueSessionTitle = intl.formatMessage({
id: "newpage-continue-session-title",
});
@@ -92,47 +117,14 @@ const NewPage = () => {
flexDir={{ base: "column", lg: "row" }}
>
}
+ onClick={handleStartNewSession}
+ label={newSessionTitle}
+ disabled={false}
+ icon={}
>
- {existingSessionTimestamp ? (
-
-
- (
-
- {chunks}
-
- ),
- name: projectName,
- }}
- />
-
-
- (
-
- {chunks}
-
- ),
- date: new Intl.DateTimeFormat(undefined, {
- dateStyle: "medium",
- }).format(existingSessionTimestamp),
- }}
- />
-
-
- ) : (
-
-
-
- )}
+
+
+
{
+
-
+ Your projects
-
- }
- >
-
-
-
-
-
-
+ {projectList?.map((proj) => (
+ {
+ void handleOpenSession(proj.id);
+ }}
+ deleteProject={deleteProject}
+ renameProject={() => setShowProjectRename(proj)}
+ showHistory={() => setShowProjectHistory(proj)}
+ />
+ ))}
+
+ setShowProjectHistory(null)}
+ projectInfo={showProjectHistory}
+ />
+ setShowProjectRename(null)}
+ projectInfo={showProjectRename}
+ handleRename={async (projectId, projectName) => {
+ await setProjectName(projectId, projectName);
+ setShowProjectRename(null);
+ }}
+ />
);
};
diff --git a/src/project-persistence/ProjectHistoryModal.tsx b/src/project-persistence/ProjectHistoryModal.tsx
new file mode 100644
index 000000000..be6a397ee
--- /dev/null
+++ b/src/project-persistence/ProjectHistoryModal.tsx
@@ -0,0 +1,87 @@
+import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, VStack, List, ListItem, Heading, Button, ModalFooter } from "@chakra-ui/react";
+import { HistoryList } from "./project-history-db";
+import { ProjectEntry } from "./project-list-db";
+import { useCallback, useEffect, useState } from "react";
+import { significantDateUnits } from "./utils";
+import { useProjectHistory } from "./project-history-hooks";
+
+interface ProjectHistoryModalProps {
+ onLoadRequest: (projectId: string, revisionId: string) => void;
+ isOpen: boolean;
+ onDismiss: () => void;
+ projectInfo: ProjectEntry | null;
+}
+
+const ProjectHistoryModal = ({
+ onLoadRequest,
+ isOpen,
+ onDismiss,
+ projectInfo,
+}: ProjectHistoryModalProps) => {
+ const [projectHistoryList, setProjectHistoryList] =
+ useState(null);
+ const { getHistory, saveRevision } = useProjectHistory();
+
+ const getProjectHistory = useCallback(async () => {
+ if (projectInfo === null) {
+ setProjectHistoryList(null);
+ return;
+ }
+ const historyList = await getHistory(projectInfo.id);
+ setProjectHistoryList(historyList.sort((h) => -h.timestamp));
+ }, [getHistory, projectInfo]);
+
+ useEffect(() => {
+ void getProjectHistory();
+ }, [projectInfo, getProjectHistory]);
+
+ return (
+
+
+
+ Project history
+
+
+ {projectInfo && (
+
+ {projectInfo.projectName}
+
+
+ Latest
+
+
+ {projectHistoryList?.map((ph) => (
+
+
+ Saved on {significantDateUnits(new Date(ph.timestamp))}
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+ );
+};
+
+export default ProjectHistoryModal;
\ No newline at end of file
diff --git a/src/project-persistence/ProjectItem.tsx b/src/project-persistence/ProjectItem.tsx
new file mode 100644
index 000000000..c1a4be901
--- /dev/null
+++ b/src/project-persistence/ProjectItem.tsx
@@ -0,0 +1,114 @@
+import {
+ CloseButton,
+ GridItem,
+ Heading,
+ HStack,
+ IconButton,
+ Text,
+} from "@chakra-ui/react";
+import { ReactNode } from "react";
+import { ProjectEntry } from "./project-list-db";
+import { timeAgo } from "./utils";
+import { RiEditFill, RiHistoryFill } from "react-icons/ri";
+
+interface ProjectItemProps {
+ project: ProjectEntry;
+ showHistory: (projectId: string) => void;
+ loadProject: (projectId: string) => void;
+ deleteProject: (projectId: string) => void;
+ renameProject: (projectId: string) => void;
+}
+
+interface ProjectItemBaseProps {
+ children: ReactNode;
+ onClick: () => void;
+}
+
+const ProjectItemBase = ({ onClick, children }: ProjectItemBaseProps) => (
+
+ {children}
+
+);
+
+export const ProjectItem = ({
+ project,
+ loadProject,
+ deleteProject,
+ renameProject,
+ showHistory,
+}: ProjectItemProps) => (
+ loadProject(project.id)}>
+
+
+ {project.projectName}
+
+
+ {timeAgo(new Date(project.modifiedDate))}
+
+ }
+ mr="2"
+ onClick={(e) => {
+ showHistory(project.id);
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ size="lg"
+ title="Project history"
+ variant="outline"
+ />
+ }
+ onClick={(e) => {
+ renameProject(project.id);
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ size="lg"
+ title="Rename"
+ variant="outline"
+ />
+ {
+ deleteProject(project.id);
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ />
+
+);
+
+interface AddProjectItemProps {
+ newProject: () => void;
+}
+
+export const AddProjectItem = ({ newProject }: AddProjectItemProps) => (
+
+
+ New project
+
+ Click to create
+
+);
diff --git a/src/project-persistence/ProjectStorageProvider.tsx b/src/project-persistence/ProjectStorageProvider.tsx
new file mode 100644
index 000000000..5a2e3b8da
--- /dev/null
+++ b/src/project-persistence/ProjectStorageProvider.tsx
@@ -0,0 +1,69 @@
+// ProjectContext.tsx
+import React, { createContext, useCallback, useContext, useState } from "react";
+import { ProjectList } from "./project-list-db";
+import { ProjectStore } from "./project-store";
+
+interface ProjectContextValue {
+ projectId: string | null;
+ projectList: ProjectList | null;
+ setProjectList: (projectList: ProjectList) => void;
+ projectStore: ProjectStore | null;
+ setProjectStore: (projectStore: ProjectStore) => void;
+}
+
+export const ProjectStorageContext = createContext(
+ null
+);
+
+/**
+ * The ProjectStorageProvider is intended to be used only through the hooks in
+ *
+ * - project-list-hooks.ts: information about hooks that does not require an open project
+ * - persistent-project-hooks.ts: manages a currently open project
+ * - project-history-hooks.ts: manages project history and revisions
+ *
+ * This structure is helpful for working out what parts of project persistence are used
+ * where.
+ */
+export function ProjectStorageProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [projectList, setProjectList] = useState(null);
+ const [projectStore, setProjectStoreImpl] = useState(
+ null
+ );
+ const setProjectStore = useCallback(
+ (newProjectStore: ProjectStore) => {
+ if (projectStore) {
+ projectStore.destroy();
+ }
+ setProjectStoreImpl(newProjectStore);
+ },
+ [projectStore]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useProjectStorage() {
+ const ctx = useContext(ProjectStorageContext);
+ if (!ctx)
+ throw new Error(
+ "useProjectStorage must be used within a ProjectStorageProvider"
+ );
+ return ctx;
+}
diff --git a/src/project-persistence/RenameProjectModal.tsx b/src/project-persistence/RenameProjectModal.tsx
new file mode 100644
index 000000000..003752329
--- /dev/null
+++ b/src/project-persistence/RenameProjectModal.tsx
@@ -0,0 +1,61 @@
+import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, VStack, Button, ModalFooter, Input } from "@chakra-ui/react";
+import { ProjectEntry } from "./project-list-db";
+import { useEffect, useState } from "react";
+
+interface ProjectHistoryModalProps {
+ handleRename: (projectId: string, projectName: string) => void;
+ isOpen: boolean;
+ onDismiss: () => void;
+ projectInfo: ProjectEntry | null;
+}
+
+const RenameProjectModal = ({
+ handleRename,
+ isOpen,
+ onDismiss,
+ projectInfo
+}: ProjectHistoryModalProps) => {
+ const [projectName, setProjectName] = useState(projectInfo?.projectName || "");
+
+ useEffect(() => {
+ if (!projectInfo) {
+ return;
+ }
+ setProjectName(projectInfo.projectName);
+ }, [projectInfo]);
+
+ return (
+
+
+ Project history
+
+
+ {projectInfo && (
+
+ setProjectName(e.target.value)} />
+ )}
+
+
+
+
+
+
+
+ )
+ }
+
+export default RenameProjectModal;
\ No newline at end of file
diff --git a/src/project-persistence/persistent-project-hooks.ts b/src/project-persistence/persistent-project-hooks.ts
new file mode 100644
index 000000000..c9096f274
--- /dev/null
+++ b/src/project-persistence/persistent-project-hooks.ts
@@ -0,0 +1,22 @@
+import { useContext } from "react";
+import { ProjectStorageContext } from "./ProjectStorageProvider";
+import * as Y from "yjs";
+import { Awareness } from "y-protocols/awareness.js";
+
+interface PersistentProjectActions {
+ ydoc?: Y.Doc;
+ awareness?: Awareness;
+}
+
+export const usePersistentProject = (): PersistentProjectActions => {
+
+ const ctx = useContext(ProjectStorageContext);
+ if (!ctx)
+ throw new Error(
+ "usePersistentProject must be used within a ProjectStorageProvider"
+ );
+ return {
+ ydoc: ctx.projectStore?.ydoc,
+ awareness: ctx.projectStore?.awareness
+ };
+}
diff --git a/src/project-persistence/project-history-db.ts b/src/project-persistence/project-history-db.ts
new file mode 100644
index 000000000..10764cf7b
--- /dev/null
+++ b/src/project-persistence/project-history-db.ts
@@ -0,0 +1,57 @@
+
+export interface HistoryEntry {
+ projectId: string;
+ revisionId: string;
+ parentId: string;
+ data: Uint8Array;
+ timestamp: number;
+}
+
+export type HistoryList = HistoryEntry[];
+
+type HistoryDbWrapper = (
+ accessMode: "readonly" | "readwrite",
+ callback: (revisions: IDBObjectStore) => Promise
+) => Promise;
+
+export const withHistoryDb: HistoryDbWrapper = async (accessMode, callback) => {
+ return new Promise((res, rej) => {
+ const openRequest = indexedDB.open("UserProjectHistory", 1);
+ openRequest.onupgradeneeded = (evt: IDBVersionChangeEvent) => {
+ const db = openRequest.result;
+ const tx = (evt.target as IDBOpenDBRequest).transaction;
+
+ let revisions: IDBObjectStore;
+ if (!db.objectStoreNames.contains("revisions")) {
+ revisions = db.createObjectStore("revisions", { autoIncrement:true });
+ } else {
+ revisions = tx!.objectStore("revisions");
+ }
+ if (!revisions.indexNames.contains("projectRevision")) {
+ revisions.createIndex("projectRevision", ["projectId", "revisionId"]);
+ }
+ if (!revisions.indexNames.contains("projectParent")) {
+ revisions.createIndex("projectParent", ["projectId", "parentId"]);
+ }
+ if (!revisions.indexNames.contains("projectId")) {
+ revisions.createIndex("projectId", "projectId");
+ }
+ };
+
+ openRequest.onsuccess = async () => {
+ const db = openRequest.result;
+
+ const tx = db.transaction("revisions", accessMode);
+ const store = tx.objectStore("revisions");
+ tx.onabort = rej;
+ tx.onerror = rej;
+
+ const result = await callback(store);
+
+ // got the result, but don't return until the transaction is complete
+ tx.oncomplete = () => res(result);
+ };
+
+ openRequest.onerror = rej;
+ });
+};
diff --git a/src/project-persistence/project-history-hooks.ts b/src/project-persistence/project-history-hooks.ts
new file mode 100644
index 000000000..8e416f5fb
--- /dev/null
+++ b/src/project-persistence/project-history-hooks.ts
@@ -0,0 +1,115 @@
+import { useCallback, useContext } from "react";
+import { ProjectStorageContext } from "./ProjectStorageProvider";
+import { HistoryEntry, HistoryList, withHistoryDb } from "./project-history-db";
+import { modifyProject, ProjectEntry, withProjectDb } from "./project-list-db";
+import { makeUID } from "./utils";
+import * as Y from "yjs";
+import { ProjectStore } from "./project-store";
+import { useProjectList } from "./project-list-hooks";
+
+/**
+ * Each project has a "head" which is a Y.Doc, and a series of revisions which are Y.js Update deltas.
+ */
+interface ProjectHistoryActions {
+ getHistory: (projectId: string) => Promise;
+ /**
+ * Note that loading a revision creates a new instance of the project at that revision.
+ *
+ * TODO: if a user loads a revision and doesn't modify it, should we even keep it around?
+ */
+ loadRevision: (projectId: string, projectRevision: string) => Promise;
+ /**
+ * Converts the head of the given project into a revision.
+ *
+ * TODO: prevent creating empty revisions if nothing changes.
+ */
+ saveRevision: (projectInfo: ProjectEntry) => Promise;
+}
+
+export const useProjectHistory = (): ProjectHistoryActions => {
+ const ctx = useContext(ProjectStorageContext);
+ if (!ctx) {
+ throw new Error(
+ "useProjectHistory must be used within a ProjectStorageProvider"
+ );
+ }
+ const { newStoredProject } = useProjectList();
+
+ const getUpdateAtRevision = useCallback(async (projectId: string, revision: string) => {
+ const deltas: HistoryEntry[] = [];
+ let parentRevision = revision;
+ do {
+ const delta = await withHistoryDb("readonly", async (revisions) => {
+ return new Promise((res, _rej) => {
+ const query = revisions
+ .index("projectRevision")
+ .get([projectId, parentRevision]);
+ query.onsuccess = () => res(query.result as HistoryEntry);
+ });
+ });
+ parentRevision = delta.parentId;
+ deltas.unshift(delta);
+ } while (parentRevision);
+ return Y.mergeUpdatesV2(deltas.map((d) => d.data));
+ }, []);
+
+ const getProjectInfo = (projectId: string) =>
+ withProjectDb("readwrite", async (store) => {
+ return new Promise((res, _rej) => {
+ const query = store.get(projectId);
+ query.onsuccess = () => res(query.result as ProjectEntry);
+ });
+ });
+
+ const loadRevision = useCallback(async (projectId: string, projectRevision: string) => {
+ const projectInfo = await getProjectInfo(projectId);
+ const { ydoc, id: forkId } = await newStoredProject();
+ await modifyProject(forkId, {
+ projectName: `${projectInfo.projectName} revision`,
+ parentRevision: forkId,
+ });
+ const updates = await getUpdateAtRevision(projectId, projectRevision);
+ Y.applyUpdateV2(ydoc, updates);
+ }, [getUpdateAtRevision, newStoredProject]);
+
+ const saveRevision = useCallback(async (projectInfo: ProjectEntry) => {
+ const projectStore = new ProjectStore(projectInfo.id, () => { });
+ await projectStore.persist();
+ let newUpdate: Uint8Array;
+ if (projectInfo.parentRevision) {
+ const previousUpdate = await getUpdateAtRevision(
+ projectInfo.id,
+ projectInfo.parentRevision
+ );
+ newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc, previousUpdate);
+ } else {
+ newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc);
+ }
+ const newRevision = makeUID();
+ await withHistoryDb("readwrite", async (revisions) => {
+ return new Promise((res, _rej) => {
+ const query = revisions.put({
+ projectId: projectInfo.id,
+ revisionId: newRevision,
+ parentId: projectInfo.parentRevision,
+ data: newUpdate,
+ timestamp: new Date(),
+ });
+ query.onsuccess = () => res();
+ });
+ });
+ await modifyProject(projectInfo.id, { parentRevision: newRevision });
+ }, [getUpdateAtRevision]);
+
+ const getHistory = useCallback(async (projectId: string) =>
+ withHistoryDb("readonly", async (store) => {
+ const revisionList = await new Promise((res, _rej) => {
+ const query = store.index("projectId").getAll(projectId);
+ query.onsuccess = () => res(query.result);
+ });
+ return revisionList;
+ }), []);
+
+
+ return { getHistory, loadRevision, saveRevision };
+}
diff --git a/src/project-persistence/project-list-db.ts b/src/project-persistence/project-list-db.ts
new file mode 100644
index 000000000..3a937e70e
--- /dev/null
+++ b/src/project-persistence/project-list-db.ts
@@ -0,0 +1,82 @@
+
+export interface ProjectEntry {
+ projectName: string;
+ id: string;
+ modifiedDate: number;
+ parentRevision?: string;
+}
+
+export type ProjectList = ProjectEntry[];
+
+type ProjectDbWrapper = (
+ accessMode: "readonly" | "readwrite",
+ callback: (projects: IDBObjectStore) => Promise
+) => Promise;
+
+export const withProjectDb: ProjectDbWrapper = async (accessMode, callback) => {
+ return new Promise((res, rej) => {
+ // TODO: what if multiple users? I think MakeCode just keeps everything...
+ const openRequest = indexedDB.open("UserProjects", 2);
+ openRequest.onupgradeneeded = (evt: IDBVersionChangeEvent) => {
+ const db = openRequest.result;
+ // NB: a more robust way to write migrations would be to get the current stored
+ // db.version and open it repeatedly with an ascending version number until the
+ // db is up to date. That would be more boilerplate though.
+ const tx = (evt.target as IDBOpenDBRequest).transaction;
+ // if the data object store doesn't exist, create it
+
+ let projects: IDBObjectStore;
+ if (!db.objectStoreNames.contains("projects")) {
+ projects = db.createObjectStore("projects", { keyPath: "id" });
+ // no indexes at present, get the whole db each time
+ } else {
+ projects = tx!.objectStore("projects");
+ }
+ if (!projects.indexNames.contains("modifiedDate")) {
+ projects.createIndex("modifiedDate", "modifiedDate");
+ const now = new Date().valueOf();
+ const updateProjectData = projects.getAll();
+ updateProjectData.onsuccess = () => {
+ updateProjectData.result.forEach((project) => {
+ if (!('modifiedDate' in project)) {
+ projects.put({ ...project, modifiedDate: now });
+ }
+ });
+ };
+ }
+ };
+
+ openRequest.onsuccess = async () => {
+ const db = openRequest.result;
+
+ const tx = db.transaction("projects", accessMode);
+ const store = tx.objectStore("projects");
+ tx.onabort = rej;
+ tx.onerror = rej;
+
+ const result = await callback(store);
+
+ // got the result, but don't return until the transaction is complete
+ tx.oncomplete = () => res(result);
+ };
+
+ openRequest.onerror = rej;
+ });
+};
+
+
+export const modifyProject = async (id: string, extras?: Partial) => {
+ await withProjectDb("readwrite", async (store) => {
+ await new Promise((res, _rej) => {
+ const getQuery = store.get(id);
+ getQuery.onsuccess = () => {
+ const putQuery = store.put({
+ ...getQuery.result,
+ ...extras,
+ modifiedDate: new Date().valueOf(),
+ });
+ putQuery.onsuccess = () => res(getQuery.result);
+ };
+ });
+ });
+}
diff --git a/src/project-persistence/project-list-hooks.ts b/src/project-persistence/project-list-hooks.ts
new file mode 100644
index 000000000..bd8cb0681
--- /dev/null
+++ b/src/project-persistence/project-list-hooks.ts
@@ -0,0 +1,120 @@
+import { useCallback, useContext, useEffect } from "react";
+import { ProjectStorageContext } from "./ProjectStorageProvider";
+import * as Y from "yjs";
+import { ProjectStore } from "./project-store";
+import { modifyProject, ProjectList, withProjectDb } from "./project-list-db";
+import { makeUID } from "./utils";
+
+export interface NewStoredDoc {
+ id: string;
+ ydoc: Y.Doc;
+}
+
+export interface RestoredStoredDoc {
+ projectName: string;
+ ydoc: Y.Doc;
+}
+
+interface ProjectListActions {
+ newStoredProject: () => Promise;
+ restoreStoredProject: (id: string) => Promise;
+ deleteProject: (id: string) => Promise;
+ setProjectName: (id: string, name: string) => Promise;
+ projectList: ProjectList | null;
+}
+
+export const useProjectList = (): ProjectListActions => {
+
+ const ctx = useContext(ProjectStorageContext);
+
+ if (!ctx) {
+ throw new Error(
+ "useProjectList must be used within a ProjectStorageProvider"
+ );
+ }
+
+ const { setProjectList, projectList, setProjectStore } = ctx;
+
+ const refreshProjects = useCallback(async () => {
+ const projectList = await withProjectDb("readonly", async (store) => {
+ const projectList = await new Promise((res, _rej) => {
+ const query = store.index("modifiedDate").getAll();
+ query.onsuccess = () => res(query.result);
+ });
+ return projectList;
+ });
+ setProjectList((projectList as ProjectList).reverse());
+ }, [setProjectList]);
+
+ useEffect(() => {
+ if (window.navigator.storage?.persist) {
+ void window.navigator.storage.persist();
+ }
+ void refreshProjects();
+ }, [refreshProjects]);
+
+ const setProjectName = useCallback(
+ async (id: string, projectName: string) => {
+ await modifyProject(id, { projectName });
+ await refreshProjects();
+ },
+ [refreshProjects]
+ );
+
+ const restoreStoredProject: (
+ projectId: string
+ ) => Promise = useCallback(
+ async (projectId: string) => {
+ const newProjectStore = new ProjectStore(projectId, () =>
+ modifyProject(projectId)
+ );
+ await newProjectStore.persist();
+ newProjectStore.startSyncing();
+ setProjectStore(newProjectStore);
+ return {
+ ydoc: newProjectStore.ydoc,
+ projectName: projectList!.find((prj) => prj.id === projectId)!
+ .projectName,
+ };
+ },
+ [projectList, setProjectStore]
+ );
+
+ const newStoredProject: () => Promise =
+ useCallback(async () => {
+ const newProjectId = makeUID();
+ await withProjectDb("readwrite", async (store) => {
+ store.add({
+ id: newProjectId,
+ projectName: "Untitled project",
+ modifiedDate: new Date().valueOf(),
+ });
+ return Promise.resolve();
+ });
+ const newProjectStore = new ProjectStore(newProjectId, () =>
+ modifyProject(newProjectId)
+ );
+ await newProjectStore.persist();
+ newProjectStore.startSyncing();
+ setProjectStore(newProjectStore);
+ return { ydoc: newProjectStore.ydoc, id: newProjectId };
+ }, [ setProjectStore]);
+
+ const deleteProject: (id: string) => Promise = useCallback(
+ async (id) => {
+ await withProjectDb("readwrite", async (store) => {
+ store.delete(id);
+ return refreshProjects();
+ });
+ },
+ [refreshProjects]
+ );
+
+ return {
+ restoreStoredProject,
+ newStoredProject,
+ deleteProject,
+ setProjectName,
+ projectList
+ };
+}
diff --git a/src/project-persistence/project-store.ts b/src/project-persistence/project-store.ts
new file mode 100644
index 000000000..813b45172
--- /dev/null
+++ b/src/project-persistence/project-store.ts
@@ -0,0 +1,78 @@
+import { IndexeddbPersistence } from "y-indexeddb";
+import { Awareness } from "y-protocols/awareness.js";
+import * as Y from "yjs";
+
+/**
+ * Because the ydoc persistence/sync needs to clean itself up from time to time
+ * it is in a class with the following state. It is agnostic in itself whether the project with the
+ * specified UID exists.
+ *
+ * constructor - sets up the state
+ * init - connects the persistence store, and local sync broadcast. Asynchronous, so you can await it
+ * destroy - disconnects everything that was connected in init, cleans up the persistence store
+ */
+export class ProjectStore {
+ public ydoc: Y.Doc;
+ public awareness: Awareness;
+ private broadcastHandler: (e: MessageEvent) => void;
+ private persistence: IndexeddbPersistence;
+ private updates: BroadcastChannel;
+ private updatePoster: (update: Uint8Array) => void;
+
+ constructor(public projectId: string, projectChangedListener: () => void) {
+ const ydoc = new Y.Doc();
+ this.ydoc = ydoc;
+ this.awareness = new Awareness(this.ydoc);
+
+ this.persistence = new IndexeddbPersistence(this.projectId, this.ydoc);
+
+ const clientId = `${Math.random()}`; // Used by the broadcasthandler to know whether we sent a data update
+ this.broadcastHandler = ({ data }: MessageEvent) => {
+ if (data.clientId !== clientId && data.projectId === projectId) {
+ Y.applyUpdate(ydoc, data.update);
+ }
+ };
+
+ this.updates = new BroadcastChannel("yjs");
+ this.updatePoster = ((update: Uint8Array) => {
+ this.updates.postMessage({ clientId, update, projectId });
+ projectChangedListener();
+ }).bind(this);
+ }
+
+ public async persist() {
+ await new Promise((res) => this.persistence.once("synced", res));
+ migrate(this.ydoc);
+ }
+
+ public startSyncing() {
+ this.ydoc.on("update", this.updatePoster);
+ this.updates.addEventListener("message", this.broadcastHandler);
+ }
+
+ public destroy() {
+ this.ydoc.off("update", this.updatePoster);
+ this.updates.removeEventListener("message", this.broadcastHandler);
+ this.updates.close();
+ void this.persistence.destroy();
+ }
+}
+
+/**
+ * This is a kind of example of what migration could look like. It's not a designed approach at this point.
+ */
+const migrate = (doc: Y.Doc) => {
+ const meta = doc.getMap("meta");
+ if (!meta.has("version")) {
+ // If the project has no version, assume it's from whatever this app did before ProjectStorageProvider
+ // This could be a per-app handler
+ meta.set("version", 1);
+ meta.set("projectName", "default"); // TODO: get this from the last loaded project name
+ }
+};
+
+interface SyncMessage {
+ clientId: string;
+ projectId: string;
+ update: Uint8Array;
+}
\ No newline at end of file
diff --git a/src/project-persistence/utils.ts b/src/project-persistence/utils.ts
new file mode 100644
index 000000000..bc3a774cf
--- /dev/null
+++ b/src/project-persistence/utils.ts
@@ -0,0 +1,51 @@
+export function timeAgo(date: Date): string {
+ const now = new Date();
+ const seconds = Math.round((+now - +date) / 1000);
+ const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
+
+ const divisions: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
+ { amount: 60, unit: 'second' },
+ { amount: 60, unit: 'minute' },
+ { amount: 24, unit: 'hour' },
+ { amount: 7, unit: 'day' },
+ { amount: 4.34524, unit: 'week' }, // approx
+ { amount: 12, unit: 'month' }
+ ];
+
+ let duration = seconds;
+ for (const division of divisions) {
+ if (Math.abs(duration) < division.amount) {
+ return rtf.format(-Math.round(duration), division.unit);
+ }
+ duration /= division.amount;
+ }
+
+ return rtf.format(-Math.round(duration), "year");
+}
+
+export function significantDateUnits(date: Date): string {
+ const now = new Date();
+
+ let dateTimeOptions: Intl.DateTimeFormatOptions = { month: "short", year: "2-digit" };
+
+ const daysDifferent = Math.round((+now - +date) / (1000 * 60 * 60 * 24));
+ if (daysDifferent < 1 && date.getDay() === now.getDay()) {
+ dateTimeOptions = {
+ hour: 'numeric',
+ minute: 'numeric',
+ }
+ } else if (now.getFullYear() === date.getFullYear()) {
+ dateTimeOptions = {
+ day: 'numeric',
+ month: 'short'
+ }
+ }
+
+ return Intl.DateTimeFormat(undefined, dateTimeOptions).format(date);
+}
+
+// TODO: WORLDS UGLIEST UIDS
+export const makeUID = () => {
+ return `${Math.random()}`;
+};
+
diff --git a/src/store-persistence-hooks.ts b/src/store-persistence-hooks.ts
new file mode 100644
index 000000000..42eb2fdd9
--- /dev/null
+++ b/src/store-persistence-hooks.ts
@@ -0,0 +1,59 @@
+import { useEffect, useState } from "react";
+import { ActionDataY, RecordingDataY } from "./model";
+import { useStore } from "./store";
+import { BASE_DOC_NAME, loadNewDoc } from "./store-persistence";
+import * as Y from "yjs";
+import { useProjectList } from "./project-persistence/project-list-hooks";
+import { usePersistentProject } from "./project-persistence/persistent-project-hooks";
+
+export const useStoreProjects = () => {
+ // storeprojects relates to projects of type Store
+ // projectstorage stores projects
+ // simple?
+ // TODO: improve naming
+ const { newStoredProject, restoreStoredProject } = useProjectList();
+ const newProject = async () => {
+ const newProjectImpl = async () => {
+ const { ydoc } = await newStoredProject();
+ ydoc.getText(BASE_DOC_NAME).insert(0, "{}");
+ ydoc.getMap("files").set("actions", new Y.Array);
+ return ydoc;
+ }
+ await loadNewDoc(newProjectImpl());
+ // Needed to attach Y types
+ await useStore.persist.rehydrate();
+ }
+ const loadProject = async (projectId: string) => {
+ const loadProjectImpl = async () => {
+ const { ydoc } = await restoreStoredProject(projectId);
+ return ydoc;
+ }
+ await loadNewDoc(loadProjectImpl());
+ await useStore.persist.rehydrate();
+ }
+ return { loadProject, newProject };
+}
+
+export const useActions = () => {
+ const [actionsRev, setActionsRev] = useState(0);
+ const { ydoc } = usePersistentProject();
+ const actions = ydoc?.getMap("files").get("actions") as ActionDataY; // TODO: what happens when you don't got actions?
+ useEffect(() => {
+ const actionsInner = ydoc?.getMap("files").get("actions") as ActionDataY;
+ if (!actionsInner) {
+ return;
+ }
+ const observer = () => setActionsRev(actionsRev + 1);
+ actionsInner.observeDeep(observer)
+ return () => actionsInner.unobserveDeep(observer);
+ }, [ydoc, actionsRev]);
+ return actions;
+}
+
+export const useHasActions = () => {
+ const actions = useActions();
+ return (
+ (actions.length > 0 && (actions.get(0).get("name") as string).length > 0) ||
+ (actions.get(0).get("recordings") as RecordingDataY).length > 0
+ );
+};
diff --git a/src/store-persistence.ts b/src/store-persistence.ts
new file mode 100644
index 000000000..97f3d437a
--- /dev/null
+++ b/src/store-persistence.ts
@@ -0,0 +1,82 @@
+import { PersistStorage, StorageValue } from "zustand/middleware";
+import * as Y from "yjs";
+import { ActionDataY } from "./model";
+
+
+interface ProjectState {
+ doc: Y.Doc | null;
+ loadingPromise: Promise | null;
+}
+
+const activeState: ProjectState = {
+ doc: null,
+ loadingPromise: null
+}
+
+export const loadNewDoc = async (loadingPromise : Promise) => {
+ activeState.doc = null;
+ activeState.loadingPromise = loadingPromise;
+ activeState.doc = await loadingPromise;
+}
+
+// This storage system ignores that Zustand supports multiple datastores, because it exists
+// in the specific context that we want to blend the existing Zustand data with a Y.js
+// backend, and we know what the data should be so genericism goes out of the window.
+// Anything that is not handled as a special case (e.g. actions are special) becomes a
+// simple json doc in the same way that Zustand persist conventionally does it.
+export const BASE_DOC_NAME = "ml";
+
+// TODO: Think about what versioning should relate to.
+// The zustand store has a version, and this also has structurally-sensitive things in
+// its storeState mapper.
+// store.ts currently has a lot of controller logic, and it could be pared out and synced
+// more loosely with the yjs-ified data. E.g. project syncing could be done at a level above
+// the store, with a subscription.
+export const projectStorage = () => {
+
+ const getItemImpl = (ydoc: Y.Doc) => {
+ const state = JSON.parse(ydoc.getText(BASE_DOC_NAME).toJSON()) as T;
+ state.actions = ydoc.getMap("files").get("actions") as ActionDataY;
+ return { state, version: 2 };
+ }
+
+ const getItem = (_name: string) => {
+ if (activeState.doc) {
+ return getItemImpl(activeState.doc);
+ } else {
+ return async () => {
+ await activeState.loadingPromise;
+ return getItemImpl(activeState.doc!);
+ }
+ }
+ }
+
+ const setItem = (_name: string, valueFull: StorageValue) => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { state: { actions, ...state }, version } = valueFull as StorageValue;
+
+ const value = { state, version };
+
+ if (activeState.doc) {
+ const storeText = activeState.doc.getText(BASE_DOC_NAME);
+ storeText.delete(0, storeText.length);
+ storeText.insert(0, JSON.stringify(value));
+ } else {
+ return async () => {
+ await activeState.loadingPromise;
+ const storeText = activeState.doc!.getText(BASE_DOC_NAME);
+ storeText.delete(0, storeText.length);
+ storeText.insert(0, JSON.stringify(value));
+ }
+ }
+ }
+ const removeItem = (_name: string) => {
+ // Don't remove things through Zustand, use ProjectStorage
+ }
+
+ return {
+ getItem,
+ setItem,
+ removeItem
+ } as PersistStorage;
+}
\ No newline at end of file
diff --git a/src/store.ts b/src/store.ts
index 6120e675a..32e29f4af 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -35,9 +35,14 @@ import {
EditorStartUp,
TourTriggerName,
tourSequence,
+ ActionDataY,
+ RecordingDatumY,
+ XYZData,
+ ActionDatumY,
+ RecordingDataY,
} from "./model";
import { defaultSettings, Settings } from "./settings";
-import { getTotalNumSamples } from "./utils/actions";
+import { getTotalNumSamples, hasSufficientDataForTraining } from "./utils/actions";
import { defaultIcons, MakeCodeIcon } from "./utils/icons";
import { untitledProjectName } from "./project-name";
import { mlSettings } from "./mlConfig";
@@ -45,15 +50,19 @@ import { BufferedData } from "./buffered-data";
import { getDetectedAction } from "./utils/prediction";
import { getTour as getTourSpec } from "./tours";
import { createPromise, PromiseInfo } from "./hooks/use-promise-ref";
+import { projectStorage } from "./store-persistence";
+import * as Y from "yjs";
export const modelUrl = "indexeddb://micro:bit-ai-creator-model";
-const createFirstAction = () => ({
- icon: defaultIcons[0],
- ID: Date.now(),
- name: "",
- recordings: [],
-});
+const createFirstAction = (actions: ActionDataY) => {
+ const newAction = new Y.Map>();
+ newAction.set("icon", defaultIcons[0]);
+ newAction.set("ID", Date.now());
+ newAction.set("name", "");
+ newAction.set("recordings", new Y.Array());
+ actions.push([newAction]);
+};
export interface DataWindow {
duration: number; // Duration of recording
@@ -116,11 +125,11 @@ const createUntitledProject = (): MakeCodeProject => ({
const updateProject = (
project: MakeCodeProject,
projectEdited: boolean,
- actions: ActionData[],
+ actions: ActionDataY,
model: tf.LayersModel | undefined,
dataWindow: DataWindow
): Partial => {
- const actionsData = { data: actions };
+ const actionsData = { data: actions.toJSON() };
const updatedProject = {
...project,
text: {
@@ -143,7 +152,7 @@ const updateProject = (
};
export interface State {
- actions: ActionData[];
+ actions: ActionDataY;
dataWindow: DataWindow;
model: tf.LayersModel | undefined;
@@ -315,7 +324,7 @@ const createMlStore = (logging: Logging) => {
persist(
(set, get) => ({
timestamp: undefined,
- actions: [],
+ actions: new Y.Array(),
dataWindow: currentDataWindow,
isRecording: false,
project: createUntitledProject(),
@@ -407,7 +416,6 @@ const createMlStore = (logging: Logging) => {
const untitledProject = createUntitledProject();
set(
{
- actions: [],
dataWindow: currentDataWindow,
model: undefined,
project: projectName
@@ -444,26 +452,23 @@ const createMlStore = (logging: Logging) => {
},
addNewAction() {
- return set(({ project, projectEdited, actions, dataWindow }) => {
- const newActions = [
- ...actions,
- {
- icon: actionIcon({
- isFirstAction: actions.length === 0,
- existingActions: actions,
- }),
- ID: Date.now(),
- name: "",
- recordings: [],
- },
- ];
+ return set(({ project, actions, projectEdited, dataWindow }) => {
+ const newAction = new Y.Map>();
+ const existingIcons = actions.map(action => action.get("icon") as MakeCodeIcon);
+ newAction.set("icon", actionIcon({
+ isFirstAction: actions.length === 0,
+ existingIcons,
+ }));
+ newAction.set("ID", Date.now());
+ newAction.set("name", "");
+ newAction.set("recordings", new Y.Array>());
+ actions.push([newAction]);
return {
- actions: newActions,
model: undefined,
...updateProject(
project,
projectEdited,
- newActions,
+ actions,
undefined,
dataWindow
),
@@ -472,37 +477,40 @@ const createMlStore = (logging: Logging) => {
},
addActionRecordings(id: ActionData["ID"], recs: RecordingData[]) {
- return set(({ actions }) => {
- const updatedActions = actions.map((action) => {
- if (action.ID === id) {
- return {
- ...action,
- recordings: [...recs, ...action.recordings],
- };
- }
- return action;
- });
- return {
- actions: updatedActions,
- model: undefined,
- };
- });
+ const { actions } = get();
+ for (const action of actions) {
+ if (action.get("ID") === id) {
+ const recsY = recs.map(rec => {
+ const recY = new Y.Map();
+ recY.set("ID", rec.ID);
+ recY.set("data", rec.data);
+ return recY;
+ });
+ (action.get("recordings") as Y.Array).push(recsY);
+ }
+ }
},
deleteAction(id: ActionData["ID"]) {
+ const { actions } = get();
+ withActionIndex(id, actions, (actionIndex) => {
+ actions.delete(actionIndex)
+ });
+ if (actions.length === 0) {
+ // TODO: Port this to Y mode
+ createFirstAction(actions);
+ }
+
return set(({ project, projectEdited, actions, dataWindow }) => {
- const newActions = actions.filter((a) => a.ID !== id);
const newDataWindow =
- newActions.length === 0 ? currentDataWindow : dataWindow;
+ actions.length === 0 ? currentDataWindow : dataWindow;
return {
- actions:
- newActions.length === 0 ? [createFirstAction()] : newActions,
dataWindow: newDataWindow,
model: undefined,
...updateProject(
project,
projectEdited,
- newActions,
+ actions,
undefined,
newDataWindow
),
@@ -511,17 +519,18 @@ const createMlStore = (logging: Logging) => {
},
setActionName(id: ActionData["ID"], name: string) {
+ const { actions } = get();
+ withActionIndex(id, actions, (actionIndex) => {
+ actions.get(actionIndex).set("name", name);
+ });
return set(
({ project, projectEdited, actions, model, dataWindow }) => {
- const newActions = actions.map((action) =>
- id !== action.ID ? action : { ...action, name }
- );
+
return {
- actions: newActions,
...updateProject(
project,
projectEdited,
- newActions,
+ actions,
model,
dataWindow
),
@@ -531,29 +540,32 @@ const createMlStore = (logging: Logging) => {
},
setActionIcon(id: ActionData["ID"], icon: MakeCodeIcon) {
+ const { actions } = get();
+
+ withActionIndex(id, actions, (actionIndex) => {
+ const action = actions.get(actionIndex);
+ const currentIcon = action.get("icon");
+ action.set("icon", icon);
+ actions.forEach((maybeClashingAction, maybeClashingActionIndex) => {
+ if (maybeClashingActionIndex === actionIndex) {
+ return;
+ }
+ const maybeClashingIcon = maybeClashingAction.get("icon");
+ if (maybeClashingIcon === icon) {
+ maybeClashingAction.set("icon", currentIcon!);
+ }
+ });
+ });
return set(
({ project, projectEdited, actions, model, dataWindow }) => {
// If we're changing the action to use an icon that's already in use
// then we update the action that's using the icon to use the action's current icon
- const currentIcon = actions.find((a) => a.ID === id)?.icon;
- const newActions = actions.map((action) => {
- if (action.ID === id) {
- return { ...action, icon };
- } else if (
- action.ID !== id &&
- action.icon === icon &&
- currentIcon
- ) {
- return { ...action, icon: currentIcon };
- }
- return action;
- });
+
return {
- actions: newActions,
...updateProject(
project,
projectEdited,
- newActions,
+ actions,
model,
dataWindow
),
@@ -563,17 +575,17 @@ const createMlStore = (logging: Logging) => {
},
setRequiredConfidence(id: ActionData["ID"], value: number) {
+ const { actions } = get();
+ withActionIndex(id, actions, (actionIndex) => {
+ actions.get(actionIndex).set("requiredConfidence", value);
+ });
return set(
({ project, projectEdited, actions, model, dataWindow }) => {
- const newActions = actions.map((a) =>
- id !== a.ID ? a : { ...a, requiredConfidence: value }
- );
return {
- actions: newActions,
...updateProject(
project,
projectEdited,
- newActions,
+ actions,
model,
dataWindow
),
@@ -583,30 +595,24 @@ const createMlStore = (logging: Logging) => {
},
deleteActionRecording(id: ActionData["ID"], recordingIdx: number) {
+ const { actions } = get();
+ let hasRecordings: boolean = false;
+ for (const action of actions) {
+ if (action.get("ID") === id) {
+ (action.get("recordings") as RecordingDataY).delete(recordingIdx);
+ } else {
+ hasRecordings ||= (action.get("recordings") as RecordingDataY).length > 0;
+ }
+ }
+
return set(({ project, projectEdited, actions, dataWindow }) => {
- const newActions = actions.map((action) => {
- if (id !== action.ID) {
- return action;
- }
- const recordings = action.recordings.filter(
- (_r, i) => i !== recordingIdx
- );
- return { ...action, recordings };
- });
- const numRecordings = newActions.reduce(
- (acc, curr) => acc + curr.recordings.length,
- 0
- );
- const newDataWindow =
- numRecordings === 0 ? currentDataWindow : dataWindow;
+ const newDataWindow: DataWindow = hasRecordings ? currentDataWindow : dataWindow;
return {
- actions: newActions,
dataWindow: newDataWindow,
- model: undefined,
...updateProject(
project,
projectEdited,
- newActions,
+ actions,
undefined,
newDataWindow
),
@@ -615,14 +621,15 @@ const createMlStore = (logging: Logging) => {
},
deleteAllActions() {
- return set(({ project, projectEdited }) => ({
- actions: [createFirstAction()],
+ const { actions } = get();
+ actions.delete(0, actions.length);
+ return set(({ actions, project, projectEdited }) => ({
dataWindow: currentDataWindow,
model: undefined,
...updateProject(
project,
projectEdited,
- [],
+ actions,
undefined,
currentDataWindow
),
@@ -646,6 +653,11 @@ const createMlStore = (logging: Logging) => {
},
loadDataset(newActions: ActionData[]) {
+ const { actions } = get();
+ actions.delete(0, actions.length);
+ const newActionsY: ActionDatumY[] = actionDataToY(newActions);
+
+ actions.push(newActionsY);
set(({ project, projectEdited, settings }) => {
const dataWindow = getDataWindowFromActions(newActions);
return {
@@ -655,25 +667,13 @@ const createMlStore = (logging: Logging) => {
new Set([...settings.toursCompleted, "DataSamplesRecorded"])
),
},
- actions: (() => {
- const copy = newActions.map((a) => ({ ...a }));
- for (const a of copy) {
- if (!a.icon) {
- a.icon = actionIcon({
- isFirstAction: false,
- existingActions: copy,
- });
- }
- }
- return copy;
- })(),
dataWindow,
model: undefined,
timestamp: Date.now(),
...updateProject(
project,
projectEdited,
- newActions,
+ actions,
undefined,
dataWindow
),
@@ -705,7 +705,6 @@ const createMlStore = (logging: Logging) => {
new Set([...settings.toursCompleted, "DataSamplesRecorded"])
),
},
- actions: newActions,
dataWindow: getDataWindowFromActions(newActions),
model: undefined,
project,
@@ -761,7 +760,7 @@ const createMlStore = (logging: Logging) => {
// can block the UI. 50 ms is not sufficient, so use 100 for now.
await new Promise((res) => setTimeout(res, 100));
const trainingResult = await trainModel(
- actions,
+ actions.toJSON() as ActionData[],
dataWindow,
(trainModelProgress) =>
set({ trainModelProgress }, false, "trainModelProgress")
@@ -802,7 +801,7 @@ const createMlStore = (logging: Logging) => {
...previousProject.text,
...generateProject(
previousProject.header?.name ?? untitledProjectName,
- { data: actions },
+ { data: actions.toJSON() as ActionData[] },
model,
dataWindow
).text,
@@ -940,7 +939,6 @@ const createMlStore = (logging: Logging) => {
timestamp,
// New project loaded externally so we can't know whether its edited.
projectEdited: true,
- actions: newActions,
dataWindow: getDataWindowFromActions(newActions),
model: undefined,
isEditorOpen: false,
@@ -1024,18 +1022,20 @@ const createMlStore = (logging: Logging) => {
);
},
dataCollectionMicrobitConnected() {
+ const { actions } = get();
+ if (actions.length === 0) {
+ createFirstAction(actions);
+ }
set(
({ actions, tourState, postConnectTourTrigger }) => {
return {
- actions:
- actions.length === 0 ? [createFirstAction()] : actions,
// If a tour has been explicitly requested, do that.
// Other tours are triggered by callbacks or effects on the relevant page so they run only on the correct screen.
tourState: postConnectTourTrigger
? {
index: 0,
- ...getTourSpec(postConnectTourTrigger, actions),
+ ...getTourSpec(postConnectTourTrigger, actions.toJSON() as ActionData[]),
}
: tourState,
postConnectTourTrigger: undefined,
@@ -1053,7 +1053,7 @@ const createMlStore = (logging: Logging) => {
(!state.tourState &&
!state.settings.toursCompleted.includes(trigger.name))
) {
- const tourSpec = getTourSpec(trigger, state.actions);
+ const tourSpec = getTourSpec(trigger, state.actions.toJSON() as ActionData[]);
const result = {
tourState: {
...tourSpec,
@@ -1139,7 +1139,7 @@ const createMlStore = (logging: Logging) => {
const input = {
model,
data: buffer.getSamples(startTime),
- classificationIds: actions.map((a) => a.ID),
+ classificationIds: actions.map((a) => a.get("ID") as number),
};
if (input.data.x.length > dataWindow.minSamples) {
const result = predict(input, dataWindow);
@@ -1152,7 +1152,7 @@ const createMlStore = (logging: Logging) => {
// recognition point are realised.
get().actions,
result.confidences
- );
+ )?.toJSON() as Action;
set({
predictionResult: {
detected,
@@ -1291,8 +1291,16 @@ const createMlStore = (logging: Logging) => {
gestures?: ActionData[];
}
const stateV0 = persistedStateUnknown as StateV0;
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { gestures, ...rest } = stateV0;
- return { actions: gestures, ...rest } as State;
+
+ // TODO: Poke gestures through
+ //const newActions = actionDataToY(gestures as ActionData[]);
+ //actions.delete(0, actions.length);
+ //actions.push(newActions);
+
+ return { ...rest } as State;
}
default:
return persistedStateUnknown;
@@ -1308,11 +1316,13 @@ const createMlStore = (logging: Logging) => {
// Make sure we have any new settings defaulted
...defaultSettings,
...currentState.settings,
- ...persistedState.settings,
+ ...persistedState?.settings,
},
};
},
- }
+ storage: projectStorage(),
+ skipHydration: true
+ },
),
{ enabled: flags.devtools }
)
@@ -1328,14 +1338,6 @@ const getDataWindowFromActions = (actions: ActionData[]): DataWindow => {
: currentDataWindow;
};
-// Get data window from actions on app load.
-const { actions } = useStore.getState();
-useStore.setState(
- { dataWindow: getDataWindowFromActions(actions) },
- false,
- "setDataWindow"
-);
-
tf.loadLayersModel(modelUrl)
.then((model) => {
if (model) {
@@ -1362,30 +1364,6 @@ useStore.subscribe((state, prevState) => {
}
});
-export const useHasActions = () => {
- const actions = useStore((s) => s.actions);
- return (
- (actions.length > 0 && actions[0].name.length > 0) ||
- actions[0]?.recordings.length > 0
- );
-};
-
-const hasSufficientDataForTraining = (actions: ActionData[]): boolean => {
- return actions.length >= 2 && actions.every((a) => a.recordings.length >= 3);
-};
-
-export const useHasSufficientDataForTraining = (): boolean => {
- const actions = useStore((s) => s.actions);
- return hasSufficientDataForTraining(actions);
-};
-
-export const useHasNoStoredData = (): boolean => {
- const actions = useStore((s) => s.actions);
- return !(
- actions.length !== 0 && actions.some((a) => a.recordings.length > 0)
- );
-};
-
type UseSettingsReturn = [Settings, (settings: Partial) => void];
const inContextTranslationLangId = "lol";
@@ -1402,18 +1380,17 @@ export const useSettings = (): UseSettingsReturn => {
const actionIcon = ({
isFirstAction,
- existingActions,
+ existingIcons,
}: {
- isFirstAction: boolean;
- existingActions: Action[];
+ isFirstAction: boolean;
+ existingIcons: MakeCodeIcon[];
}) => {
if (isFirstAction) {
return defaultIcons[0];
}
- const iconsInUse = existingActions.map((a) => a.icon);
const useableIcons: MakeCodeIcon[] = [];
for (const icon of defaultIcons) {
- if (!iconsInUse.includes(icon)) {
+ if (!existingIcons.includes(icon)) {
useableIcons.push(icon);
}
}
@@ -1458,3 +1435,40 @@ const renameProject = (
},
};
};
+
+const withActionIndex = (actionID: number, actions: ActionDataY, cb: (actionIndex: number) => void) => {
+ let actionIndex;
+ for (actionIndex = 0; actionIndex < actions.length; ++actionIndex) {
+ if (actions.get(actionIndex).get("ID") === actionID) {
+ break;
+ }
+ }
+ if (actionIndex === actions.length) return;
+ cb(actionIndex);
+}
+
+const actionDataToY = (newActions: ActionData[]) => {
+ const existingIcons: MakeCodeIcon[] = [];
+ const newActionsY: ActionDatumY[] = [];
+ for (const a of newActions) {
+ const newActionY: ActionDatumY = new Y.Map();
+ newActionY.set("ID", a.ID);
+ newActionY.set("name", a.name);
+ const newIcon = a.icon ? a.icon : actionIcon({ isFirstAction: false, existingIcons });
+ existingIcons.push(newIcon);
+ newActionY.set("icon", newIcon);
+ newActionY.set("requiredConfidence", a.requiredConfidence as number); // TODO: consider undefined case
+ const recordings: RecordingDatumY[] = [];
+ for (const r of a.recordings) {
+ const recordingY = new Y.Map() as RecordingDatumY;
+ recordingY.set("ID", r.ID);
+ recordingY.set("data", r.data);
+ recordings.push(recordingY);
+ }
+ const recordingsY = new Y.Array() as RecordingDataY;
+ recordingsY.push(recordings);
+ newActionY.set("recordings", recordingsY);
+ newActionsY.push(newActionY);
+ }
+ return newActionsY;
+}
diff --git a/src/utils/actions.ts b/src/utils/actions.ts
index 9d8c79e3e..4ff88235c 100644
--- a/src/utils/actions.ts
+++ b/src/utils/actions.ts
@@ -3,7 +3,23 @@
*
* SPDX-License-Identifier: MIT
*/
-import { ActionData } from "../model";
+import { ActionDataY, ActionDatumY, RecordingDataY, } from "../model";
-export const getTotalNumSamples = (actions: ActionData[]) =>
- actions.map((a) => a.recordings).reduce((acc, curr) => acc + curr.length, 0);
+export const getTotalNumSamples = (actions: ActionDataY) =>
+ actions.map((a) => (a.get("recordings") as RecordingDataY).length).reduce((acc, curr) => acc + curr, 0);
+
+// Y has no "every"
+export const forSomeAction = (actions: ActionDataY, cb: (action: ActionDatumY) => boolean) => {
+ for (const action of actions) {
+ if (cb(action)) return true;
+ }
+ return false;
+}
+
+export const hasSufficientDataForTraining = (actions: ActionDataY): boolean => {
+ if (actions.length < 2) return false;
+ const numRecordings = actions.map(action => (action.get("recordings") as RecordingDataY).length);
+ const enoughRecordings = numRecordings.reduce((p, c) => p && c >= 3, true);
+ return enoughRecordings;
+ //return actions.length >= 2 && actions.map(action => (action.get("recordings") as RecordingDataY).length).reduce((p, c) => p && c >= 3, true);
+};
\ No newline at end of file
diff --git a/src/utils/prediction.ts b/src/utils/prediction.ts
index 39acf7413..a3333858f 100644
--- a/src/utils/prediction.ts
+++ b/src/utils/prediction.ts
@@ -6,12 +6,12 @@
*/
import { Confidences } from "../ml";
import { mlSettings } from "../mlConfig";
-import { Action } from "../model";
+import { ActionDataY, ActionDatumY } from "../model";
export const getDetectedAction = (
- actions: Action[],
+ actions: ActionDataY,
confidences: Confidences | undefined
-): Action | undefined => {
+): ActionDatumY | undefined => {
if (!confidences) {
return undefined;
}
@@ -21,8 +21,8 @@ export const getDetectedAction = (
.map((action) => ({
action,
thresholdDelta:
- confidences[action.ID] -
- (action.requiredConfidence ?? mlSettings.defaultRequiredConfidence),
+ confidences[action.get("ID") as number] -
+ (action.get("requiredConfidence") as number ?? mlSettings.defaultRequiredConfidence),
}))
.sort((left, right) => {
const a = left.thresholdDelta;