Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

project save/load to browser #158

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
105 changes: 105 additions & 0 deletions gui/src/app/pages/HomePage/BrowserProjectsInterface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
export class BrowserProjectsInterface {
constructor(
private dbName: string = "stan-playground",
private storeName: string = "projects",
) {}
async loadProject(title: string) {
const objectStore = await this.openObjectStore("readonly");
const filename = `${title}.json`;
const content = await this.getTextFile(objectStore, filename);
if (!content) return null;
return JSON.parse(content);
}
async saveProject(title: string, fileManifest: { [name: string]: string }) {
const objectStore = await this.openObjectStore("readwrite");
const filename = `${title}.json`;
return await this.setTextFile(
objectStore,
filename,
JSON.stringify(fileManifest, null, 2),
);
}
async listProjects(): Promise<string[]> {
const objectStore = await this.openObjectStore("readonly");
return new Promise<string[]>((resolve, reject) => {
const request = objectStore.getAllKeys();
request.onsuccess = () => {
resolve(
request.result.map((key) => {
return key.toString().replace(/\.json$/, "");
}),
);
};
request.onerror = () => {
reject(request.error);
};
});
}
async deleteProject(title: string) {
const objectStore = await this.openObjectStore("readwrite");
const filename = `${title}.json`;
await this.deleteTextFile(objectStore, filename);
}
private async openDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(this.dbName);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: "name" });
}
};
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
});
}
private async openObjectStore(mode: IDBTransactionMode) {
const db = await this.openDatabase();
const transaction = db.transaction(this.storeName, mode);
return transaction.objectStore(this.storeName);
}
private async getTextFile(objectStore: IDBObjectStore, filename: string) {
return new Promise<string | null>((resolve, reject) => {
const getRequest = objectStore.get(filename);
getRequest.onsuccess = () => {
resolve(getRequest.result?.content || null);
};
getRequest.onerror = () => {
reject(getRequest.error);
};
});
}
private async setTextFile(
objectStore: IDBObjectStore,
filename: string,
content: string,
) {
return new Promise<void>((resolve, reject) => {
const file = { name: filename, content: content };
const putRequest = objectStore.put(file);
putRequest.onsuccess = () => {
resolve();
};
putRequest.onerror = () => {
reject(putRequest.error);
};
});
}
private async deleteTextFile(objectStore: IDBObjectStore, filename: string) {
return new Promise<void>((resolve, reject) => {
const deleteRequest = objectStore.delete(filename);
deleteRequest.onsuccess = () => {
resolve();
};
deleteRequest.onerror = () => {
reject(deleteRequest.error);
};
});
}
}

