diff --git a/opencodeblocks/blocks/block.py b/opencodeblocks/blocks/block.py index 569d97b1..29763b57 100644 --- a/opencodeblocks/blocks/block.py +++ b/opencodeblocks/blocks/block.py @@ -30,15 +30,27 @@ class OCBBlock(QGraphicsItem, Serializable): """Base class for blocks in OpenCodeBlocks.""" + DEFAULT_DATA = { + "title": "New block", + "splitter_pos": [0, 0], + "width": 300, + "height": 200, + "metadata": { + "title_metadata": {"color": "white", "font": "Ubuntu", "size": 10} + }, + "sockets": [], + } + MANDATORY_FIELDS = {"block_type", "position"} + def __init__( self, block_type: str = "base", source: str = "", position: tuple = (0, 0), - width: int = 300, - height: int = 200, + width: int = DEFAULT_DATA["width"], + height: int = DEFAULT_DATA["height"], edge_size: float = 10.0, - title: Union[OCBTitle, str] = "New block", + title: Union[OCBTitle, str] = DEFAULT_DATA["title"], parent: Optional["QGraphicsItem"] = None, ): """Base class for blocks in OpenCodeBlocks. @@ -286,8 +298,11 @@ def serialize(self) -> OrderedDict: def deserialize(self, data: dict, hashmap: dict = None, restore_id=True) -> None: """Restore the block from serialized data""" - if restore_id: + if restore_id and "id" in data: self.id = data["id"] + + self.complete_with_default(data) + for dataname in ("title", "block_type", "width", "height"): setattr(self, dataname, data[dataname]) diff --git a/opencodeblocks/blocks/codeblock.py b/opencodeblocks/blocks/codeblock.py index ae3e6c21..f2dad9a0 100644 --- a/opencodeblocks/blocks/codeblock.py +++ b/opencodeblocks/blocks/codeblock.py @@ -28,6 +28,12 @@ class OCBCodeBlock(OCBBlock): """ + DEFAULT_DATA = { + **OCBBlock.DEFAULT_DATA, + "source": "", + } + MANDATORY_FIELDS = OCBBlock.MANDATORY_FIELDS + def __init__(self, **kwargs): """ Create a new OCBCodeBlock. @@ -318,6 +324,9 @@ def deserialize( self, data: OrderedDict, hashmap: dict = None, restore_id: bool = True ): """Restore a codeblock from it's serialized state""" + + self.complete_with_default(data) + for dataname in ("source", "stdout"): if dataname in data: setattr(self, dataname, data[dataname]) diff --git a/opencodeblocks/core/serializable.py b/opencodeblocks/core/serializable.py index f997d270..bc714a78 100644 --- a/opencodeblocks/core/serializable.py +++ b/opencodeblocks/core/serializable.py @@ -3,13 +3,17 @@ """ Module for the Serializable base class """ -from typing import OrderedDict +from typing import OrderedDict, Set class Serializable: """Serializable base for serializable objects.""" + MANDATORY_FIELDS: OrderedDict = {} + DEFAULT_DATA: Set[str] = {} + + def __init__(self): self.id = id(self) @@ -30,3 +34,13 @@ def deserialize( """ raise NotImplementedError() + + def complete_with_default(self, data: OrderedDict) -> None: + """Add default data in place when fields are missing""" + for key in self.MANDATORY_FIELDS: + if key not in data: + raise ValueError(f"{key} of the socket is missing") + + for key in self.DEFAULT_DATA: + if key not in data: + data[key] = self.DEFAULT_DATA[key] diff --git a/opencodeblocks/graphics/edge.py b/opencodeblocks/graphics/edge.py index e4b33722..4376779e 100644 --- a/opencodeblocks/graphics/edge.py +++ b/opencodeblocks/graphics/edge.py @@ -17,14 +17,23 @@ class OCBEdge(QGraphicsPathItem, Serializable): - """ Base class for directed edges in OpenCodeBlocks. """ - - def __init__(self, edge_width: float = 4.0, path_type='bezier', - edge_color="#001000", edge_selected_color="#00ff00", - source: QPointF = QPointF(0, 0), destination: QPointF = QPointF(0, 0), - source_socket: OCBSocket = None, destination_socket: OCBSocket = None - ): - """ Base class for edges in OpenCodeBlocks. + """Base class for directed edges in OpenCodeBlocks.""" + + DEFAULT_DATA = {"path_type": "bezier"} + MANDATORY_FIELDS = {"source", "destination"} + + def __init__( + self, + edge_width: float = 4.0, + path_type = DEFAULT_DATA["path_type"], + edge_color="#001000", + edge_selected_color="#00ff00", + source: QPointF = QPointF(0, 0), + destination: QPointF = QPointF(0, 0), + source_socket: OCBSocket = None, + destination_socket: OCBSocket = None, + ): + """Base class for edges in OpenCodeBlocks. Args: edge_width: Width of the edge. @@ -62,35 +71,38 @@ def __init__(self, edge_width: float = 4.0, path_type='bezier', self._destination = destination self.update_path() - def remove_from_socket(self, socket_type='source'): - """ Remove the edge from the sockets it is snaped to on the given socket_type. + def remove_from_socket(self, socket_type="source"): + """Remove the edge from the sockets it is snaped to on the given socket_type. Args: socket_type: One of ('source', 'destination'). """ - socket_name = f'{socket_type}_socket' + socket_name = f"{socket_type}_socket" socket = getattr(self, socket_name, OCBSocket) if socket is not None: socket.remove_edge(self) setattr(self, socket_name, None) def remove_from_sockets(self): - """ Remove the edge from all sockets it is snaped to. """ - self.remove_from_socket('source') - self.remove_from_socket('destination') + """Remove the edge from all sockets it is snaped to.""" + self.remove_from_socket("source") + self.remove_from_socket("destination") def remove(self): - """ Remove the edge from the scene in which it is drawn. """ + """Remove the edge from the scene in which it is drawn.""" scene = self.scene() if scene is not None: self.remove_from_sockets() scene.removeItem(self) - def paint(self, painter: QPainter, - option: QStyleOptionGraphicsItem, # pylint:disable=unused-argument - widget: Optional[QWidget] = None): # pylint:disable=unused-argument - """ Paint the edge. """ + def paint( + self, + painter: QPainter, + option: QStyleOptionGraphicsItem, # pylint:disable=unused-argument + widget: Optional[QWidget] = None, + ): # pylint:disable=unused-argument + """Paint the edge.""" self.update_path() pen = self._pen_dragging if self.destination_socket is None else self._pen painter.setPen(self._pen_selected if self.isSelected() else pen) @@ -98,22 +110,22 @@ def paint(self, painter: QPainter, painter.drawPath(self.path()) def update_path(self): - """ Update the edge path depending on the path_type. """ + """Update the edge path depending on the path_type.""" path = QPainterPath(self.source) - if self.path_type == 'direct': + if self.path_type == "direct": path.lineTo(self.destination) - elif self.path_type == 'bezier': + elif self.path_type == "bezier": sx, sy = self.source.x(), self.source.y() dx, dy = self.destination.x(), self.destination.y() mid_dist = (dx - sx) / 2 path.cubicTo(sx + mid_dist, sy, dx - mid_dist, dy, dx, dy) else: - raise NotImplementedError(f'Unknowed path type: {self.path_type}') + raise NotImplementedError(f"Unknowed path type: {self.path_type}") self.setPath(path) @property def source(self) -> QPointF: - """ Source point of the directed edge. """ + """Source point of the directed edge.""" if self.source_socket is not None: return self.source_socket.scenePos() return self._source @@ -128,7 +140,7 @@ def source(self, value: QPointF): @property def source_socket(self) -> OCBSocket: - """ Source socket of the directed edge. """ + """Source socket of the directed edge.""" return self._source_socket @source_socket.setter @@ -140,7 +152,7 @@ def source_socket(self, value: OCBSocket): @property def destination(self) -> QPointF: - """ Destination point of the directed edge. """ + """Destination point of the directed edge.""" if self.destination_socket is not None: return self.destination_socket.scenePos() return self._destination @@ -155,7 +167,7 @@ def destination(self, value: QPointF): @property def destination_socket(self) -> OCBSocket: - """ Destination socket of the directed edge. """ + """Destination socket of the directed edge.""" return self._destination_socket @destination_socket.setter @@ -166,32 +178,61 @@ def destination_socket(self, value: OCBSocket): self.destination = value.scenePos() def serialize(self) -> OrderedDict: - return OrderedDict([ - ('id', self.id), - ('path_type', self.path_type), - ('source', OrderedDict([ - ('block', - self.source_socket.block.id if self.source_socket else None), - ('socket', - self.source_socket.id if self.source_socket else None) - ])), - ('destination', OrderedDict([ - ('block', - self.destination_socket.block.id if self.destination_socket else None), - ('socket', - self.destination_socket.id if self.destination_socket else None) - ])) - ]) + return OrderedDict( + [ + ("id", self.id), + ("path_type", self.path_type), + ( + "source", + OrderedDict( + [ + ( + "block", + self.source_socket.block.id + if self.source_socket + else None, + ), + ( + "socket", + self.source_socket.id if self.source_socket else None, + ), + ] + ), + ), + ( + "destination", + OrderedDict( + [ + ( + "block", + self.destination_socket.block.id + if self.destination_socket + else None, + ), + ( + "socket", + self.destination_socket.id + if self.destination_socket + else None, + ), + ] + ), + ), + ] + ) def deserialize(self, data: OrderedDict, hashmap: dict = None, restore_id=True): - if restore_id: - self.id = data['id'] - self.path_type = data['path_type'] + if restore_id and "id" in data: + self.id = data["id"] + + self.complete_with_default(data) + + self.path_type = data["path_type"] try: - self.source_socket = hashmap[data['source']['socket']] + self.source_socket = hashmap[data["source"]["socket"]] self.source_socket.add_edge(self, is_destination=False) - self.destination_socket = hashmap[data['destination']['socket']] + self.destination_socket = hashmap[data["destination"]["socket"]] self.destination_socket.add_edge(self, is_destination=True) self.update_path() except KeyError: diff --git a/opencodeblocks/graphics/pyeditor.py b/opencodeblocks/graphics/pyeditor.py index e7877257..3f9c33d7 100644 --- a/opencodeblocks/graphics/pyeditor.py +++ b/opencodeblocks/graphics/pyeditor.py @@ -5,7 +5,14 @@ from typing import TYPE_CHECKING, List from PyQt5.QtCore import QThreadPool, Qt -from PyQt5.QtGui import QFocusEvent, QFont, QFontMetrics, QColor, QMouseEvent, QWheelEvent +from PyQt5.QtGui import ( + QFocusEvent, + QFont, + QFontMetrics, + QColor, + QMouseEvent, + QWheelEvent, +) from PyQt5.Qsci import QsciScintilla, QsciLexerPython from opencodeblocks.graphics.theme_manager import theme_manager @@ -18,13 +25,15 @@ if TYPE_CHECKING: from opencodeblocks.graphics.view import OCBView +POINT_SIZE = 11 + class PythonEditor(QsciScintilla): - """ In-block python editor for OpenCodeBlocks. """ + """In-block python editor for OpenCodeBlocks.""" def __init__(self, block: OCBBlock): - """ In-block python editor for OpenCodeBlocks. + """In-block python editor for OpenCodeBlocks. Args: block: Block in which to add the python editor widget. @@ -64,11 +73,11 @@ def __init__(self, block: OCBBlock): self.setWindowFlags(Qt.WindowType.FramelessWindowHint) def update_theme(self): - """ Change the font and colors of the editor to match the current theme """ + """Change the font and colors of the editor to match the current theme""" font = QFont() font.setFamily(theme_manager().recommended_font_family) font.setFixedPitch(True) - font.setPointSize(11) + font.setPointSize(POINT_SIZE) self.setFont(font) # Margin 0 is used for line numbers @@ -86,19 +95,19 @@ def update_theme(self): lexer.setFont(font) self.setLexer(lexer) - def views(self) -> List['OCBView']: - """ Get the views in which the python_editor is present. """ + def views(self) -> List["OCBView"]: + """Get the views in which the python_editor is present.""" return self.block.scene().views() def wheelEvent(self, event: QWheelEvent) -> None: - """ How PythonEditor handles wheel events """ + """How PythonEditor handles wheel events""" if self.mode == "EDITING" and event.angleDelta().x() == 0: event.accept() return super().wheelEvent(event) @property def mode(self) -> int: - """ PythonEditor current mode """ + """PythonEditor current mode""" return self._mode @mode.setter @@ -108,13 +117,13 @@ def mode(self, value: str): view.set_mode(value) def mousePressEvent(self, event: QMouseEvent) -> None: - """ PythonEditor reaction to PyQt mousePressEvent events. """ + """PythonEditor reaction to PyQt mousePressEvent events.""" if event.buttons() & Qt.MouseButton.LeftButton: self.mode = "EDITING" return super().mousePressEvent(event) def focusOutEvent(self, event: QFocusEvent): - """ PythonEditor reaction to PyQt focusOut events. """ + """PythonEditor reaction to PyQt focusOut events.""" self.mode = "NOOP" self.block.source = self.text() return super().focusOutEvent(event) diff --git a/opencodeblocks/graphics/socket.py b/opencodeblocks/graphics/socket.py index d567c54f..366b1f29 100644 --- a/opencodeblocks/graphics/socket.py +++ b/opencodeblocks/graphics/socket.py @@ -22,15 +22,26 @@ class OCBSocket(QGraphicsItem, Serializable): """Base class for sockets in OpenCodeBlocks.""" + DEFAULT_DATA = { + "type": "undefined", + "metadata": { + "color": "#FF55FFF0", + "linecolor": "#FF000000", + "linewidth": 1.0, + "radius": 6.0, + }, + } + MANDATORY_FIELDS = {"position"} + def __init__( self, block: "OCBBlock", - socket_type: str = "undefined", + socket_type: str = DEFAULT_DATA["type"], flow_type: str = "exe", - radius: float = 10.0, - color: str = "#FF55FFF0", - linewidth: float = 1.0, - linecolor: str = "#FF000000", + radius: float = DEFAULT_DATA["metadata"]["radius"], + color: str = DEFAULT_DATA["metadata"]["color"], + linewidth: float = DEFAULT_DATA["metadata"]["linewidth"], + linecolor: str = DEFAULT_DATA["metadata"]["linecolor"], ): """Base class for sockets in OpenCodeBlocks. @@ -133,13 +144,15 @@ def serialize(self) -> OrderedDict: ) def deserialize(self, data: OrderedDict, hashmap: dict = None, restore_id=True): - if restore_id: + if restore_id and "id" in data: self.id = data["id"] + + self.complete_with_default(data) + self.socket_type = data["type"] self.setPos(QPointF(*data["position"])) self.metadata = dict(data["metadata"]) - self.radius = self.metadata["radius"] self._pen.setColor(QColor(self.metadata["linecolor"])) self._pen.setWidth(int(self.metadata["linewidth"])) self._brush.setColor(QColor(self.metadata["color"])) diff --git a/opencodeblocks/graphics/widget.py b/opencodeblocks/graphics/widget.py index 19cdc5ce..a3910319 100644 --- a/opencodeblocks/graphics/widget.py +++ b/opencodeblocks/graphics/widget.py @@ -61,6 +61,9 @@ def savepath(self, value: str): def save(self): self.scene.save(self.savepath) + def saveAsJupyter(self): + self.scene.save_to_ipynb(self.savepath) + def load(self, filepath: str): self.scene.load(filepath) self.savepath = filepath diff --git a/opencodeblocks/graphics/window.py b/opencodeblocks/graphics/window.py index 5cb8ffdd..3e2a6af4 100644 --- a/opencodeblocks/graphics/window.py +++ b/opencodeblocks/graphics/window.py @@ -37,17 +37,14 @@ def __init__(self): ) loadStylesheets( ( - os.path.join(os.path.dirname(__file__), - "..", "qss", "ocb_dark.qss"), + os.path.join(os.path.dirname(__file__), "..", "qss", "ocb_dark.qss"), self.stylesheet_filename, ) ) self.mdiArea = QMdiArea() - self.mdiArea.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded) - self.mdiArea.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.mdiArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.mdiArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.mdiArea.setViewMode(QMdiArea.ViewMode.TabbedView) self.mdiArea.setDocumentMode(True) self.mdiArea.setTabsMovable(True) @@ -129,6 +126,11 @@ def createActions(self): shortcut="Ctrl+Shift+S", triggered=self.onFileSaveAs, ) + self._actSaveAsJupyter = QAction( + "Save &As ... .ipynb", + statusTip="Save the ipygraph as a Jupter Notebook at ...", + triggered=self.oneFileSaveAsJupyter, + ) self._actQuit = QAction( "&Quit", statusTip="Save and Quit the application", @@ -232,6 +234,7 @@ def createMenus(self): self.filemenu.addSeparator() self.filemenu.addAction(self._actSave) self.filemenu.addAction(self._actSaveAs) + self.filemenu.addAction(self._actSaveAsJupyter) self.filemenu.addSeparator() self.filemenu.addAction(self._actQuit) @@ -309,8 +312,7 @@ def onFileNew(self): def onFileOpen(self): """Open a file.""" - filename, _ = QFileDialog.getOpenFileName( - self, "Open ipygraph from file") + filename, _ = QFileDialog.getOpenFileName(self, "Open ipygraph from file") if filename == "": return if os.path.isfile(filename): @@ -345,7 +347,8 @@ def onFileSaveAs(self) -> bool: dialog = QFileDialog() dialog.setDefaultSuffix(".ipyg") filename, _ = dialog.getSaveFileName( - self, "Save ipygraph to file", filter="IPython Graph (*.ipyg)") + self, "Save ipygraph to file", filter="IPython Graph (*.ipyg)" + ) if filename == "": return False current_window.savepath = filename @@ -355,6 +358,31 @@ def onFileSaveAs(self) -> bool: return True return False + def oneFileSaveAsJupyter(self) -> bool: + """Save file in a given directory as ipynb, caching savepath for quick save. + + Returns: + True if the file was successfully saved, False otherwise. + + """ + current_window = self.activeMdiChild() + if current_window is not None: + dialog = QFileDialog() + dialog.setDefaultSuffix(".ipynb") + filename, _ = dialog.getSaveFileName( + self, "Save ipygraph to file", filter="IPython Graph (*.ipynb)" + ) + if filename == "": + return False + current_window.savepath = filename + current_window.saveAsJupyter() + self.statusbar.showMessage( + f"Successfully saved ipygraph as jupter notebook at {current_window.savepath}", + 2000, + ) + return True + return False + def saveWindow(self, window: OCBWidget): """Save the given window""" window.save() diff --git a/opencodeblocks/scene/from_ipynb_conversion.py b/opencodeblocks/scene/from_ipynb_conversion.py new file mode 100644 index 00000000..ab9b41c7 --- /dev/null +++ b/opencodeblocks/scene/from_ipynb_conversion.py @@ -0,0 +1,199 @@ +""" Module for converting ipynb data to ipyg data """ + +from typing import OrderedDict, List + +from PyQt5.QtGui import QFontMetrics, QFont + +from opencodeblocks.scene.ipynb_conversion_constants import * +from opencodeblocks.graphics.theme_manager import theme_manager +from opencodeblocks.graphics.pyeditor import POINT_SIZE + + +def ipynb_to_ipyg(data: OrderedDict) -> OrderedDict: + """Convert ipynb data (ipynb file, as ordered dict) into ipyg data (ipyg, as ordered dict)""" + + blocks_data: List[OrderedDict] = get_blocks_data(data) + edges_data: List[OrderedDict] = get_edges_data(blocks_data) + + return { + "blocks": blocks_data, + "edges": edges_data, + } + + +def get_blocks_data(data: OrderedDict) -> List[OrderedDict]: + """ + Get the blocks corresponding to a ipynb file, + Returns them in the ipyg ordered dict format + """ + + if "cells" not in data: + return [] + + # Get the font metrics to determine the size fo the blocks + font = QFont() + font.setFamily(theme_manager().recommended_font_family) + font.setFixedPitch(True) + font.setPointSize(POINT_SIZE) + fontmetrics = QFontMetrics(font) + + blocks_data: List[OrderedDict] = [] + + next_block_x_pos: float = 0 + next_block_y_pos: float = 0 + + for cell in data["cells"]: + if "cell_type" not in cell or cell["cell_type"] not in ["code", "markdown"]: + pass + else: + block_type: str = cell["cell_type"] + + text: str = cell["source"] + + text_width: float = ( + max(fontmetrics.boundingRect(line).width() for line in text) + if len(text) > 0 + else 0 + ) + block_width: float = max(text_width + MARGIN_X, BLOCK_MIN_WIDTH) + text_height: float = len(text) * ( + fontmetrics.lineSpacing() + fontmetrics.lineWidth() + ) + block_height: float = text_height + MARGIN_Y + + block_data = { + "id": len(blocks_data), + "block_type": BLOCK_TYPE_TO_NAME[block_type], + "width": block_width, + "height": block_height, + "position": [ + next_block_x_pos, + next_block_y_pos, + ], + "sockets": [], + } + + if block_type == "code": + block_data["source"] = "".join(text) + next_block_y_pos = 0 + next_block_x_pos += block_width + MARGIN_BETWEEN_BLOCKS_X + + if len(blocks_data) > 0 and is_title(blocks_data[-1]): + block_title: OrderedDict = blocks_data.pop() + block_data["title"] = block_title["text"] + + # Revert position effect of the markdown block + block_data["position"] = block_title["position"] + elif block_type == "markdown": + block_data.update( + { + "text": "".join(text), + } + ) + next_block_y_pos += block_height + MARGIN_BETWEEN_BLOCKS_Y + + blocks_data.append(block_data) + + adujst_markdown_blocks_width(blocks_data) + + return blocks_data + + +def is_title(block_data: OrderedDict) -> bool: + """Checks if the block is a one-line markdown block which could correspond to a title""" + if block_data["block_type"] != BLOCK_TYPE_TO_NAME["markdown"]: + return False + if "\n" in block_data["text"]: + return False + if len(block_data["text"]) == 0 or len(block_data["text"]) > TITLE_MAX_LENGTH: + return False + # Headings, quotes, bold or italic text are not considered to be headings + if block_data["text"][0] in {"#", "*", "`"}: + return False + return True + + +def adujst_markdown_blocks_width(blocks_data: OrderedDict) -> None: + """ + Modify the markdown blocks width (in place) + For them to match the width of block of code below + """ + i: int = len(blocks_data) - 1 + + while i >= 0: + if blocks_data[i]["block_type"] == BLOCK_TYPE_TO_NAME["code"]: + block_width: float = blocks_data[i]["width"] + i -= 1 + + while ( + i >= 0 + and blocks_data[i]["block_type"] == BLOCK_TYPE_TO_NAME["markdown"] + ): + blocks_data[i]["width"] = block_width + i -= 1 + else: + i -= 1 + + +def get_edges_data(blocks_data: OrderedDict) -> OrderedDict: + """Add sockets to the blocks (in place) and returns the edge list""" + code_blocks: List[OrderedDict] = [ + block + for block in blocks_data + if block["block_type"] == BLOCK_TYPE_TO_NAME["code"] + ] + edges_data: List[OrderedDict] = [] + + for i in range(1, len(code_blocks)): + socket_id_out = len(blocks_data) + 2 * i + socket_id_in = len(blocks_data) + 2 * i + 1 + code_blocks[i - 1]["sockets"].append( + get_output_socket_data(socket_id_out, code_blocks[i - 1]["width"]) + ) + code_blocks[i]["sockets"].append(get_input_socket_data(socket_id_in)) + edges_data.append( + get_edge_data( + i, + code_blocks[i - 1]["id"], + socket_id_out, + code_blocks[i]["id"], + socket_id_in, + ) + ) + return edges_data + + +def get_input_socket_data(socket_id: int) -> OrderedDict: + """Returns the input socket's data with the corresponding id""" + return { + "id": socket_id, + "type": "input", + "position": [0.0, SOCKET_HEIGHT], + } + + +def get_output_socket_data(socket_id: int, block_width: int) -> OrderedDict: + """ + Returns the input socket's data with the corresponding id + and at the correct relative position with respect to the block + """ + return { + "id": socket_id, + "type": "output", + "position": [block_width, SOCKET_HEIGHT], + } + + +def get_edge_data( + edge_id: int, + edge_start_block_id: int, + edge_start_socket_id: int, + edge_end_block_id: int, + edge_end_socket_id: int, +) -> OrderedDict: + """Return the ordered dict corresponding to the given parameters""" + return { + "id": edge_id, + "source": {"block": edge_start_block_id, "socket": edge_start_socket_id}, + "destination": {"block": edge_end_block_id, "socket": edge_end_socket_id}, + } diff --git a/opencodeblocks/scene/ipynb_conversion_constants.py b/opencodeblocks/scene/ipynb_conversion_constants.py new file mode 100644 index 00000000..78ecba72 --- /dev/null +++ b/opencodeblocks/scene/ipynb_conversion_constants.py @@ -0,0 +1,74 @@ +""" Module with the constants used to converter to ipynb and from ipynb """ + +from typing import Dict + +MARGIN_X: float = 75 +MARGIN_BETWEEN_BLOCKS_X: float = 50 +MARGIN_Y: float = 60 +MARGIN_BETWEEN_BLOCKS_Y: float = 5 +BLOCK_MIN_WIDTH: float = 400 +TITLE_MAX_LENGTH: int = 60 +SOCKET_HEIGHT: float = 44.0 + +BLOCK_TYPE_TO_NAME: Dict[str, str] = { + "code": "OCBCodeBlock", + "markdown": "OCBMarkdownBlock", +} + +BLOCK_TYPE_SUPPORTED_FOR_IPYG_TO_IPYNB = {"OCBCodeBlock", "OCBMarkdownBlock"} + +DEFAULT_NOTEBOOK_DATA = { + "cells": [], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3", + }, + "language_info": { + "codemirror_mode": {"name": "ipython", "version": 3}, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.4", + }, + }, + "nbformat": 4, + "nbformat_minor": 4, +} + +DEFAULT_CODE_CELL = { + "cell_type": "code", + "execution_count": None, + "metadata": { + "_cell_guid": "b1076dfc-b9ad-4769-8c92-a6c4dae69d19", + "_uuid": "8f2839f25d086af736a60e9eeb907d3b93b6e0e5", + "execution": { + "iopub.execute_input": "2021-11-23T21:43:41.246727Z", + "iopub.status.busy": "2021-11-23T21:43:41.246168Z", + "iopub.status.idle": "2021-11-23T21:43:41.260389Z", + "shell.execute_reply": "2021-11-23T21:43:41.260950Z", + "shell.execute_reply.started": "2021-11-22T18:36:28.843251Z", + }, + "tags": [], + }, + "outputs": [], + "source": [], +} + +DEFAULT_MARKDOWN_CELL = { + "cell_type": "markdown", + "metadata": { + "papermill": { + "duration": 0, + "end_time": "2021-11-23T21:43:55.202848", + "exception": False, + "start_time": "2021-11-23T21:43:55.174774", + "status": "completed", + }, + "tags": [], + }, + "source": [], +} diff --git a/opencodeblocks/scene/scene.py b/opencodeblocks/scene/scene.py index ff2b85cb..d00d641c 100644 --- a/opencodeblocks/scene/scene.py +++ b/opencodeblocks/scene/scene.py @@ -19,6 +19,8 @@ from opencodeblocks.graphics.edge import OCBEdge from opencodeblocks.scene.clipboard import SceneClipboard from opencodeblocks.scene.history import SceneHistory +from opencodeblocks.scene.from_ipynb_conversion import ipynb_to_ipyg +from opencodeblocks.scene.to_ipynb_conversion import ipyg_to_ipynb import networkx as nx @@ -139,6 +141,22 @@ def save_to_ipyg(self, filepath: str): with open(filepath, "w", encoding="utf-8") as file: file.write(json.dumps(self.serialize(), indent=4)) + def save_to_ipynb(self, filepath: str): + """Save the scene into filepath as ipynb""" + if "." not in filepath: + filepath += ".ipynb" + + extention_format: str = filepath.split(".")[-1] + if extention_format != "ipynb": + raise NotImplementedError( + f"The file should be a *.ipynb (not a .{extention_format})" + ) + + with open(filepath, "w", encoding="utf-8") as file: + json_ipyg_data: OrderedDict = self.serialize() + json_ipynb_data: OrderedDict = ipyg_to_ipynb(json_ipyg_data) + file.write(json.dumps(json_ipynb_data, indent=4)) + def load(self, filepath: str): """Load a saved scene. @@ -147,7 +165,10 @@ def load(self, filepath: str): """ if filepath.endswith(".ipyg"): - data = self.load_from_ipyg(filepath) + data = self.load_from_json(filepath) + elif filepath.endswith(".ipynb"): + ipynb_data = self.load_from_json(filepath) + data = ipynb_to_ipyg(ipynb_data) else: extention_format = filepath.split(".")[-1] raise NotImplementedError(f"Unsupported format {extention_format}") @@ -155,12 +176,10 @@ def load(self, filepath: str): self.history.checkpoint("Loaded scene") self.has_been_modified = False - def load_from_ipyg(self, filepath: str): - """Load an interactive python graph (.ipyg) into the scene. - + def load_from_json(self, filepath: str) -> OrderedDict: + """Load the json data into an ordered dict Args: - filepath: Path to the .ipyg file to load. - + filepath: Path to the file to load. """ with open(filepath, "r", encoding="utf-8") as file: data = json.loads(file.read()) @@ -239,8 +258,8 @@ def deserialize( ): self.clear() hashmap = hashmap if hashmap is not None else {} - if restore_id: - self.id = data["id"] + if restore_id and 'id' in data: + self.id = data['id'] # Create blocks for block_data in data["blocks"]: diff --git a/opencodeblocks/scene/to_ipynb_conversion.py b/opencodeblocks/scene/to_ipynb_conversion.py new file mode 100644 index 00000000..98e78878 --- /dev/null +++ b/opencodeblocks/scene/to_ipynb_conversion.py @@ -0,0 +1,52 @@ +""" Module for converting ipyg data to ipynb data """ + +from typing import OrderedDict, List + +import copy + +from opencodeblocks.scene.ipynb_conversion_constants import * + + +def ipyg_to_ipynb(data: OrderedDict) -> OrderedDict: + """Convert ipyg data (as ordered dict) into ipynb data (as ordered dict)""" + ordered_data: OrderedDict = get_block_in_order(data) + + ipynb_data: OrderedDict = copy.deepcopy(DEFAULT_NOTEBOOK_DATA) + + for block_data in ordered_data["blocks"]: + if block_data["block_type"] in BLOCK_TYPE_SUPPORTED_FOR_IPYG_TO_IPYNB: + ipynb_data["cells"].append(block_to_ipynb_cell(block_data)) + + return ipynb_data + + +def get_block_in_order(data: OrderedDict) -> OrderedDict: + """Changes the order of the blocks from random to the naturel flow of the text""" + + # Not implemented yet + return data + + +def block_to_ipynb_cell(block_data: OrderedDict) -> OrderedDict: + """Convert a ipyg block into its corresponding ipynb cell""" + if block_data["block_type"] == BLOCK_TYPE_TO_NAME["code"]: + cell_data: OrderedDict = copy.deepcopy(DEFAULT_CODE_CELL) + cell_data["source"] = split_lines_and_add_newline(block_data["source"]) + return cell_data + if block_data["block_type"] == BLOCK_TYPE_TO_NAME["markdown"]: + cell_data: OrderedDict = copy.deepcopy(DEFAULT_MARKDOWN_CELL) + cell_data["source"] = split_lines_and_add_newline(block_data["text"]) + return cell_data + + raise ValueError( + f"The block type {block_data['block_type']} is not supported but has been declared as such" + ) + + +def split_lines_and_add_newline(text: str) -> List[str]: + """Split the text and add a \\n at the end of each line + This is the jupyter notebook default formatting for source, outputs and text""" + lines = text.split("\n") + for i in range(len(lines) - 1): + lines[i] += "\n" + return lines