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

frontend: import/export #53

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
"@monaco-editor/react": "^4.6.0",
"@mui/icons-material": "^5.15.17",
"@mui/material": "^5.15.17",
"jszip": "^3.10.1",
"monaco-editor": "^0.48.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-markdown": "^8",
"react-router-dom": "^6.17.0",
"react-syntax-highlighter": "^15.5.0",
Expand Down
101 changes: 101 additions & 0 deletions gui/src/app/pages/HomePage/ExportWindow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { FunctionComponent, useMemo } from "react"
import { useSPAnalysis } from "../../SPAnalysis/SPAnalysisContext"
import JSZip from 'jszip'

type ExportWindowProps = {
onClose: () => void
}

const ExportWindow: FunctionComponent<ExportWindowProps> = ({ onClose }) => {
const { localDataModel } = useSPAnalysis()

const files = useMemo(() => {
return {
'main.stan': localDataModel.stanFileContent,
'data.json': localDataModel.dataFileContent,
'sampling_opts.json': localDataModel.samplingOptsContent,
'meta.json': JSON.stringify({
// Even though the folder name is derived from the
// title, we still include it in a meta file because
// we want to preserve the spaces in the title. When
// loading, if the meta.json is not present, we will
// use the folder name to derive the title.
title: localDataModel.title
})
}
}, [localDataModel])

return (
<div>
<h3>Export this analysis</h3>
<table className="table1">
<tbody>
<tr>
<td>Title</td>
<td>
<EditTitleComponent
value={localDataModel.title}
onChange={localDataModel.setTitle}
/>
</td>
</tr>
{
Object.entries(files).map(([name, content], i) => (
<tr key={i}>
<td>{name}</td>
<td>
{content.length} bytes
</td>
</tr>
))
}
</tbody>
</table>
<div>
<button onClick={async () => {
const title = localDataModel.title
const folderName = replaceSpaces(title)
const zip = new JSZip()
const folder = zip.folder(folderName)
if (!folder) {
throw new Error('Could not create folder in zip file')
}
Object.entries(files).forEach(([name, content]) => {
folder.file(name, content)
})
const zipBlob = await zip.generateAsync({type: 'blob'})
const zipBlobUrl = URL.createObjectURL(zipBlob)
const a = document.createElement('a')
a.href = zipBlobUrl
a.download = `SP-${folderName}.zip`
a.click()
URL.revokeObjectURL(zipBlobUrl)
onClose()
}}>
Export to .zip file
</button>
</div>
</div>
)
}

const replaceSpaces = (str: string) => {
return str.replace(/ /g, '_')
}

type EditTitleComponentProps = {
value: string
onChange: (value: string) => void
}

const EditTitleComponent: FunctionComponent<EditTitleComponentProps> = ({ value, onChange }) => {
return (
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
/>
)
}

export default ExportWindow
6 changes: 6 additions & 0 deletions gui/src/app/pages/HomePage/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ const HomePageChild: FunctionComponent<Props> = ({ width, height }) => {
document.title = route?.title ?? 'stan-playground'
}, [route.title])

const hasUnsavedChanges = useMemo(() => {
return editedStanFileContent !== localDataModel.stanFileContent
|| editedDataFileContent !== localDataModel.dataFileContent
}, [editedStanFileContent, editedDataFileContent, localDataModel.stanFileContent, localDataModel.dataFileContent])

