Skip to content

Commit a53ee74

Browse files
authored
Merge pull request #346 from flatironinstitute/project-url-compressed-string
Add quick share option that stores project in the link
2 parents 5e744a0 + 6afec19 commit a53ee74

18 files changed

+392
-233
lines changed

gui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@octokit/rest": "^21.1.1",
3030
"@vercel/analytics": "^1.3.1",
3131
"jszip": "^3.10.1",
32+
"lz-string": "^1.5.0",
3233
"mcmc-stats": "^0.0.1",
3334
"plotly-stan-playground-dist": "file:./vendor/plotly-stan-playground-dist",
3435
"pyodide": "^0.29.0",
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { FunctionComponent } from "react";
2+
3+
import IconButton from "@mui/material/IconButton";
4+
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
5+
import Stack from "@mui/material/Stack";
6+
import Link from "@mui/material/Link";
7+
import Tooltip from "@mui/material/Tooltip";
8+
9+
type Props = {
10+
link: string;
11+
};
12+
13+
const CopyableLink: FunctionComponent<Props> = ({ link }) => {
14+
return (
15+
<Stack direction="row" spacing={1} alignItems="center">
16+
<Link href={link} target="_blank" rel="noreferrer" noWrap>
17+
{link}
18+
</Link>
19+
<Tooltip title="Copy link to clipboard">
20+
<IconButton
21+
onClick={() => {
22+
navigator.clipboard.writeText(link);
23+
}}
24+
aria-label="copy"
25+
size="small"
26+
>
27+
<ContentCopyIcon fontSize="small" />
28+
</IconButton>
29+
</Tooltip>
30+
</Stack>
31+
);
32+
};
33+
34+
export default CopyableLink;

