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
Prev Previous commit
Next Next commit
timestamps for saved browser projects
Add browser project functions and time ago string utility
  • Loading branch information
magland committed Jul 29, 2024
commit 041176ab31c889a0dedba4b4cb1e850a7ebaf9bc
78 changes: 57 additions & 21 deletions gui/src/app/pages/HomePage/BrowserProjectsInterface.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,59 @@
import baseObjectCheck from "@SpUtil/baseObjectCheck";

export type BrowserProject = {
title: string;
timestamp: number;
fileManifest: { [name: string]: string };
};

const isBrowserProject = (value: any): value is BrowserProject => {
if (!baseObjectCheck(value)) return false;
if (typeof value.timestamp !== "number") return false;
if (!baseObjectCheck(value.fileManifest)) return false;
for (const key in value.fileManifest) {
if (typeof key !== "string") return false;
if (typeof value.fileManifest[key] !== "string") return false;
}
return true;
};

export class BrowserProjectsInterface {
constructor(
private dbName: string = "stan-playground",
private storeName: string = "projects",
private dbVersion: number = 2,
private storeName: string = "browser-projects",
) {}
async loadProject(title: string) {
async loadBrowserProject(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);
const bp = JSON.parse(content);
if (!isBrowserProject(bp)) {
console.warn(`Invalid browser project: ${title}`);
return null;
}
return bp;
}
async saveProject(title: string, fileManifest: { [name: string]: string }) {
async saveBrowserProject(title: string, browserProject: BrowserProject) {
const objectStore = await this.openObjectStore("readwrite");
const filename = `${title}.json`;
return await this.setTextFile(
objectStore,
filename,
JSON.stringify(fileManifest, null, 2),
JSON.stringify(browserProject, 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 getAllBrowserProjects() {
const titles = await this.getAllProjectTitles();
const browserProjects = [];
for (const title of titles) {
const browserProject = await this.loadBrowserProject(title);
if (browserProject) {
browserProjects.push(browserProject);
}
}
return browserProjects;
}
async deleteProject(title: string) {
const objectStore = await this.openObjectStore("readwrite");
Expand All @@ -42,7 +62,7 @@ export class BrowserProjectsInterface {
}
private async openDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(this.dbName);
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(this.storeName)) {
Expand All @@ -62,6 +82,22 @@ export class BrowserProjectsInterface {
const transaction = db.transaction(this.storeName, mode);
return transaction.objectStore(this.storeName);
}
private async getAllProjectTitles(): 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);
};
});
}
private async getTextFile(objectStore: IDBObjectStore, filename: string) {
return new Promise<string | null>((resolve, reject) => {
const getRequest = objectStore.get(filename);
Expand Down
45 changes: 27 additions & 18 deletions gui/src/app/pages/HomePage/LoadProjectWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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 Link from "@mui/material/Link";
import Button from "@mui/material/Button";
import {
FunctionComponent,
Expand All @@ -18,7 +18,10 @@ import {
useEffect,
useState,
} from "react";
import BrowserProjectsInterface from "./BrowserProjectsInterface";
import BrowserProjectsInterface, {
BrowserProject,
} from "./BrowserProjectsInterface";
import timeAgoString from "@SpUtil/timeAgoString";

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

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

const handleOpenBrowserProject = useCallback(
async (title: string) => {
const bpi = new BrowserProjectsInterface();
const fileManifest = await bpi.loadProject(title);
if (!fileManifest) {
const browserProject = await bpi.loadBrowserProject(title);
if (!browserProject) {
alert("Failed to load project");
return;
}
const { fileManifest } = browserProject;
update({
type: "loadFiles",
files: mapFileContentsToModel(fileManifest),
Expand Down Expand Up @@ -175,37 +179,42 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
</div>
)}
<h3>Load from browser</h3>
{browserProjectTitles.length > 0 ? (
{allBrowserProjects.length > 0 ? (
<table>
<tbody>
{browserProjectTitles.map((title) => (
<tr key={title}>
{allBrowserProjects.map((browserProject) => (
<tr key={browserProject.title}>
<td>
<SmallIconButton
icon={<Delete />}
onClick={async () => {
const ok = window.confirm(
`Delete project "${title}" from browser?`,
`Delete project "${browserProject.title}" from browser?`,
);
if (!ok) return;
const bpi = new BrowserProjectsInterface();
await bpi.deleteProject(title);
const titles = await bpi.listProjects();
setBrowserProjectTitles(titles);
await bpi.deleteProject(browserProject.title);
const p = await bpi.getAllBrowserProjects();
setAllBrowserProjects(p);
}}
/>
</td>
<td>
<Link
onClick={() => {
handleOpenBrowserProject(title);
handleOpenBrowserProject(browserProject.title);
}}
component="button"
underline="none"
>
{title}
{browserProject.title}
</Link>
</td>
<td>
<span style={{ color: "gray" }}>
{timeAgoString(browserProject.timestamp)}
</span>
</td>
</tr>
))}
</tbody>
Expand Down
13 changes: 9 additions & 4 deletions gui/src/app/pages/HomePage/SaveProjectWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import saveAsGitHubGist from "@SpCore/gists/saveAsGitHubGist";
import { triggerDownload } from "@SpUtil/triggerDownload";
import Button from "@mui/material/Button";
import BrowserProjectsInterface from "./BrowserProjectsInterface";
import timeAgoString from "@SpUtil/timeAgoString";

type SaveProjectWindowProps = {
onClose: () => void;
Expand Down Expand Up @@ -245,16 +246,20 @@ const SaveToBrowserView: FunctionComponent<SaveToBrowserViewProps> = ({
const handleSave = useCallback(async () => {
try {
const bpi = new BrowserProjectsInterface();
const existingProject = await bpi.loadProject(title);
if (existingProject) {
const existingBrowserProject = await bpi.loadBrowserProject(title);
if (existingBrowserProject) {
const overwrite = window.confirm(
`A project with the title "${title}" already exists. Do you want to overwrite it?`,
`A project with the title "${title}" already exists (modified ${timeAgoString(existingBrowserProject.timestamp)}). Do you want to overwrite it?`,
);
if (!overwrite) {
return;
}
}
await bpi.saveProject(title, fileManifest);
await bpi.saveBrowserProject(title, {
title,
timestamp: Date.now(),
fileManifest,
});
} catch (err: any) {
alert(`Error saving to browser: ${err.message}`);
}
Expand Down
28 changes: 28 additions & 0 deletions gui/src/app/util/timeAgoString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const timeAgoString = (timestampMsec?: number) => {
if (timestampMsec === undefined) return "";
const timestampSeconds = Math.floor(timestampMsec / 1000);
const now = Date.now();
const diff = now - timestampSeconds * 1000;
const diffSeconds = Math.floor(diff / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffWeeks / 4);
const diffYears = Math.floor(diffMonths / 12);
if (diffYears > 0) {
return `${diffYears} yr${diffYears === 1 ? "" : "s"} ago`;
} else if (diffWeeks > 0) {
return `${diffWeeks} wk${diffWeeks === 1 ? "" : "s"} ago`;
} else if (diffDays > 0) {
return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
} else if (diffHours > 0) {
return `${diffHours} hr${diffHours === 1 ? "" : "s"} ago`;
} else if (diffMinutes > 0) {
return `${diffMinutes} min ago`;
} else {
return `${diffSeconds} sec ago`;
}
};

export default timeAgoString;