return (
<div style={{ position: 'absolute', width, height, overflow: 'hidden' }}>
<div className="top-bar" style={{ position: 'absolute', left: 0, top: 0, width, height: topBarHeight, overflow: 'hidden' }}>
Expand All @@ -86,6 +91,7 @@ const HomePageChild: FunctionComponent<Props> = ({ width, height }) => {
<LeftPanel
width={leftPanelWidth}
height={height - topBarHeight - 2}
hasUnsavedChanges={hasUnsavedChanges}
/>
</div>
<div className="main-area" style={{ position: 'absolute', left: leftPanelWidth, top: topBarHeight + 2, width: width - leftPanelWidth, height: height - topBarHeight - 2, overflow: 'hidden' }}>
Expand Down
186 changes: 186 additions & 0 deletions gui/src/app/pages/HomePage/ImportWindow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { FunctionComponent, useCallback, useEffect, useState } from "react"
import { useSPAnalysis } from "../../SPAnalysis/SPAnalysisContext"
import JSZip from 'jszip'
import UploadFilesArea from "./UploadFilesArea"

type ImportWindowProps = {
onClose: () => void
}

const ImportWindow: FunctionComponent<ImportWindowProps> = ({ onClose }) => {
const { localDataModel } = useSPAnalysis()
const [errorText, setErrorText] = useState<string | null>(null)
const [filesUploaded, setFilesUploaded] = useState<{name: string, content: ArrayBuffer}[] | null>(null)
const [showReplaceProjectOptions, setShowReplaceProjectOptions] = useState<boolean>(false)

const importUploadedFiles = useCallback(async (o: {replaceProject: boolean}) => {
const {replaceProject} = o
if (!filesUploaded) return
try {
if ((filesUploaded.length === 1) && (filesUploaded[0].name.endsWith('.zip'))) {
// a single .zip file
await loadFromZip(localDataModel, filesUploaded[0].name, filesUploaded[0].content, {replaceProject})
}
else if ((filesUploaded.length === 1) && (filesUploaded[0].name.endsWith('.stan'))) {
// a single .stan file
await loadFromStanFile(localDataModel, filesUploaded[0].name, filesUploaded[0].content, {replaceProject})
}
else if ((filesUploaded.length === 1) && (filesUploaded[0].name === 'data.json')) {
// a single data.json file
await loadFromFiles(localDataModel, filesUploaded, {replaceProject})
}
else {
for (const file of filesUploaded) {
if (!['main.stan', 'data.json', 'sampling_opts.json', 'meta.json'].includes(file.name)) {
throw Error('Unrecognized file: ' + file.name)
}
}
await loadFromFiles(localDataModel, filesUploaded, {replaceProject})
}
onClose()
}
catch (e: any) {
setErrorText(e.message)
}
}, [filesUploaded, localDataModel, onClose])

useEffect(() => {
if (!filesUploaded) return
if ((filesUploaded.length === 1) && (!filesUploaded[0].name.endsWith('.zip'))) {
// The user has uploaded a single file and it is not a zip file. In
// this case we want to give the user the option whether or not to
// replace the current project.
setShowReplaceProjectOptions(true)
}
else {
// Otherwise, we just go ahead and import the files, replacing the
// entire project
importUploadedFiles({replaceProject: true})
}
}, [filesUploaded, importUploadedFiles])

return (
<div>
<h3>Import analysis</h3>
<div>
You can upload:
<ul>
<li>A .zip file that was previously exported</li>
<li>A directory of files that were extracted from an exported .zip file</li>
<li>An individual *.stan file</li>
<li>An individual data.json file</li>
</ul>
</div>
<div style={{color: 'red'}}>
{errorText}
</div>
{!filesUploaded ? (
<div>
<UploadFilesArea
height={300}
onUpload={setFilesUploaded}
/>
</div>
) : (
<div>
{filesUploaded.map(file => (
<div key={file.name}>
{file.name}
</div>
))}
</div>
)}
{
showReplaceProjectOptions && (
<div>
<button onClick={() => importUploadedFiles({replaceProject: true})}>Import into a NEW project</button>
&nbsp;
<button onClick={() => importUploadedFiles({replaceProject: false})}>Import into EXISTING project</button>
</div>
)
}
</div>
)
}

const loadFromFiles = async (localDataModel: any, files: {
name: string,
content: ArrayBuffer
}[], o: {replaceProject: boolean}) => {
const stanFileContent = files.find(file => file.name === 'main.stan')?.content
const dataFileContent = files.find(file => file.name === 'data.json')?.content
const samplingOptsContent = files.find(file => file.name === 'sampling_opts.json')?.content
const metaContent = files.find(file => file.name === 'meta.json')?.content

if (stanFileContent || o.replaceProject) {
localDataModel.setStanFileContent(stanFileContent ? new TextDecoder().decode(stanFileContent) : '')
}
if (dataFileContent || o.replaceProject) {
localDataModel.setDataFileContent(dataFileContent ? new TextDecoder().decode(dataFileContent) : '')
}
if (samplingOptsContent || o.replaceProject) {
localDataModel.setSamplingOptsContent(samplingOptsContent ? new TextDecoder().decode(samplingOptsContent) : '')
}
if (metaContent || o.replaceProject) {
const meta = metaContent ? JSON.parse(new TextDecoder().decode(metaContent)) : {}
localDataModel.setTitle(meta.title || 'Untitled')
}
}

const loadFromStanFile = async (localDataModel: any, name: string, content: ArrayBuffer, o: {replaceProject: boolean}) => {
await loadFromFiles(localDataModel, [
{name: 'main.stan', content},
], {replaceProject: o.replaceProject})
if (o.replaceProject) {
localDataModel.setTitle(name)
}
}

const loadFromZip = async (localDataModel: any, _name: string, content: ArrayBuffer, o: {replaceProject: boolean}) => {
const zip = await JSZip.loadAsync(content)

// check for a single folder
let folderName: string | undefined = undefined
for (const name in zip.files) {
if (zip.files[name].dir) {
if (folderName) {
throw new Error('Multiple folders in zip file')
}
else {
folderName = name
}
}
}
if (folderName) {
// check that all the files are in single folder
for (const name in zip.files) {
if (!name.startsWith(folderName)) {
throw new Error('Files are not all in a single folder')
}
}
}
const files: {name: string, content: ArrayBuffer}[] = []
for (const name in zip.files) {
const f = zip.files[name]
if (!f.dir) {
if (f.name === `${(folderName || '')}main.stan`) {
files.push({name: 'main.stan', content: await f.async('arraybuffer')})
}
else if (f.name === `${(folderName || '')}data.json`) {
files.push({name: 'data.json', content: await f.async('arraybuffer')})
}
else if (f.name === `${(folderName || '')}sampling_opts.json`) {
files.push({name: 'sampling_opts.json', content: await f.async('arraybuffer')})
}
else if (f.name === `${(folderName || '')}meta.json`) {
files.push({name: 'meta.json', content: await f.async('arraybuffer')})
}
else {
throw new Error(`Unrecognized file in zip: ${f.name}`)
}
}
}
await loadFromFiles(localDataModel, files, {replaceProject: o.replaceProject})
}

export default ImportWindow
42 changes: 41 additions & 1 deletion gui/src/app/pages/HomePage/LeftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import examplesStanies, { Stanie } from "../../exampleStanies/exampleStanies"
import { Hyperlink } from "@fi-sci/misc"
import { useSPAnalysis } from "../../SPAnalysis/SPAnalysisContext"
import { defaultSamplingOpts } from "../../StanSampler/StanSampler"
import ModalWindow, { useModalWindow } from "@fi-sci/modal-window"
import ExportWindow from "./ExportWindow"
import ImportWindow from "./ImportWindow"

type LeftPanelProps = {
width: number
height: number
hasUnsavedChanges: boolean
}

const LeftPanel: FunctionComponent<LeftPanelProps> = ({ width, height }) => {
const LeftPanel: FunctionComponent<LeftPanelProps> = ({ width, height, hasUnsavedChanges }) => {
const {
localDataModel
} = useSPAnalysis()
Expand All @@ -20,6 +24,10 @@ const LeftPanel: FunctionComponent<LeftPanelProps> = ({ width, height }) => {
localDataModel.setSamplingOptsContent(JSON.stringify(defaultSamplingOpts, null, 2))
localDataModel.setTitle(stanie.meta.title || 'Untitled')
}, [localDataModel])

const { visible: exportVisible, handleOpen: exportOpen, handleClose: exportClose } = useModalWindow()
const { visible: importVisible, handleOpen: importOpen, handleClose: importClose } = useModalWindow()

return (
<div style={{position: 'absolute', width, height, backgroundColor: 'lightgray', overflowY: 'auto'}}>
<div style={{margin: 5}}>
Expand All @@ -33,6 +41,7 @@ const LeftPanel: FunctionComponent<LeftPanelProps> = ({ width, height }) => {
</div>
))
}
<hr />
<div>
{/* This will probably be removed or replaced in the future. It's just for convenience during development. */}
<button onClick={() => {
Expand All @@ -46,7 +55,38 @@ const LeftPanel: FunctionComponent<LeftPanelProps> = ({ width, height }) => {
This panel will have controls for loading/saving data from cloud
</p>
</div>
<div>
<button
onClick={importOpen}
disabled={hasUnsavedChanges}
>
Import
</button>
&nbsp;
<button
onClick={exportOpen}
disabled={hasUnsavedChanges}
>
Export
</button>
</div>
</div>
<ModalWindow
visible={importVisible}
onClose={importClose}
>
<ImportWindow
onClose={importClose}
/>
</ModalWindow>
<ModalWindow
visible={exportVisible}
onClose={exportClose}
>
<ExportWindow
onClose={exportClose}
/>
</ModalWindow>
</div>
)
}
Expand Down
Loading