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;