gui/src/app/core/Project/ProjectContextProvider.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import ProjectReducer, {
1212
ProjectReducerAction,
1313
} from "@SpCore/Project/ProjectReducer";
1414
import {
15-
deserializeProjectFromLocalStorage,
16-
serializeProjectToLocalStorage,
15+
deserializeProjectFromString,
16+
serializeProjectToString,
1717
} from "@SpCore/Project/ProjectSerialization";
1818
import {
1919
createContext,
@@ -49,7 +49,7 @@ const ProjectContextProvider: FunctionComponent<
4949
// as user reloads the page or closes the tab, save state to local storage
5050
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
5151
if (persist) {
52-
const state = serializeProjectToLocalStorage(data);
52+
const state = serializeProjectToString(data);
5353
localStorage.setItem("stan-playground-saved-state", state);
5454
if (modelHasUnsavedChanges(data)) {
5555
e.preventDefault();
@@ -84,7 +84,7 @@ const ProjectContextProvider: FunctionComponent<
8484
if (persist) {
8585
const savedState = localStorage.getItem("stan-playground-saved-state");
8686
if (!savedState) return;
87-
const parsedData = deserializeProjectFromLocalStorage(savedState);
87+
const parsedData = deserializeProjectFromString(savedState);
8888
if (!parsedData) return; // unsuccessful parse or type cast
8989
update({ type: "loadInitialData", state: parsedData });
9090
}

gui/src/app/core/Project/ProjectQueryLoading.ts

Lines changed: 113 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ import { mapFileContentsToModel } from "@SpCore/Project/FileMapping";
22
import loadFilesFromGist from "@SpUtil/gists/loadFilesFromGist";
33
import {
44
ProjectDataModel,
5+
ProjectKnownFiles,
6+
SamplingOpts,
57
defaultSamplingOpts,
68
initialDataModel,
79
parseSamplingOpts,
810
persistStateToEphemera,
911
validateSamplingOpts,
1012
} from "@SpCore/Project/ProjectDataModel";
11-
import { loadFromProjectFiles } from "@SpCore/Project/ProjectSerialization";
13+
import {
14+
deserializeProjectFromURLParameter,
15+
hasKnownProjectParameterPrefix,
16+
loadFromProjectFiles,
17+
} from "@SpCore/Project/ProjectSerialization";
1218
import { tryFetch } from "@SpUtil/tryFetch";
1319

1420
export enum QueryParamKeys {
@@ -60,145 +66,135 @@ export const fromQueryParams = (searchParams: URLSearchParams) => {
6066
return queries;
6167
};
6268

69+
const queryParamToDataModelFieldMap = {
70+
[QueryParamKeys.StanFile]: ProjectKnownFiles.STANFILE,
71+
[QueryParamKeys.DataFile]: ProjectKnownFiles.DATAFILE,
72+
[QueryParamKeys.AnalysisPyFile]: ProjectKnownFiles.ANALYSISPYFILE,
73+
[QueryParamKeys.AnalysisRFile]: ProjectKnownFiles.ANALYSISRFILE,
74+
[QueryParamKeys.DataPyFile]: ProjectKnownFiles.DATAPYFILE,
75+
[QueryParamKeys.DataRFile]: ProjectKnownFiles.DATARFILE,
76+
} as const;
77+
6378
export const queryStringHasParameters = (query: QueryParams) => {
6479
return Object.values(query).some((v) => v !== null);
6580
};
6681

6782
export const fetchRemoteProject = async (query: QueryParams) => {
68-
const projectUri = query.project;
69-
70-
let data: ProjectDataModel = structuredClone(initialDataModel);
71-
if (projectUri) {
72-
if (projectUri.startsWith("https://gist.github.com/")) {
73-
let contentLoadedFromGist: {
74-
files: { [key: string]: string };
75-
description: string;
76-
};
77-
try {
78-
contentLoadedFromGist = await loadFilesFromGist(projectUri);
79-
} catch (err) {
80-
console.error("Failed to load content from gist", err);
81-
alert(`Failed to load content from gist ${projectUri}`);
82-
// do not continue with any other query parameters if we failed to load the gist
83-
return persistStateToEphemera(data);
84-
}
85-
data = loadFromProjectFiles(
86-
data,
87-
mapFileContentsToModel(contentLoadedFromGist.files),
88-
false,
89-
);
90-
data.meta.title = contentLoadedFromGist.description;
91-
return persistStateToEphemera(data);
92-
} else {
93-
// right now we only support loading from a gist
94-
console.error("Unsupported project URI", projectUri);
95-
}
83+
if (query.project) {
84+
// other parameters are ignored whenever project= is set
85+
return await loadFromProjectParameter(query.project);
9686
}
9787

98-
const stanFilePromise = query.stan
99-
? tryFetch(query.stan)
100-
: Promise.resolve(data.stanFileContent);
101-
const dataFilePromise = query.data
102-
? tryFetch(query.data)
103-
: Promise.resolve(data.dataFileContent);
104-
const analysisPyFilePromise = query["analysis_py"]
105-
? tryFetch(query["analysis_py"])
106-
: Promise.resolve(data.analysisPyFileContent);
107-
const analysisRFilePromise = query["analysis_r"]
108-
? tryFetch(query["analysis_r"])
109-
: Promise.resolve(data.analysisRFileContent);
110-
const dataPyFilePromise = query["data_py"]
111-
? tryFetch(query["data_py"])
112-
: Promise.resolve(data.dataPyFileContent);
113-
const dataRFilePromise = query["data_r"]
114-
? tryFetch(query["data_r"])
115-
: Promise.resolve(data.dataRFileContent);
116-
const sampling_optsPromise = query.sampling_opts
117-
? tryFetch(query.sampling_opts)
118-
: Promise.resolve(null);
119-
120-
const stanFileContent = await stanFilePromise;
121-
if (stanFileContent !== undefined) {
122-
data.stanFileContent = stanFileContent;
123-
} else {
124-
data.stanFileContent = `// Failed to load content from ${query.stan}`;
125-
}
88+
const data: ProjectDataModel = structuredClone(initialDataModel);
12689

127-
const dataFileContent = await dataFilePromise;
128-
if (dataFileContent !== undefined) {
129-
data.dataFileContent = dataFileContent;
130-
} else {
131-
data.dataFileContent = `// Failed to load content from ${query.data}`;
90+
if (query.title) {
91+
data.meta.title = query.title;
13292
}
13393

134-
const analysisPyFileContent = await analysisPyFilePromise;
135-
if (analysisPyFileContent !== undefined) {
136-
data.analysisPyFileContent = analysisPyFileContent;
137-
} else {
138-
data.analysisPyFileContent = `# Failed to load content from ${query["analysis_py"]}`;
139-
}
94+
const fetchFileForParameter = async (
95+
param: keyof typeof queryParamToDataModelFieldMap,
96+
comment: string = "# ",
97+
) => {
98+
if (query[param]) {
99+
const value = await tryFetch(query[param]);
100+
return value ?? `${comment}Failed to load content from ${query[param]}`;
101+
}
102+
return data[queryParamToDataModelFieldMap[param]];
103+
};
140104

141-
const analysisRFileContent = await analysisRFilePromise;
142-
if (analysisRFileContent !== undefined) {
143-
data.analysisRFileContent = analysisRFileContent;
144-
} else {
145-
data.analysisRFileContent = `# Failed to load content from ${query["analysis_r"]}`;
146-
}
105+
[
106+
data.stanFileContent,
107+
data.dataFileContent,
108+
data.analysisPyFileContent,
109+
data.analysisRFileContent,
110+
data.dataPyFileContent,
111+
data.dataRFileContent,
112+
data.samplingOpts,
113+
] = await Promise.all([
114+
fetchFileForParameter(QueryParamKeys.StanFile, "// "),
115+
fetchFileForParameter(QueryParamKeys.DataFile, "// "),
116+
fetchFileForParameter(QueryParamKeys.AnalysisPyFile),
117+
fetchFileForParameter(QueryParamKeys.AnalysisRFile),
118+
fetchFileForParameter(QueryParamKeys.DataPyFile),
119+
fetchFileForParameter(QueryParamKeys.DataRFile),
120+
loadSamplingOptsFromQueryParams(query),
121+
]);
147122

148-
const dataPyFileContent = await dataPyFilePromise;
149-
if (dataPyFileContent !== undefined) {
150-
data.dataPyFileContent = dataPyFileContent;
151-
} else {
152-
data.dataPyFileContent = `# Failed to load content from ${query["data_py"]}`;
153-
}
123+
return persistStateToEphemera(data);
124+
};
154125

155-
const dataRFileContent = await dataRFilePromise;
156-
if (dataRFileContent !== undefined) {
157-
data.dataRFileContent = dataRFileContent;
126+
const loadFromProjectParameter = async (projectParam: string) => {
127+
if (projectParam.startsWith("https://gist.github.com/")) {
128+
try {
129+
const contentLoadedFromGist = await loadFilesFromGist(projectParam);
130+
const dataFromGist = loadFromProjectFiles(
131+
mapFileContentsToModel(contentLoadedFromGist.files),
132+
);
133+
dataFromGist.meta.title = contentLoadedFromGist.description;
134+
return persistStateToEphemera(dataFromGist);
135+
} catch (err) {
136+
console.error("Failed to load content from gist", err);
137+
alert(`Failed to load content from gist ${projectParam}`);
138+
}
139+
} else if (hasKnownProjectParameterPrefix(projectParam)) {
140+
try {
141+
const dataFromParam = deserializeProjectFromURLParameter(projectParam);
142+
if (dataFromParam) {
143+
return persistStateToEphemera(dataFromParam);
144+
} else {
145+
throw new Error("Failed to deserialize project from URL parameter");
146+
}
147+
} catch (err) {
148+
console.error("Failed to load content from project string", err);
149+
alert("Failed to load content from compressed project");
150+
}
158151
} else {
159-
data.dataRFileContent = `# Failed to load content from ${query["data_r"]}`;
152+
console.error("Unsupported project parameter type", projectParam);
160153
}
154+
return initialDataModel;
155+
};
161156

162-
const sampling_opts = await sampling_optsPromise;
163-
if (sampling_opts === undefined) {
164-
const msg = `Failed to load content from ${query["sampling_opts"]}`;
165-
alert(msg);
166-
console.error(msg);
167-
} else if (sampling_opts !== null) {
157+
const loadSamplingOptsFromQueryParams = async (query: QueryParams) => {
158+
// try to load json of all opts
159+
if (query.sampling_opts) {
160+
const sampling_opts = await tryFetch(query.sampling_opts);
161+
if (sampling_opts === undefined) {
162+
const msg = `Failed to load content from ${query["sampling_opts"]}`;
163+
alert(msg);
164+
console.error(msg);
165+
return defaultSamplingOpts;
166+
}
168167
try {
169-
data.samplingOpts = parseSamplingOpts(sampling_opts);
168+
return parseSamplingOpts(sampling_opts);
170169
} catch (err) {
171170
console.error("Failed to parse sampling_opts", err);
172171
alert("Invalid sampling options: " + sampling_opts);
173172
}
174-
} else {
175-
if (query.num_chains) {
176-
data.samplingOpts.num_chains = parseInt(query.num_chains);
177-
}
178-
if (query.num_warmup) {
179-
data.samplingOpts.num_warmup = parseInt(query.num_warmup);
180-
}
181-
if (query.num_samples) {
182-
data.samplingOpts.num_samples = parseInt(query.num_samples);
183-
}
184-
if (query.init_radius) {
185-
data.samplingOpts.init_radius = parseFloat(query.init_radius);
186-
}
187-
if (query.seed) {
188-
data.samplingOpts.seed =
189-
query.seed === "undefined" ? undefined : parseInt(query.seed);
190-
}
191-
192-
if (!validateSamplingOpts(data.samplingOpts)) {
193-
console.error("Invalid sampling options", data.samplingOpts);
194-
alert("Invalid sampling options: " + JSON.stringify(data.samplingOpts));
195-
data.samplingOpts = defaultSamplingOpts;
196-
}
197173
}
198174

199-
if (query.title) {
200-
data.meta.title = query.title;
175+
// load individual opts from query params
176+
const samplingOpts: SamplingOpts = { ...defaultSamplingOpts };
177+
if (query.num_chains) {
178+
samplingOpts.num_chains = parseInt(query.num_chains);
179+
}
180+
if (query.num_warmup) {
181+
samplingOpts.num_warmup = parseInt(query.num_warmup);
182+
}
183+
if (query.num_samples) {
184+
samplingOpts.num_samples = parseInt(query.num_samples);
185+
}
186+
if (query.init_radius) {
187+
samplingOpts.init_radius = parseFloat(query.init_radius);
188+
}
189+
if (query.seed) {
190+
samplingOpts.seed =
191+
query.seed === "undefined" ? undefined : parseInt(query.seed);
201192
}
202193

203-
return persistStateToEphemera(data);
194+
if (!validateSamplingOpts(samplingOpts)) {
195+
console.error("Invalid sampling options", samplingOpts);
196+
alert("Invalid sampling options: " + JSON.stringify(samplingOpts));
197+
return defaultSamplingOpts;
198+
}
199+
return samplingOpts;
204200
};

gui/src/app/core/Project/ProjectReducer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const ProjectReducer = (s: ProjectDataModel, a: ProjectReducerAction) => {
4747
switch (a.type) {
4848
case "loadFiles": {
4949
try {
50-
return loadFromProjectFiles(s, a.files, a.clearExisting);
50+
return loadFromProjectFiles(a.files, a.clearExisting ? undefined : s);
5151
} catch (e) {
5252
// probably sampling opts or meta files were not valid
5353
console.error("Error loading files", e);

0 commit comments

Comments
 (0)