Skip to content

Commit 012ecc8

Browse files
authored
feat: [UI] Jupyter Notebook export in Frontend (#343)
1 parent 0bdd3e7 commit 012ecc8

File tree

4 files changed

+191
-0
lines changed

4 files changed

+191
-0
lines changed

ui/src/components/Sidebar.tsx

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,168 @@ function ExportJSON() {
534534
);
535535
}
536536

537+
function ExportJupyterNB() {
538+
const { id: repoId } = useParams();
539+
const store = useContext(RepoContext);
540+
if (!store) throw new Error("Missing BearContext.Provider in the tree");
541+
const repoName = useStore(store, (state) => state.repoName);
542+
const pods = useStore(store, (state) => state.pods);
543+
const filename = `${
544+
repoName || "Untitled"
545+
}-${new Date().toISOString()}.ipynb`;
546+
const [loading, setLoading] = useState(false);
547+
548+
const onClick = () => {
549+
setLoading(true);
550+
551+
// Hard-code Jupyter cell format. Reference, https://nbformat.readthedocs.io/en/latest/format_description.html
552+
let jupyterCellList: {
553+
cell_type: string;
554+
execution_count: number;
555+
metadata: object;
556+
source: string[];
557+
}[] = [];
558+
559+
// Queue to sort the pods geographically
560+
let q = new Array();
561+
// adjacency list for podId -> parentId mapping
562+
let adj = {};
563+
q.push([pods["ROOT"], "0.0"]);
564+
while (q.length > 0) {
565+
let [curPod, curScore] = q.shift();
566+
567+
// sort the pods geographically(top-down, left-right)
568+
let sortedChildren = curPod.children
569+
.map((x) => x.id)
570+
.sort((id1, id2) => {
571+
let pod1 = pods[id1];
572+
let pod2 = pods[id2];
573+
if (pod1 && pod2) {
574+
if (pod1.y === pod2.y) {
575+
return pod1.x - pod2.x;
576+
} else {
577+
return pod1.y - pod2.y;
578+
}
579+
} else {
580+
return 0;
581+
}
582+
});
583+
584+
for (let i = 0; i < sortedChildren.length; i++) {
585+
let pod = pods[sortedChildren[i]];
586+
let geoScore = curScore + `${i + 1}`;
587+
adj[pod.id] = {
588+
name: pod.name,
589+
parentId: pod.parent,
590+
geoScore: geoScore,
591+
};
592+
593+
if (pod.type == "SCOPE") {
594+
q.push([pod, geoScore.substring(0, 2) + "0" + geoScore.substring(2)]);
595+
} else if (pod.type == "CODE") {
596+
jupyterCellList.push({
597+
cell_type: "code",
598+
// hard-code execution_count
599+
execution_count: 1,
600+
// TODO: expand other Codepod related-metadata fields, or run a real-time search in database when importing.
601+
metadata: { id: pod.id, geoScore: Number(geoScore) },
602+
source: [pod.content || ""],
603+
});
604+
} else if (pod.type == "RICH") {
605+
jupyterCellList.push({
606+
cell_type: "markdown",
607+
// hard-code execution_count
608+
execution_count: 1,
609+
// TODO: expand other Codepod related-metadata fields, or run a real-time search in database when importing.
610+
metadata: { id: pod.id, geoScore: Number(geoScore) },
611+
source: [pod.richContent || ""],
612+
});
613+
}
614+
}
615+
}
616+
617+
// sort the generated cells by their geoScore
618+
jupyterCellList.sort((cell1, cell2) => {
619+
if (
620+
Number(cell1.metadata["geoScore"]) < Number(cell2.metadata["geoScore"])
621+
) {
622+
return -1;
623+
} else {
624+
return 1;
625+
}
626+
});
627+
628+
// Append the scope structure as comment for each cell and format source
629+
for (const cell of jupyterCellList) {
630+
let scopes: string[] = [];
631+
let parentId = adj[cell.metadata["id"]].parentId;
632+
633+
// iterative {parentId,name} retrieval
634+
while (parentId && parentId != "ROOT") {
635+
scopes.push(adj[parentId].name);
636+
parentId = adj[parentId].parentId;
637+
}
638+
639+
// Add scope structure as a block comment at the head of each cell
640+
let scopeStructureAsComment =
641+
scopes.length > 0
642+
? [
643+
"'''\n",
644+
`CodePod Scope structure: ${scopes.reverse().join("/")}\n`,
645+
"'''\n",
646+
]
647+
: [""];
648+
649+
const sourceArray = cell.source[0]
650+
.split(/\r?\n/)
651+
.map((line) => line + "\n");
652+
653+
cell.source = [...scopeStructureAsComment, ...sourceArray];
654+
}
655+
656+
const fileContent = JSON.stringify({
657+
// hard-code Jupyter Notebook top-level metadata
658+
metadata: {
659+
name: repoName,
660+
kernelspec: {
661+
name: "python3",
662+
display_name: "Python 3",
663+
},
664+
language_info: { name: "python" },
665+
Codepod_version: "v0.0.1",
666+
},
667+
nbformat: 4,
668+
nbformat_minor: 0,
669+
cells: jupyterCellList,
670+
});
671+
672+
// Generate the download link on the fly
673+
let element = document.createElement("a");
674+
element.setAttribute(
675+
"href",
676+
"data:text/plain;charset=utf-8," + encodeURIComponent(fileContent)
677+
);
678+
element.setAttribute("download", filename);
679+
680+
element.style.display = "none";
681+
document.body.appendChild(element);
682+
element.click();
683+
document.body.removeChild(element);
684+
};
685+
686+
return (
687+
<Button
688+
variant="outlined"
689+
size="small"
690+
color="secondary"
691+
onClick={onClick}
692+
disabled={false}
693+
>
694+
Jupyter Notebook
695+
</Button>
696+
);
697+
}
698+
537699
function ExportSVG() {
538700
// The name should contain the name of the repo, the ID of the repo, and the current date
539701
const { id: repoId } = useParams();
@@ -590,6 +752,7 @@ function ExportButtons() {
590752
<Stack spacing={1}>
591753
<ExportFile />
592754
<ExportJSON />
755+
<ExportJupyterNB />
593756
<ExportSVG />
594757
</Stack>
595758
);

ui/src/components/nodes/Rich.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
YjsExtension,
6060
createMarkPositioner,
6161
wysiwygPreset,
62+
MarkdownExtension,
6263
} from "remirror/extensions";
6364
import {
6465
Remirror,
@@ -527,6 +528,7 @@ const MyEditor = ({
527528
const store = useContext(RepoContext);
528529
if (!store) throw new Error("Missing BearContext.Provider in the tree");
529530
const setPodContent = useStore(store, (state) => state.setPodContent);
531+
const setPodRichContent = useStore(store, (state) => state.setPodRichContent);
530532
// initial content
531533
const getPod = useStore(store, (state) => state.getPod);
532534
const nodesMap = useStore(store, (state) => state.ydoc.getMap<Node>("pods"));
@@ -554,6 +556,7 @@ const MyEditor = ({
554556
new LinkExtension({ autoLink: true }),
555557
new ImageExtension({ enableResizing: true }),
556558
new DropCursorExtension(),
559+
new MarkdownExtension(),
557560
new MyYjsExtension({ getProvider: () => provider, id }),
558561
new MentionExtension({
559562
extraAttributes: { type: "user" },
@@ -623,6 +626,10 @@ const MyEditor = ({
623626
}
624627
}
625628
setPodContent({ id, content: nextState.doc.toJSON() });
629+
setPodRichContent({
630+
id,
631+
richContent: parameter.helpers.getMarkdown(),
632+
});
626633
}
627634
}}
628635
>

ui/src/lib/store/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type Pod = {
1919
name?: string;
2020
type: "CODE" | "SCOPE" | "RICH";
2121
content?: string;
22+
richContent?: string;
2223
dirty?: boolean;
2324
// A temporary dirty status used during remote API syncing, so that new dirty
2425
// status is not cleared by API returns.

ui/src/lib/store/podSlice.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ export interface PodSlice {
3434
) => void;
3535
setPodName: ({ id, name }: { id: string; name: string }) => void;
3636
setPodContent: ({ id, content }: { id: string; content: string }) => void;
37+
setPodRichContent: ({
38+
id,
39+
richContent,
40+
}: {
41+
id: string;
42+
richContent: string;
43+
}) => void;
3744
initPodContent: ({ id, content }: { id: string; content: string }) => void;
3845
addPod: (pod: Pod) => void;
3946
deletePod: (
@@ -91,6 +98,19 @@ export const createPodSlice: StateCreator<MyState, [], [], PodSlice> = (
9198
// @ts-ignore
9299
"setPodContent"
93100
),
101+
setPodRichContent: ({ id, richContent }) =>
102+
set(
103+
produce((state) => {
104+
let pod = state.pods[id];
105+
if (pod.type != "RICH") {
106+
return;
107+
}
108+
pod.richContent = richContent;
109+
}),
110+
false,
111+
// @ts-ignore
112+
"setPodRichContent"
113+
),
94114
initPodContent: ({ id, content }) =>
95115
set(
96116
produce((state) => {

0 commit comments

Comments
 (0)