export default BrowserProjectsInterface;
75 changes: 73 additions & 2 deletions gui/src/app/pages/HomePage/LoadProjectWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import Button from "@mui/material/Button";
import {
FieldsContentsMap,
FileNames,
Expand All @@ -8,13 +7,18 @@ import {
import { ProjectContext } from "@SpCore/ProjectContextProvider";
import { deserializeZipToFiles, parseFile } from "@SpCore/ProjectSerialization";
import UploadFilesArea from "@SpPages/UploadFilesArea";
import { SmallIconButton } from "@fi-sci/misc";
import { Delete } from "@mui/icons-material";
import { Link } from "@mui/material/Link";
import Button from "@mui/material/Button";
import {
FunctionComponent,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import BrowserProjectsInterface from "./BrowserProjectsInterface";

type LoadProjectWindowProps = {
onClose: () => void;
Expand Down Expand Up @@ -103,9 +107,37 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
}
}, [filesUploaded, importUploadedFiles]);

const [browserProjectTitles, setBrowserProjectTitles] = useState<string[]>(
[],
);
useEffect(() => {
const bpi = new BrowserProjectsInterface();
bpi.listProjects().then((titles) => {
setBrowserProjectTitles(titles);
});
}, []);

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that there is a race condition here if you have two tabs open: saving (or deleting) a project in one will not update the other.

I think this is probably okay, it's not really an intended operational mode or likely to come up, but I wanted to point it out.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean, but this is not going to be a problem since retrieving from browser storage is fast enough.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's a matter of save/retrieve speed--it's if you have the window open already, there isn't anything triggering an update. (At least that's how it looked when I was playing around with it.)

Again, I think the steps to trigger are kind of far-fetched, so I'm not worried, but just want to note everything I see.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see! Didn't read carefully enough :)

const handleOpenBrowserProject = useCallback(
async (title: string) => {
const bpi = new BrowserProjectsInterface();
const fileManifest = await bpi.loadProject(title);
if (!fileManifest) {
alert("Failed to load project");
return;
}
update({
type: "loadFiles",
files: mapFileContentsToModel(fileManifest),
clearExisting: true,
});
onClose();
},
[update, onClose],
);

return (
<div>
<h3>Load project</h3>
<h3>Upload project</h3>
<div>
You can upload:
<ul>
Expand Down Expand Up @@ -142,6 +174,45 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
</Button>
</div>
)}
<h3>Load from browser</h3>
{browserProjectTitles.length > 0 ? (
<table>
<tbody>
{browserProjectTitles.map((title) => (
<tr key={title}>
<td>
<SmallIconButton
icon={<Delete />}
onClick={async () => {
const ok = window.confirm(
`Delete project "${title}" from browser?`,
);
if (!ok) return;
const bpi = new BrowserProjectsInterface();
await bpi.deleteProject(title);
const titles = await bpi.listProjects();
setBrowserProjectTitles(titles);
}}
/>
</td>
<td>
<Link
onClick={() => {
handleOpenBrowserProject(title);
}}
component="button"
underline="none"
>
{title}
</Link>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me like it would also be incredibly helpful to store a timestamp (we could put this in meta.json in general). That way we could display it here if we supported multiple local saves by the same name, or display it in the "are you sure you want to overwrite" dialog so people know when the to-be-overwritten project comes from

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it would be helpful. But I'm wary about including it in meta.json as there's nothing that is enforcing the accuracy of that information if someone edits the project outside of SP. And for Gists, this information would be available independent of the content of meta.json.

Let me put the timestamp in the browser storage record and we'll see how that looks.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a good idea (modulo all the usual complications about timestamps/time zones) but might be for a different PR? I'd want to see the other forms of persistence be aware of it also.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Didn't see your message Jeff until just now.)

I added timestamps for the saved browser projects, but it's not in meta.json.

The timestamps are now shown (in the form of "time-ago-strings", like "3 min ago").

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

</td>
</tr>
))}
</tbody>
</table>
) : (
<div>No projects found in browser storage</div>
)}
</div>
);
};
Expand Down
75 changes: 74 additions & 1 deletion gui/src/app/pages/HomePage/SaveProjectWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ProjectContext } from "@SpCore/ProjectContextProvider";
import saveAsGitHubGist from "@SpCore/gists/saveAsGitHubGist";
import { triggerDownload } from "@SpUtil/triggerDownload";
import Button from "@mui/material/Button";
import BrowserProjectsInterface from "./BrowserProjectsInterface";

type SaveProjectWindowProps = {
onClose: () => void;
Expand All @@ -18,6 +19,7 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
const fileManifest = mapModelToFileManifest(data);

const [exportingToGist, setExportingToGist] = useState(false);
const [savingToBrowser, setSavingToBrowser] = useState(false);

return (
<div>
Expand Down Expand Up @@ -47,7 +49,7 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
</tbody>
</table>
<div>&nbsp;</div>
{!exportingToGist && (
{!exportingToGist && !savingToBrowser && (
<div>
<Button
onClick={async () => {
Expand All @@ -66,6 +68,14 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
>
Save to GitHub Gist
</Button>
&nbsp;
<Button
onClick={() => {
setSavingToBrowser(true);
}}
>
Save to Browser
</Button>
</div>
)}
{exportingToGist && (
Expand All @@ -75,6 +85,13 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
onClose={onClose}
/>
)}
{savingToBrowser && (
<SaveToBrowserView
fileManifest={fileManifest}
title={data.meta.title}
onCancel={onClose}
/>
)}
</div>
);
};
Expand Down Expand Up @@ -211,4 +228,60 @@ const makeSPShareableLinkFromGistUrl = (gistUrl: string) => {
return url;
};

type SaveToBrowserViewProps = {
fileManifest: Partial<FileRegistry>;
title: string;
onCancel: () => void;
};

const SaveToBrowserView: FunctionComponent<SaveToBrowserViewProps> = ({
fileManifest,
title,
onCancel,
}) => {
// use IndexedDB to save the project
// https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB

const handleSave = useCallback(async () => {
try {
const bpi = new BrowserProjectsInterface();
const existingProject = await bpi.loadProject(title);
if (existingProject) {
const overwrite = window.confirm(
`A project with the title "${title}" already exists. Do you want to overwrite it?`,
);
if (!overwrite) {
return;
}
}
await bpi.saveProject(title, fileManifest);
} catch (err: any) {
alert(`Error saving to browser: ${err.message}`);
}
onCancel();
}, [title, fileManifest, onCancel]);

return (
<div className="SaveToBrowserView">
<h3>Save to Browser</h3>
<p>
This project will be saved to your browser as &quot;{title}&quot;.&nbsp;
It will be available to you on this device until you clear your browser
cache, but not on other devices or browsers.
</p>
<div>
<Button
onClick={() => {
handleSave();
}}
>
Save to Browser
</Button>
&nbsp;
<Button onClick={onCancel}>Cancel</Button>
</div>
</div>
);
};

export default SaveProjectWindow;
Loading