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/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..39f87d093
--- /dev/null
+++ b/src/project-persistence/ProjectStorageProvider.tsx
@@ -0,0 +1,96 @@
+// ProjectContext.tsx
+import React, { createContext, useCallback, useContext, useState } from "react";
+import { ProjectData, ProjectList } from "./project-list-db";
+import { DocAccessor } from "./project-list-hooks";
+import { writeProject, readProject } from "./project-store-idb";
+
+interface ProjectContextValue {
+ projectId: string | null;
+ projectList: ProjectList | null;
+ setProjectList: (projectList: ProjectList) => void;
+ projectAccessor: DocAccessor | null;
+ setProjectAccessor: (accessor: DocAccessor) => void;
+ openProject: (
+ projectId: string,
+ onChangeObserver: () => void
+ ) => Promise;
+}
+
+export const ProjectStorageContext = createContext(
+ null
+);
+
+interface DocAccessorInternal extends DocAccessor {
+ destroy: () => void;
+ projectId: string;
+}
+
+/**
+ * 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 [projectAccessor, setProjectAccessorImpl] =
+ useState(null);
+ const projectAccessorInternal = projectAccessor as DocAccessorInternal;
+ const setProjectAccessor = useCallback(
+ (newProjectStore: DocAccessor) => {
+ if (projectAccessorInternal) {
+ projectAccessorInternal.destroy();
+ }
+ setProjectAccessorImpl(newProjectStore);
+ },
+ [projectAccessorInternal]
+ );
+
+ const openProject = (projectId: string, onChangeObserver: () => void) => {
+ const newProjectAccessor: DocAccessorInternal = {
+ setDoc: async (doc: string) => {
+ await writeProject(projectId, doc);
+ onChangeObserver(); // Fine here, because we only use it to trigger modified behaviour, but...
+ // TODO: handle synchronisation from other DB changes
+ },
+ getDoc: async () => {
+ return (await readProject(projectId)).contents;
+ },
+ destroy: () => {},
+ projectId,
+ };
+ return Promise.resolve(newProjectAccessor);
+ };
+
+ 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/project-history-db.ts b/src/project-persistence/project-history-db.ts
new file mode 100644
index 000000000..5b8931a0f
--- /dev/null
+++ b/src/project-persistence/project-history-db.ts
@@ -0,0 +1,57 @@
+
+export interface HistoryEntry {
+ projectId: string;
+ revisionId: string;
+ parentId: string;
+ data: string;
+ 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..b32c22f76
--- /dev/null
+++ b/src/project-persistence/project-history-hooks.ts
@@ -0,0 +1,97 @@
+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 { readProject } from "./project-store-idb";
+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 update = await withHistoryDb("readonly", async (revisions) => {
+ return new Promise((res, _rej) => {
+ const query = revisions
+ .index("projectRevision")
+ .get([projectId, revision]);
+ query.onsuccess = () => res(query.result as HistoryEntry);
+ });
+ });
+ return update;
+ }, []);
+
+ 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 { doc, id: forkId } = await newStoredProject();
+ await modifyProject(forkId, {
+ projectName: `${projectInfo.projectName} revision`,
+ parentRevision: forkId,
+ });
+ const update = await getUpdateAtRevision(projectId, projectRevision);
+ return doc.setDoc(update.data);
+ }, [getUpdateAtRevision, newStoredProject]);
+
+ const saveRevision = useCallback(async (projectInfo: ProjectEntry) => {
+ const newUpdate = ((await readProject(projectInfo.id)).contents);
+ 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 });
+ }, []);
+
+ 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-hooks.ts b/src/project-persistence/project-hooks.ts
new file mode 100644
index 000000000..393365580
--- /dev/null
+++ b/src/project-persistence/project-hooks.ts
@@ -0,0 +1,12 @@
+import { useContext } from "react";
+import { ProjectStorageContext } from "./ProjectStorageProvider";
+
+export const usePersistentProject = () => {
+
+ const ctx = useContext(ProjectStorageContext);
+ if (!ctx)
+ throw new Error(
+ "usePersistentProject must be used within a ProjectStorageProvider"
+ );
+ return ctx.projectAccessor;
+}
diff --git a/src/project-persistence/project-list-db.ts b/src/project-persistence/project-list-db.ts
new file mode 100644
index 000000000..05bba15f5
--- /dev/null
+++ b/src/project-persistence/project-list-db.ts
@@ -0,0 +1,97 @@
+
+export interface ProjectEntry {
+ projectName: string;
+ id: string;
+ modifiedDate: number;
+ parentRevision?: string;
+}
+
+export type ProjectList = ProjectEntry[];
+
+export interface ProjectData {
+ id: string;
+ contents: string;
+}
+
+export enum ProjectDbTable {
+ Projects = "projects",
+ ProjectData = "projectData"
+}
+
+type ProjectDbWrapper = (
+ accessMode: "readonly" | "readwrite",
+ callback: (projects: IDBObjectStore) => Promise,
+ tableName?: ProjectDbTable
+) => Promise;
+
+export const withProjectDb: ProjectDbWrapper = async (accessMode, callback, tableName = ProjectDbTable.Projects) => {
+ return new Promise((res, rej) => {
+ // TODO: what if multiple users? I think MakeCode just keeps everything...
+ const openRequest = indexedDB.open("UserProjects", 3);
+ openRequest.onupgradeneeded = onUpgradeNeeded(openRequest);
+
+ openRequest.onsuccess = async () => {
+ const db = openRequest.result;
+
+ const tx = db.transaction(tableName, accessMode);
+ const store = tx.objectStore(tableName);
+ 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);
+ };
+ });
+ });
+}
+
+const onUpgradeNeeded = (openRequest: IDBOpenDBRequest) => (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 projectListStore: IDBObjectStore;
+ if (!db.objectStoreNames.contains(ProjectDbTable.Projects)) {
+ projectListStore = db.createObjectStore(ProjectDbTable.Projects, { keyPath: "id" });
+ } else {
+ projectListStore = tx!.objectStore(ProjectDbTable.Projects);
+ }
+ if (!projectListStore.indexNames.contains("modifiedDate")) {
+ projectListStore.createIndex("modifiedDate", "modifiedDate");
+ const now = new Date().valueOf();
+ const updateProjectData = projectListStore.getAll();
+ updateProjectData.onsuccess = () => {
+ updateProjectData.result.forEach((project) => {
+ if (!('modifiedDate' in project)) {
+ projectListStore.put({ ...project, modifiedDate: now });
+ }
+ });
+ };
+ }
+
+ if (!db.objectStoreNames.contains(ProjectDbTable.ProjectData)) {
+ db.createObjectStore(ProjectDbTable.ProjectData, { keyPath: "id" });
+ }
+};
diff --git a/src/project-persistence/project-list-hooks.ts b/src/project-persistence/project-list-hooks.ts
new file mode 100644
index 000000000..6370af193
--- /dev/null
+++ b/src/project-persistence/project-list-hooks.ts
@@ -0,0 +1,117 @@
+import { useCallback, useContext, useEffect } from "react";
+import { ProjectStorageContext } from "./ProjectStorageProvider";
+import { modifyProject, ProjectList, withProjectDb } from "./project-list-db";
+import { makeUID } from "./utils";
+
+export interface DocAccessor {
+ setDoc: (doc: string) => void | Promise;
+ getDoc: () => string | Promise;
+}
+
+export interface NewStoredDoc {
+ id: string;
+ doc: DocAccessor;
+}
+
+export interface RestoredStoredDoc {
+ projectName: string;
+ doc: DocAccessor;
+}
+
+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, setProjectAccessor, openProject } = 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 = await openProject(projectId, () => modifyProject(projectId));
+ setProjectAccessor(newProjectStore);
+ return {
+ doc: newProjectStore,
+ projectName: projectList!.find((prj) => prj.id === projectId)!
+ .projectName,
+ };
+ },
+ [projectList, setProjectAccessor, openProject]
+ );
+
+ 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 = await openProject(newProjectId, () =>
+ modifyProject(newProjectId)
+ );
+ setProjectAccessor(newProjectStore);
+ return { doc: newProjectStore, id: newProjectId };
+ }, [setProjectAccessor, openProject]);
+
+ 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-idb.ts b/src/project-persistence/project-store-idb.ts
new file mode 100644
index 000000000..337c1d8b2
--- /dev/null
+++ b/src/project-persistence/project-store-idb.ts
@@ -0,0 +1,16 @@
+import { ProjectData, ProjectDbTable, withProjectDb } from "./project-list-db";
+
+export const writeProject = (projectId: string, contents: any) => withProjectDb("readwrite", (projects) =>
+
+ new Promise((res, _rej) => {
+ const query = projects.put({ id: projectId, contents })
+ query.onsuccess = () => res();
+ })
+ , ProjectDbTable.ProjectData);
+
+export const readProject = (projectId: string) => withProjectDb("readonly", (projects) =>
+ new Promise((res, _rej) => {
+ const query = projects.get(projectId)
+ query.onsuccess = () => res(query.result as ProjectData);
+ })
+ , ProjectDbTable.ProjectData);
diff --git a/src/project-persistence/project-store-yjs.ts b/src/project-persistence/project-store-yjs.ts
new file mode 100644
index 000000000..4dccf7f6b
--- /dev/null
+++ b/src/project-persistence/project-store-yjs.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 ProjectStoreYjs {
+ 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..c90a943f9
--- /dev/null
+++ b/src/store-persistence-hooks.ts
@@ -0,0 +1,28 @@
+import { useStore } from "./store";
+import { loadNewDoc } from "./store-persistence";
+import { useProjectList } from "./project-persistence/project-list-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 { doc } = await newStoredProject();
+ return doc;
+ }
+ await loadNewDoc(newProjectImpl());
+ await useStore.persist.rehydrate();
+ }
+ const loadProject = async (projectId: string) => {
+ const loadProjectImpl = async () => {
+ const { doc } = await restoreStoredProject(projectId);
+ return doc;
+ }
+ await loadNewDoc(loadProjectImpl());
+ await useStore.persist.rehydrate();
+ }
+ return { loadProject, newProject };
+}
diff --git a/src/store-persistence.ts b/src/store-persistence.ts
new file mode 100644
index 000000000..2614bf592
--- /dev/null
+++ b/src/store-persistence.ts
@@ -0,0 +1,72 @@
+import { PersistStorage, StorageValue } from "zustand/middleware";
+import { DocAccessor } from "./project-persistence/project-list-hooks";
+
+
+interface ProjectState {
+ doc: DocAccessor | 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 getItem = (_name: string) => {
+ const unpackResult = (result: string | Promise) => {
+ if (typeof result === "string") {
+ return JSON.parse(result) as StorageValue;
+ } else {
+ return (async () => JSON.parse(await activeState.doc!.getDoc()) as StorageValue)();
+ }
+ }
+ if (activeState.doc) {
+ return unpackResult(activeState.doc.getDoc());
+ } else {
+ return async () => {
+ await activeState.loadingPromise;
+ return unpackResult(activeState.doc!.getDoc());
+ }
+ }
+ }
+
+ const setItem = (_name: string, value: StorageValue) => {
+ if (activeState.doc) {
+ return activeState.doc.setDoc(JSON.stringify(value));
+ } else {
+ return async () => {
+ await activeState.loadingPromise;
+ return activeState.doc!.setDoc(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..c908d48ab 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -45,6 +45,7 @@ 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";
export const modelUrl = "indexeddb://micro:bit-ai-creator-model";
@@ -1312,6 +1313,8 @@ const createMlStore = (logging: Logging) => {
},
};
},
+ storage: projectStorage(),
+ skipHydration: true
}
),
{ enabled: flags.devtools }