Skip to content

Commit 5f2fd0b

Browse files
senwang86li-xin-yi
authored andcommitted
feat: [UI] Jupyter Notebook export in Frontend (#343)
1 parent b2bd097 commit 5f2fd0b

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
@@ -603,6 +603,168 @@ function ExportJSON() {
603603
);
604604
}
605605

606+
function ExportJupyterNB() {
607+
const { id: repoId } = useParams();
608+
const store = useContext(RepoContext);
609+
if (!store) throw new Error("Missing BearContext.Provider in the tree");
610+
const repoName = useStore(store, (state) => state.repoName);
611+
const pods = useStore(store, (state) => state.pods);
612+
const filename = `${
613+
repoName || "Untitled"
614+
}-${new Date().toISOString()}.ipynb`;
615+
const [loading, setLoading] = useState(false);
616+
617+
const onClick = () => {
618+
setLoading(true);
619+
620+
// Hard-code Jupyter cell format. Reference, https://nbformat.readthedocs.io/en/latest/format_description.html
621+
let jupyterCellList: {
622+
cell_type: string;
623+
execution_count: number;
624+
metadata: object;
625+
source: string[];
626+
}[] = [];
627+
628+
// Queue to sort the pods geographically
629+
let q = new Array();
630+
// adjacency list for podId -> parentId mapping
631+
let adj = {};
632+
q.push([pods["ROOT"], "0.0"]);
633+
while (q.length > 0) {
634+
let [curPod, curScore] = q.shift();
635+
636+
// sort the pods geographically(top-down, left-right)
637+
let sortedChildren = curPod.children
638+
.map((x) => x.id)
639+
.sort((id1, id2) => {
640+
let pod1 = pods[id1];
641+
let pod2 = pods[id2];
642+
if (pod1 && pod2) {
643+
if (pod1.y === pod2.y) {
644+
return pod1.x - pod2.x;
645+
} else {
646+
return pod1.y - pod2.y;
647+
}
648+
} else {
649+
return 0;
650+
}
651+
});
652+
653+
for (let i = 0; i < sortedChildren.length; i++) {
654+
let pod = pods[sortedChildren[i]];
655+
let geoScore = curScore + `${i + 1}`;
656+
adj[pod.id] = {
657+
name: pod.name,
658+
parentId: pod.parent,
659+
geoScore: geoScore,
660+
};
661+
662+
if (pod.type == "SCOPE") {
663+
q.push([pod, geoScore.substring(0, 2) + "0" + geoScore.substring(2)]);
664+
} else if (pod.type == "CODE") {
665+
jupyterCellList.push({
666+
cell_type: "code",
667+
// hard-code execution_count
668+
execution_count: 1,
669+
// TODO: expand other Codepod related-metadata fields, or run a real-time search in database when importing.
670+
metadata: { id: pod.id, geoScore: Number(geoScore) },
671+
source: [pod.content || ""],
672+
});
673+
} else if (pod.type == "RICH") {
674+
jupyterCellList.push({
675+
cell_type: "markdown",
676+
// hard-code execution_count
677+
execution_count: 1,
678+
// TODO: expand other Codepod related-metadata fields, or run a real-time search in database when importing.
679+
metadata: { id: pod.id, geoScore: Number(geoScore) },
680+
source: [pod.richContent || ""],
681+
});
682+
}
683+
}
684+
}
685+
686+
// sort the generated cells by their geoScore
687+
jupyterCellList.sort((cell1, cell2) => {
688+
if (
689+
Number(cell1.metadata["geoScore"]) < Number(cell2.metadata["geoScore"])
690+
) {
691+
return -1;
692+
} else {
693+
return 1;
694+
}
695+
});
696+
697+
// Append the scope structure as comment for each cell and format source
698+
for (const cell of jupyterCellList) {
699+
let scopes: string[] = [];
700+
let parentId = adj[cell.metadata["id"]].parentId;
701+
702+
// iterative {parentId,name} retrieval
703+
while (parentId && parentId != "ROOT") {
704+
scopes.push(adj[parentId].name);
705+
parentId = adj[parentId].parentId;
706+
}
707+
708+
// Add scope structure as a block comment at the head of each cell
709+
let scopeStructureAsComment =
710+
scopes.length > 0
711+
? [
712+
"'''\n",
713+
`CodePod Scope structure: ${scopes.reverse().join("/")}\n`,
714+
"'''\n",
715+
]
716+
: [""];
717+
718+
const sourceArray = cell.source[0]
719+
.split(/\r?\n/)
720+
.map((line) => line + "\n");
721+
722+
cell.source = [...scopeStructureAsComment, ...sourceArray];
723+
}
724+
725+
const fileContent = JSON.stringify({
726+
// hard-code Jupyter Notebook top-level metadata
727+
metadata: {
728+
name: repoName,
729+
kernelspec: {
730+
name: "python3",
731+
display_name: "Python 3",
732+
},
733+
language_info: { name: "python" },
734+
Codepod_version: "v0.0.1",
735+
},
736+
nbformat: 4,
737+
nbformat_minor: 0,
738+
cells: jupyterCellList,
739+
});
740+
741+
// Generate the download link on the fly
742+
let element = document.createElement("a");
743+
element.setAttribute(
744+
"href",
745+
"data:text/plain;charset=utf-8," + encodeURIComponent(fileContent)
746+
);
747+
element.setAttribute("download", filename);
748+
749+
element.style.display = "none";
750+
document.body.appendChild(element);
751+
element.click();
752+
document.body.removeChild(element);
753+
};
754+
755+
return (
756+
<Button
757+
variant="outlined"
758+
size="small"
759+
color="secondary"
760+
onClick={onClick}
761+
disabled={false}
762+
>
763+
Jupyter Notebook
764+
</Button>
765+
);
766+
}
767+
606768
function ExportSVG() {
607769
// The name should contain the name of the repo, the ID of the repo, and the current date
608770
const { id: repoId } = useParams();
@@ -659,6 +821,7 @@ function ExportButtons() {
659821
<Stack spacing={1}>
660822
<ExportFile />
661823
<ExportJSON />
824+
<ExportJupyterNB />
662825
<ExportSVG />
663826
</Stack>
664827
);

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)