diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 637538a4..fa388378 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,12 @@ repos: - id: check-added-large-files args: ['--maxkb=1024'] - id: check-json - exclude: caster-editor/tsconfig.json + exclude: | + (?x)^( + caster-editor/tsconfig.json| + .vscode/settings.json| + .vscode/extensions.json + )$ - id: check-merge-conflict - repo: https://github.com/psf/black rev: 22.3.0 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index bc40a1b0..71fc4900 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,8 @@ { "recommendations": [ "Vue.volar", - "Vue.vscode-typescript-vue-plugin" + "Vue.vscode-typescript-vue-plugin", + "ms-python.mypy-type-checker", + "ms-python.python", ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 8b0b917f..d09175d1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { - "python.analysis.typeCheckingMode": "basic", + // "python.analysis.typeCheckingMode": "basic", + "mypy-type-checker.args": [ + "--config-file=${workspaceFolder}/caster-back/setup.cfg" + ], "cSpell.words": [ "Gencaster", "pythonosc", @@ -14,5 +17,6 @@ "javascript", "javascriptreact", "vue" - ] + ], + "python.analysis.typeCheckingMode": "basic" } diff --git a/caster-back/gencaster/schema.py b/caster-back/gencaster/schema.py index dc4d190e..d8db5834 100644 --- a/caster-back/gencaster/schema.py +++ b/caster-back/gencaster/schema.py @@ -39,15 +39,22 @@ from story_graph.types import ( AddGraphInput, AudioCellInput, + Edge, EdgeInput, Graph, GraphFilter, + InvalidPythonCode, Node, NodeCreate, + NodeDoor, + NodeDoorInputCreate, + NodeDoorInputUpdate, + NodeDoorResponse, NodeUpdate, ScriptCell, ScriptCellInputCreate, ScriptCellInputUpdate, + create_python_highlight_string, ) from stream.exceptions import NoStreamAvailableException from stream.frontend_types import Dialog @@ -275,29 +282,27 @@ async def update_node(self, info: Info, node_update: NodeUpdate) -> None: return None @strawberry.mutation - async def add_edge(self, info: Info, new_edge: EdgeInput) -> None: + async def add_edge(self, info: Info, new_edge: EdgeInput) -> Edge: """Creates a :class:`~story_graph.models.Edge` for a given :class:`~story_graph.models.Graph`. - It does not return the created edge. + It returns the created edge. """ await graphql_check_authenticated(info) - in_node: story_graph_models.Node = ( - await story_graph_models.Node.objects.select_related("graph").aget( - uuid=new_edge.node_in_uuid - ) - ) - out_node: story_graph_models.Node = await story_graph_models.Node.objects.aget( - uuid=new_edge.node_out_uuid + in_node_door = await story_graph_models.NodeDoor.objects.select_related( + "node__graph" + ).aget(uuid=new_edge.node_door_in_uuid) + out_node_door = await story_graph_models.NodeDoor.objects.aget( + uuid=new_edge.node_door_out_uuid ) - edge: story_graph_models.Edge = await story_graph_models.Edge.objects.acreate( - in_node=in_node, - out_node=out_node, + edge = await story_graph_models.Edge.objects.acreate( + in_node_door=in_node_door, + out_node_door=out_node_door, ) await GenCasterChannel.send_graph_update( layer=info.context.channel_layer, - graph_uuid=in_node.graph.uuid, + graph_uuid=in_node_door.node.graph.uuid, ) - return None + return edge # type: ignore @strawberry.mutation async def delete_edge(self, info, edge_uuid: uuid.UUID) -> None: @@ -306,16 +311,17 @@ async def delete_edge(self, info, edge_uuid: uuid.UUID) -> None: try: edge: story_graph_models.Edge = ( await story_graph_models.Edge.objects.select_related( - "in_node__graph" + "in_node_door__node__graph" ).aget(uuid=edge_uuid) ) await story_graph_models.Edge.objects.filter(uuid=edge_uuid).adelete() except Exception: raise Exception(f"Could not delete edge {edge_uuid}") - await GenCasterChannel.send_graph_update( - layer=info.context.channel_layer, - graph_uuid=edge.in_node.graph.uuid, - ) + if edge.in_node_door: + await GenCasterChannel.send_graph_update( + layer=info.context.channel_layer, + graph_uuid=edge.in_node_door.node.graph.uuid, + ) return None @strawberry.mutation @@ -528,6 +534,62 @@ async def create_update_stream_variable( return stream_vars # type: ignore + @strawberry.mutation + async def create_node_door( + self, + info, + node_door_input: NodeDoorInputCreate, + node_uuid: uuid.UUID, + ) -> NodeDoor: + await graphql_check_authenticated(info) + node = await story_graph_models.Node.objects.aget(uuid=node_uuid) + return await story_graph_models.NodeDoor.objects.acreate( + door_type=node_door_input.door_type, + node=node, + name=node_door_input.name, + order=node_door_input.order, + code=node_door_input.code, + ) # type: ignore + + @strawberry.mutation + async def update_node_door( + self, + info, + node_door_input: NodeDoorInputUpdate, + ) -> NodeDoorResponse: + await graphql_check_authenticated(info) + node_door = await story_graph_models.NodeDoor.objects.aget( + uuid=node_door_input.uuid + ) + node_door.door_type = node_door_input.door_type + if node_door_input.code: + node_door.code = node_door_input.code + if node_door_input.name: + node_door.name = node_door_input.name + if node_door_input.order: + node_door.order = node_door_input.order + try: + await node_door.asave() + except SyntaxError as e: + return InvalidPythonCode( + error_type=e.msg, + error_code=e.text if e.text else "", + error_message=create_python_highlight_string(e), + ) + return node_door # type: ignore + + @strawberry.mutation + async def delete_node_door(self, info, node_door_uuid: uuid.UUID) -> bool: + """Allows to delete a non-default NodeDoor. + If a node door was deleted it will return ``True``, otherwise ``False``. + """ + await graphql_check_authenticated(info) + deleted_objects, _ = await story_graph_models.NodeDoor.objects.filter( + is_default=False, + uuid=node_door_uuid, + ).adelete() + return deleted_objects >= 1 + @strawberry.type class Subscription: @@ -656,7 +718,7 @@ async def get_streams() -> List[Stream]: async for stream in stream_models.Stream.objects.order_by("-created_date")[ 0:limit ]: - streams_db.append(stream) + streams_db.append(stream) # type: ignore return streams_db yield await get_streams() diff --git a/caster-back/gencaster/tests.py b/caster-back/gencaster/tests.py index 9defd75e..4974a497 100644 --- a/caster-back/gencaster/tests.py +++ b/caster-back/gencaster/tests.py @@ -118,16 +118,18 @@ async def test_add_edge(self): out_node: Node = await sync_to_async(NodeTestCase.get_node)(graph=graph) mutation = """ - mutation TestMutation($nodeInUuid:UUID!, $nodeOutUuid:UUID!) { - addEdge(newEdge: {nodeInUuid: $nodeInUuid, nodeOutUuid: $nodeOutUuid}) + mutation TestMutation($nodeDoorInUuid:UUID!, $nodeDoorOutUuid:UUID!) { + addEdge(newEdge: {nodeDoorInUuid: $nodeDoorInUuid, nodeDoorOutUuid: $nodeDoorOutUuid}) { + uuid + } } """ resp = await schema.execute( mutation, variable_values={ - "nodeInUuid": str(in_node.uuid), - "nodeOutUuid": str(out_node.uuid), + "nodeDoorInUuid": str((await in_node.aget_default_in_door()).uuid), + "nodeDoorOutUuid": str((await out_node.aget_default_out_door()).uuid), }, context_value=self.get_login_context(), ) @@ -137,8 +139,8 @@ async def test_add_edge(self): self.assertEqual(await Edge.objects.all().acount(), 1) edge: Edge = await Edge.objects.all().afirst() # type: ignore - self.assertEqual(await sync_to_async(lambda: edge.in_node)(), in_node) - self.assertEqual(await sync_to_async(lambda: edge.out_node)(), out_node) + self.assertEqual((await sync_to_async(lambda: edge.in_node_door.node)()).uuid, in_node.uuid) # type: ignore + self.assertEqual((await sync_to_async(lambda: edge.out_node_door.node)()).uuid, out_node.uuid) # type: ignore GRAPH_QUERY = """ query TestQuery { diff --git a/caster-back/gencaster/urls.py b/caster-back/gencaster/urls.py index 6983e9e9..c503a8f6 100644 --- a/caster-back/gencaster/urls.py +++ b/caster-back/gencaster/urls.py @@ -20,8 +20,7 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin -from django.http import HttpRequest -from django.shortcuts import HttpResponse +from django.http import HttpRequest, HttpResponse from django.urls import path from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt diff --git a/caster-back/operations.gql b/caster-back/operations.gql index 867c5e12..acad0d2f 100644 --- a/caster-back/operations.gql +++ b/caster-back/operations.gql @@ -67,8 +67,10 @@ query GetGraphsMeta($slug: String!) { } } -mutation createEdge($nodeInUuid:UUID!, $nodeOutUuid:UUID!) { - addEdge(newEdge: {nodeInUuid: $nodeInUuid, nodeOutUuid: $nodeOutUuid}) +mutation createEdge($nodeDoorInUuid:UUID!, $nodeDoorOutUuid:UUID!) { + addEdge(newEdge: {nodeDoorInUuid: $nodeDoorInUuid, nodeDoorOutUuid: $nodeDoorOutUuid}) { + uuid + } } mutation createNode($name: String!, $graphUuid: UUID!, $color: String, $positionX: Float, $positionY: Float) { @@ -112,6 +114,16 @@ mutation deleteScriptCell($scriptCellUuid:UUID!) { deleteScriptCell(scriptCellUuid: $scriptCellUuid) } +fragment NodeDoorBasic on NodeDoor { + uuid + name + order + isDefault + node { + uuid + } +} + subscription graph($uuid: UUID!) { graph(graphUuid: $uuid) { name @@ -119,11 +131,11 @@ subscription graph($uuid: UUID!) { uuid edges { uuid - outNode { - uuid + inNodeDoor { + ...NodeDoorBasic } - inNode { - uuid + outNodeDoor { + ...NodeDoorBasic } } nodes { @@ -138,12 +150,33 @@ subscription graph($uuid: UUID!) { positionX positionY color + inNodeDoors { + ...NodeDoorBasic + } + outNodeDoors { + ...NodeDoorBasic + } } } } +fragment NodeDoorDetail on NodeDoor { + uuid + name + order + isDefault + code + doorType +} + subscription node($uuid: UUID!) { node(nodeUuid: $uuid) { + inNodeDoors { + ...NodeDoorDetail + } + outNodeDoors { + ...NodeDoorDetail + } color name positionX @@ -356,3 +389,33 @@ subscription StreamLogs($streamUuid: UUID, $streamPointUuid: UUID) { } } } + +mutation createNodeDoor($nodeUuid: UUID!, $name: String!, $code: String! = "", $doorType: DoorType = OUTPUT, $order: Int) { + createNodeDoor( + nodeDoorInput: {name: $name, code: $code, doorType: $doorType, order: $order} + nodeUuid: $nodeUuid + ) { + uuid + } +} + +mutation deleteNodeDoor($nodeDoorUuid: UUID!) { + deleteNodeDoor(nodeDoorUuid: $nodeDoorUuid) +} + +mutation updateNodeDoor($uuid: UUID!, $name: String, $code: String, $order: Int) { + updateNodeDoor( + nodeDoorInput: {name: $name, code: $code, order: $order, uuid: $uuid} + ) { + ... on NodeDoor { + __typename + uuid + } + ... on InvalidPythonCode { + __typename + errorCode + errorMessage + errorType + } + } +} diff --git a/caster-back/requirements.txt b/caster-back/requirements.txt index fb53e023..20c758a9 100644 --- a/caster-back/requirements.txt +++ b/caster-back/requirements.txt @@ -11,3 +11,4 @@ mistletoe==1.0.1 pydantic==1.10.7 channels-redis==4.1.0 sentry-sdk==1.29.0 +django-stubs-ext==4.2.2 diff --git a/caster-back/schema.gql b/caster-back/schema.gql index 2d23164c..b3f6906f 100644 --- a/caster-back/schema.gql +++ b/caster-back/schema.gql @@ -1,4 +1,3 @@ -### START LOGGING THREAD LOOP ### input AddAudioFile { file: Upload! description: String! @@ -56,7 +55,7 @@ input AddGraphInput { templateName: GraphDetailTemplate } -"""AudioCell(uuid, playback, audio_file, volume)""" +"""Stores information for playback of static audio files.""" type AudioCell { uuid: UUID! playback: PlaybackChoices! @@ -64,7 +63,7 @@ type AudioCell { audioFile(pagination: OffsetPaginationInput): AudioFile! } -"""AudioCell(uuid, playback, audio_file, volume)""" +"""Stores information for playback of static audio files.""" input AudioCellInput { uuid: UUID = null playback: PlaybackChoices @@ -164,7 +163,56 @@ enum CallbackAction { SEND_VARIABLE } -"""Choice of foobar""" +""" +A :class:`~story_graph.models.ScriptCell` can contain +different types of code, each with unique functionality. + +Both, the database and :class:`~story_graph.engine.Engine`, +implement some specific details according to these types. + +.. list-table:: Cell types + :header-rows: 1 + + * - Name + - Description + - Database + - Engine + * - Markdown + - Allows to write arbitrary text which will get + rendered as an audio file via a text to speech service, + see :class:`~stream.models.TextToSpeech` for conversion + and :class:`~story_graph.markdown_parser.GencasterRenderer` + for the extended Markdown syntax. + - - :class:`~stream.models.TextToSpeech` + - - :func:`~story_graph.engine.Engine.execute_markdown_code` + - :class:`~story_graph.markdown_parser.GencasterRenderer` + * - Python + - Allows to execute python code via :func:`exec` which allows + to trigger e.g. Dialogs in the frontend + (see :class:`~stream.frontend_types.Dialog`) + or calculate or fetch any kind of data and store its value + as a :class:`~stream.models.StreamVariable`. + - + - - :func:`~story_graph.engine.Engine.execute_python_cell` + * - SuperCollider + - Executes *sclang* code on the associated server. + This can be used to control the sonic content on the server. + - - :class:`~stream.models.StreamInstruction` + - - :func:`~story_graph.engine.Engine.execute_sc_code` + - :ref:`OSC Server` + * - Comment + - Does not get executed, but allows to put comments into + the graph. + - + - + * - Audio + - Allows to playback static audio files. + The instruction will be translated into *sclang* code and will + be executed as such on the associated stream. + - - :class:`~story_graph.models.AudioCell` + - :class:`~stream.models.AudioFile` + - - :func:`~story_graph.engine.Engine.execute_audio_cell` +""" enum CellType { MARKDOWN PYTHON @@ -203,25 +251,83 @@ type DjangoFileType { url: String! } -""" -Connects two :class:`~Node` with each other. - -.. todo:: - - With a script we can also jump to any other node - so it is not clear how to use this. - Maybe take a look at visual programming languages - such as MSP or Scratch how they handle this? +"""An enumeration.""" +enum DoorType { + INPUT + OUTPUT +} + +""" +Connects two :class:`~Node` with each other by +using their respective :class:`~NodeDoor`. + +.. important:: + + It is important to note that an edge flows from + ``out_node_door`` to ``in_node_door`` as we follow + the notion from the perspective of a + :class:`story_graph.models.Node` rather than from the + edge. + + +.. graphviz:: + + digraph Connection { + rank = same; + subgraph cluster_node_a { + rank = same; + label = "NODE_A"; + NODE_A [shape=Msquare, label="NODE_A\n\nscript_cell_1\nscript_cell_2"]; + subgraph cluster_in_nodes_a { + label = "IN_NODES"; + in_node_door_a [label="in_node_door"]; + } + subgraph cluster_out_nodes_a { + label = "OUT_NODES"; + out_node_door_a_1 [label="out_node_door 1"]; + out_node_door_a_2 [label="out_node_door 2"]; + } + in_node_door_a -> NODE_A [label="DB\nreference"]; + {out_node_door_a_1, out_node_door_a_2} -> NODE_A; + in_node_door_a -> NODE_A [style=dashed, color=red, fontcolor=red, label="Engine\nProgression"]; + NODE_A -> out_node_door_a_1 [style=dashed, color=red]; + } + + edge_ [shape=Msquare, label="EDGE"]; + edge_ -> out_node_door_a_1 [label="out_node_door"]; + edge_ -> in_node_door_b [label="in_node_door"]; + out_node_door_a_1 -> edge_ [style=dashed, color=red]; + edge_ -> in_node_door_b [style=dashed, color=red]; + + subgraph cluster_node_b { + rank = same; + label = "NODE_B"; + NODE_B [shape=Msquare]; + subgraph cluster_in_nodes_b { + label = "IN_NODES"; + in_node_door_b [label="in_node_door"]; + } + subgraph cluster_out_nodes_b { + label = "OUT_NODES"; + out_node_door_b_1 [label="out_node_door 1"]; + out_node_door_b_2 [label="out_node_door 2"]; + } + in_node_door_b -> NODE_B; + {out_node_door_b_1, out_node_door_b_2} -> NODE_B; + in_node_door_b -> NODE_B [style=dashed, color=red]; + NODE_B -> out_node_door_b_1 [style=dashed, color=red]; + } + } """ type Edge { uuid: UUID! - inNode: Node! - outNode: Node! + inNodeDoor: NodeDoor + outNodeDoor: NodeDoor } input EdgeInput { - nodeInUuid: UUID! - nodeOutUuid: UUID! + nodeDoorInUuid: UUID! + nodeDoorOutUuid: UUID! } """ @@ -331,6 +437,12 @@ type InvalidAudioFile { error: String! } +type InvalidPythonCode { + errorType: String! + errorMessage: String! + errorCode: String! +} + type LoginError { errorMessage: String } @@ -361,9 +473,9 @@ type Mutation { """ Creates a :class:`~story_graph.models.Edge` for a given :class:`~story_graph.models.Graph`. - It does not return the created edge. + It returns the created edge. """ - addEdge(newEdge: EdgeInput!): Void + addEdge(newEdge: EdgeInput!): Edge! """Deletes a given :class:`~story_graph.models.Edge`.""" deleteEdge(edgeUuid: UUID!): Void @@ -382,6 +494,14 @@ type Mutation { addGraph(graphInput: AddGraphInput!): Graph! addAudioFile(newAudioFile: AddAudioFile!): AudioFileUploadResponse! createUpdateStreamVariable(streamVariables: [StreamVariableInput!]!): [StreamVariable!]! + createNodeDoor(nodeDoorInput: NodeDoorInputCreate!, nodeUuid: UUID!): NodeDoor! + updateNodeDoor(nodeDoorInput: NodeDoorInputUpdate!): NodeDoorResponse! + + """ + Allows to delete a non-default NodeDoor. + If a node door was deleted it will return ``True``, otherwise ``False``. + """ + deleteNodeDoor(nodeDoorUuid: UUID!): Boolean! } """Matches :class:`gencaster.stream.exceptions.NoStreamAvailable`.""" @@ -407,9 +527,10 @@ type Node { Acts as a singular entrypoint for our graph.Only one such node can exist per graph. """ isEntryNode: Boolean! - inEdges: [Edge!]! - outEdges: [Edge!]! scriptCells: [ScriptCell!]! + nodeDoors: [NodeDoor!]! + inNodeDoors: [NodeDoor!]! + outNodeDoors: [NodeDoor!]! } input NodeCreate { @@ -420,6 +541,97 @@ input NodeCreate { color: String = null } +""" +A :class:`~Node` can be entered and exited via +multiple paths, where each of these exits and +entrances is called a *door*. + +A connection between nodes can only be made via their +doors. +There are two types of doors: + +.. list-table:: Door types + :header-rows: 1 + + * - Kind + - Description + * - **INPUT** + - Allows to enter a node. + Currently each Node only has one entry point + but for future development and a nicer + database operations it is also represented. + * - **OUTPUT** + - Allows to exit a node. + After all script cells of a node has been + executed, the condition of each door will + be evaluated (like in a switch case). + Once a condition has been met, the door + will be stepped through. + This allows to have a visual representation + of logic branches. + +It is only possible to connect an **OUTPUT** to an +**INPUT** door via an :class:`~Edge`. +""" +type NodeDoor { + uuid: UUID! + doorType: DoorType! + node: Node! + name: String! + order: Int! + isDefault: Boolean! + code: String! +} + +""" +A :class:`~Node` can be entered and exited via +multiple paths, where each of these exits and +entrances is called a *door*. + +A connection between nodes can only be made via their +doors. +There are two types of doors: + +.. list-table:: Door types + :header-rows: 1 + + * - Kind + - Description + * - **INPUT** + - Allows to enter a node. + Currently each Node only has one entry point + but for future development and a nicer + database operations it is also represented. + * - **OUTPUT** + - Allows to exit a node. + After all script cells of a node has been + executed, the condition of each door will + be evaluated (like in a switch case). + Once a condition has been met, the door + will be stepped through. + This allows to have a visual representation + of logic branches. + +It is only possible to connect an **OUTPUT** to an +**INPUT** door via an :class:`~Edge`. +""" +input NodeDoorInputCreate { + doorType: DoorType + name: String! + order: Int + code: String +} + +input NodeDoorInputUpdate { + uuid: UUID! + doorType: DoorType! = OUTPUT + name: String = null + order: Int = null + code: String = null +} + +union NodeDoorResponse = NodeDoor | InvalidPythonCode + input NodeUpdate { uuid: UUID! name: String = null @@ -433,7 +645,23 @@ input OffsetPaginationInput { limit: Int! = -1 } -"""An enumeration.""" +""" +Different kinds of playback. + +.. list-table:: Playback types + :header-rows: 1 + + * - Name + - Description + * - ``SYNC`` + - Plays back an audio file and waits for the + playback to finish before continuing the + execution of the script cells. + * - ``ASYNC`` + - Plays back an audio file and immediately + continues the execution of script cells. + This is fitting for e.g. background music. +""" enum PlaybackChoices { SYNC_PLAYBACK ASYNC_PLAYBACK diff --git a/caster-back/story_graph/admin.py b/caster-back/story_graph/admin.py index 0d7fc3aa..db40aace 100644 --- a/caster-back/story_graph/admin.py +++ b/caster-back/story_graph/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import AudioCell, Edge, Graph, Node, ScriptCell +from .models import AudioCell, Edge, Graph, Node, NodeDoor, ScriptCell class NodeInline(admin.TabularInline): @@ -8,16 +8,10 @@ class NodeInline(admin.TabularInline): extra: int = 0 -class InEdgeInline(admin.TabularInline): - model = Edge +class NodeDoorInline(admin.TabularInline): + model = NodeDoor extra = 1 - fk_name: str = "in_node" - - -class OutEdgeInline(admin.TabularInline): - model = Edge - extra = 1 - fk_name: str = "out_node" + fk_name = "node" class ScriptCellInline(admin.TabularInline): @@ -25,6 +19,33 @@ class ScriptCellInline(admin.TabularInline): extra: int = 1 +@admin.register(NodeDoor) +class NodeDoorAdmin(admin.ModelAdmin): + list_display = [ + "uuid", + "node", + "name", + "door_type", + "is_default", + ] + + autocomplete_fields = [ + "node", + ] + + search_fields = [ + "name", + "node__graph__name", + "node__name", + ] + + list_filter = [ + "door_type", + "node__graph", + "is_default", + ] + + @admin.register(Graph) class GraphAdmin(admin.ModelAdmin): inlines = [NodeInline] @@ -36,6 +57,12 @@ class GraphAdmin(admin.ModelAdmin): "public_visible", ] + search_fields = [ + "name", + "display_name", + "slug_name", + ] + prepopulated_fields = { "slug_name": ["name"], "display_name": ["name"], @@ -46,7 +73,7 @@ class GraphAdmin(admin.ModelAdmin): @admin.register(Node) class NodeAdmin(admin.ModelAdmin): - inlines = [ScriptCellInline, InEdgeInline, OutEdgeInline] + inlines = [ScriptCellInline, NodeDoorInline] list_display = [ "name", "graph", @@ -60,17 +87,36 @@ class NodeAdmin(admin.ModelAdmin): "is_blocking_node", ] + search_fields = [ + "name", + "graph__name", + ] + + autocomplete_fields = [ + "graph", + ] + @admin.register(Edge) class EdgeAdmin(admin.ModelAdmin): list_display = [ "uuid", - "in_node", - "out_node", + "in_node_door", + "out_node_door", + ] + + search_fields = [ + "in_node_door__node__name", + "out_node_door__node__name", + ] + + autocomplete_fields = [ + "in_node_door", + "out_node_door", ] list_filter = [ - "in_node__graph", + "in_node_door__node__graph", ] @@ -87,10 +133,18 @@ class ScriptCellAdmin(admin.ModelAdmin): "cell_type", ] + search_fields = [ + "node__name", + ] + readonly_fields = [ "uuid", ] + autocomplete_fields = [ + "node", + ] + @admin.register(AudioCell) class AudioCellAdmin(admin.ModelAdmin): @@ -98,8 +152,17 @@ class AudioCellAdmin(admin.ModelAdmin): readonly_fields = ["uuid"] + search_fields = [ + "uuid", + "node__name", + ] + list_filter = [ "playback", "script_cell__node__graph", "script_cell__node", ] + + autocomplete_fields = [ + "audio_file", + ] diff --git a/caster-back/story_graph/engine.py b/caster-back/story_graph/engine.py index ed7c5b6c..78ec7bb7 100644 --- a/caster-back/story_graph/engine.py +++ b/caster-back/story_graph/engine.py @@ -17,7 +17,7 @@ from stream.models import Stream, StreamInstruction, StreamVariable from .markdown_parser import md_to_ssml -from .models import AudioCell, CellType, Graph, Node, ScriptCell +from .models import AudioCell, CellType, Graph, Node, NodeDoor, ScriptCell log = logging.getLogger(__name__) @@ -26,6 +26,14 @@ class ScriptCellTimeout(Exception): pass +class GraphDeadEnd(Exception): + pass + + +class InvalidPythonCode(Exception): + pass + + class Engine: """An engine executes a :class:`~story_graph.models.Graph` for a given :class:`~stream.models.StreamPoint`. @@ -265,7 +273,10 @@ async def execute_python_cell(self, cell_code: str) -> AsyncGenerator[Dialog, No stream_variables.get("return", None) async def wait_for_finished_instruction( - self, instruction: StreamInstruction, timeout: int = 30, interval: float = 0.2 + self, + instruction: StreamInstruction, + timeout: float = 300.0, + interval: float = 0.2, ) -> None: log.debug(f"Wait for finished instruction {instruction.uuid}") for _ in range(int(timeout / interval)): @@ -274,6 +285,7 @@ async def wait_for_finished_instruction( return await asyncio.sleep(interval) log.info(f"Timed out on waiting for stream instruction {instruction.uuid}") + raise asyncio.TimeoutError() async def execute_node( self, node: Node, blocking_sleep_time: int = 10000 @@ -311,6 +323,79 @@ async def execute_node( else: log.error(f"Occured invalid/unknown CellType {cell_type}") + async def _evaluate_python_code(self, code: str) -> bool: + stream_variables = await self.get_stream_variables() + try: + r = eval( + code, + self.get_engine_global_vars( + { + "loop": asyncio.get_event_loop(), + "vars": stream_variables, + "self": self, + "get_stream_variables": self.get_stream_variables, + "wait_for_stream_variable": self.wait_for_stream_variable, + } + ), + ) + except Exception: + raise InvalidPythonCode() + if not isinstance(r, bool): + log.debug(f"Return type of '{code}' is not a boolean but {type(r)}") + raise InvalidPythonCode() + return r + + async def get_next_node(self) -> Node: + """Iterates over each exit :class:`~NodeDoor` + of the current node and evaluates its boolean value + and decides. + + If the node door code consists of invalid code it will be skipped. + If all boolean evaluations result in ``False`` or invalid code, + the default exit will be used. + + If multiple out-going edges are connected to an active door, + a random edge will be picked to follow for the next node. + + If the node does not have any out-going edges a :class:`~GraphDeadEnd` + exception will be raised. + """ + exit_door: Optional[NodeDoor] + async for node_door in NodeDoor.objects.filter( + node=self._current_node, + door_type=NodeDoor.DoorType.OUTPUT, + ).prefetch_related("node"): + try: + active_exit = await self._evaluate_python_code(node_door.code) + # a broad exception because many things can go wrong here while evaluating + # python code (e.g. even raising a custom exception), therefore we catch all + # possible exceptions here + except Exception: + log.debug( + f"Exception raised on evaluating code of node door {node_door}" + ) + continue + if active_exit: + log.debug(f"Choose exit {node_door} on {self._current_node}") + exit_door = node_door + break + else: + log.debug(f"Fallback to default node door on {self._current_node}") + exit_door = await NodeDoor.objects.filter( + node=self._current_node, + door_type=NodeDoor.DoorType.OUTPUT, + is_default=True, + ).afirst() + # else return default out + + if exit_door is None: + raise GraphDeadEnd() + + try: + return (await exit_door.out_edges.order_by("?").select_related("in_node_door__node").afirst()).in_node_door.node # type: ignore + except AttributeError: + raise GraphDeadEnd() + async def start( self, max_steps: int = 1000 ) -> AsyncGenerator[Union[StreamInstruction, Dialog], None]: @@ -334,17 +419,11 @@ async def start( await asyncio.sleep(self.blocking_time) # search for next node - if ( - new_node := await Node.objects.filter( - in_edges__in_node=self._current_node - ) - .order_by("?") - .afirst() - ): - self._current_node = new_node - else: - log.error( - f"Ran into a dead end on {self.graph} on {self._current_node}" - ) + try: + await self.get_next_node() + except GraphDeadEnd: + log.info(f"Ran into a dead end on {self.graph} on {self._current_node}") return await asyncio.sleep(0.1) + else: + log.info(f"Reached maximum steps on graph {self.graph} - stop execution") diff --git a/caster-back/story_graph/migrations/0001_initial.py b/caster-back/story_graph/migrations/0001_initial.py index c53ca0c9..1cab42c8 100644 --- a/caster-back/story_graph/migrations/0001_initial.py +++ b/caster-back/story_graph/migrations/0001_initial.py @@ -128,6 +128,7 @@ class Migration(migrations.Migration): on_delete=django.db.models.deletion.CASCADE, related_name="out_edges", to="story_graph.node", + null=True, ), ), ( @@ -136,6 +137,7 @@ class Migration(migrations.Migration): on_delete=django.db.models.deletion.CASCADE, related_name="in_edges", to="story_graph.node", + null=True, ), ), ], diff --git a/caster-back/story_graph/migrations/0015_alter_graph_about_text_alter_graph_end_text_and_more.py b/caster-back/story_graph/migrations/0015_alter_graph_about_text_alter_graph_end_text_and_more.py index 3a39a7ba..9eb6ee1c 100644 --- a/caster-back/story_graph/migrations/0015_alter_graph_about_text_alter_graph_end_text_and_more.py +++ b/caster-back/story_graph/migrations/0015_alter_graph_about_text_alter_graph_end_text_and_more.py @@ -14,7 +14,10 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(change_drifter_to_default_template), + migrations.RunPython( + code=change_drifter_to_default_template, + reverse_code=migrations.RunPython.noop, + ), migrations.AlterField( model_name="graph", name="about_text", diff --git a/caster-back/story_graph/migrations/0016_nodedoor_remove_edge_unique_edge_remove_edge_in_node_and_more.py b/caster-back/story_graph/migrations/0016_nodedoor_remove_edge_unique_edge_remove_edge_in_node_and_more.py new file mode 100644 index 00000000..691fbd9d --- /dev/null +++ b/caster-back/story_graph/migrations/0016_nodedoor_remove_edge_unique_edge_remove_edge_in_node_and_more.py @@ -0,0 +1,201 @@ +# Generated by Django 4.2.4 on 2023-08-30 22:53 +# +# The migration transforms the old Node - Edge - Node +# connection scheme to a Node - NodeExit - Edge - NodeExit - Node +# scheme which allows to have multiple outputs of a Node +# which is necessary to reflect visuals programming exits + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +def edge_migration(apps, schema_editor): + Edge = apps.get_model("story_graph", "Edge") + NodeDoor = apps.get_model("story_graph", "NodeDoor") + Node = apps.get_model("story_graph", "Node") + + # create default in- and out door for every node + for node in Node.objects.all(): + # hardcoded from enums as import of it is not available + for t in ["input", "output"]: + NodeDoor.objects.create( + door_type=t, + node=node, + name="default", + is_default=True, + code="", + ) + + # transfer the node-connecting edge to a + # door-connecting edge by using the default doors + for edge in Edge.objects.all(): + in_door = NodeDoor.objects.get( + # pay attention - switcheroo! + # + # old (focussed on edge terminology) + # Node_A --in_node--> edge --out_node--> Node_B + # + # new + # Node_A --out_door --> edge --in_door--> Node_B + node=edge.out_node, + door_type="input", + is_default=True, + ) + out_door = NodeDoor.objects.get( + node=edge.in_node, + door_type="output", + is_default=True, + ) + edge.in_node_door = in_door + edge.out_node_door = out_door + edge.save() + + +def reverse_edge_migration(apps, schema_editor): + Edge = apps.get_model("story_graph", "Edge") + # transfer each node door to a edge + for edge in Edge.objects.all(): + # remember the switcheroo + edge.out_node = edge.in_node_door.node + edge.in_node = edge.out_node_door.node + edge.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("story_graph", "0015_alter_graph_about_text_alter_graph_end_text_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="NodeDoor", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "door_type", + models.CharField( + choices=[("input", "Input"), ("output", "output")], + default="output", + max_length=20, + ), + ), + ("name", models.CharField(max_length=512)), + ("order", models.IntegerField(default=0)), + ("is_default", models.BooleanField(default=False)), + ("code", models.TextField(default="")), + ], + options={ + "verbose_name": "Node exit", + "verbose_name_plural": "Node exits", + "ordering": ["node", "is_default", "order", "name"], + }, + ), + migrations.RemoveConstraint( + model_name="edge", + name="unique_edge", + ), + migrations.AlterField( + model_name="graph", + name="about_text", + field=models.TextField( + blank=True, + default="", + help_text="Text about the graph which can be accessed during a stream - only if this is set", + verbose_name="About text (markdown)", + ), + ), + migrations.AlterField( + model_name="graph", + name="end_text", + field=models.TextField( + blank=True, + default="", + help_text="Text which will be displayed at the end of a stream", + verbose_name="End text (markdown)", + ), + ), + migrations.AlterField( + model_name="graph", + name="slug_name", + field=models.SlugField( + help_text="Will be used as a URL", + max_length=256, + unique=True, + verbose_name="Slug name", + ), + ), + migrations.AlterField( + model_name="graph", + name="start_text", + field=models.TextField( + blank=True, + default="", + help_text="Text about the graph which will be displayed at the start of a stream - only if this is set", + verbose_name="Start text (markdown)", + ), + ), + migrations.AddField( + model_name="nodedoor", + name="node", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="story_graph.node" + ), + ), + migrations.AddField( + model_name="edge", + name="in_node_door", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="in_edges", + to="story_graph.nodedoor", + ), + ), + migrations.AddField( + model_name="edge", + name="out_node_door", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="out_edges", + to="story_graph.nodedoor", + ), + ), + migrations.AddConstraint( + model_name="edge", + constraint=models.UniqueConstraint( + fields=("in_node_door", "out_node_door"), name="unique_edge" + ), + ), + migrations.AddConstraint( + model_name="nodedoor", + constraint=models.UniqueConstraint( + condition=models.Q(("is_default", True)), + fields=("node", "door_type"), + name="unique_default_per_type_and_node", + ), + ), + migrations.RunPython( + code=edge_migration, + reverse_code=reverse_edge_migration, + ), + migrations.RemoveField( + model_name="edge", + name="in_node", + ), + migrations.RemoveField( + model_name="edge", + name="out_node", + ), + ] diff --git a/caster-back/story_graph/models.py b/caster-back/story_graph/models.py index 4d81ed0d..30f6c9f5 100644 --- a/caster-back/story_graph/models.py +++ b/caster-back/story_graph/models.py @@ -3,11 +3,22 @@ ====== """ +import ast +import logging import uuid -from django.db import models -from django.db.models import Q +from asgiref.sync import async_to_sync, sync_to_async +from channels.layers import get_channel_layer +from django.core.exceptions import ValidationError +from django.db import models, transaction +from django.db.models import Q, signals +from django.dispatch import receiver from django.utils.translation import gettext as _ +from django_stubs_ext.db.models import TypedModelMeta + +from gencaster.distributor import GenCasterChannel + +log = logging.getLogger(__name__) class Graph(models.Model): @@ -209,6 +220,45 @@ class Node(models.Model): default=False, ) + async def aget_default_out_door(self) -> "NodeDoor": + return await sync_to_async(self.get_default_out_door)() + + def get_default_out_door(self) -> "NodeDoor": + default_out_door = NodeDoor.objects.filter( + node=self, + door_type=NodeDoor.DoorType.OUTPUT, + is_default=True, + ).first() + if default_out_door is None: + raise NodeDoorMissing(f"Default out door for node {self} is missing") + return default_out_door + + async def aget_default_in_door(self) -> "NodeDoor": + return await sync_to_async(self.get_default_in_door)() + + def get_default_in_door(self) -> "NodeDoor": + default_in_door = NodeDoor.objects.filter( + node=self, + door_type=NodeDoor.DoorType.INPUT, + is_default=True, + ).first() + if default_in_door is None: + raise NodeDoorMissing(f"Default in door for node {self} is missing") + return default_in_door + + def save(self, *args, **kwargs): + create_default_doors = self._state.adding + super().save(*args, **kwargs) + if create_default_doors: + for t in NodeDoor.DoorType: + NodeDoor( + node=self, + name="default", + door_type=t, + is_default=True, + code="True", + ).save() + class Meta: ordering = ["graph"] constraints = [ @@ -223,17 +273,52 @@ def __str__(self) -> str: return self.name -class Edge(models.Model): - """Connects two :class:`~Node` with each other. +class NodeDoorMissing(Exception): + """Exception that can be thrown if a node door is missing. + Normally each node should have a default in- and out + :class:`~NodeDoor` via a signal, but as this is not forced + via the database it is necessary to check for it. + In case this check fails, this exception can be raised. + """ - .. todo:: - With a script we can also jump to any other node - so it is not clear how to use this. - Maybe take a look at visual programming languages - such as MSP or Scratch how they handle this? +class NodeDoor(models.Model): + """A :class:`~Node` can be entered and exited via + multiple paths, where each of these exits and + entrances is called a *door*. + + A connection between nodes can only be made via their + doors. + There are two types of doors: + + .. list-table:: Door types + :header-rows: 1 + + * - Kind + - Description + * - **INPUT** + - Allows to enter a node. + Currently each Node only has one entry point + but for future development and a nicer + database operations it is also represented. + * - **OUTPUT** + - Allows to exit a node. + After all script cells of a node has been + executed, the condition of each door will + be evaluated (like in a switch case). + Once a condition has been met, the door + will be stepped through. + This allows to have a visual representation + of logic branches. + + It is only possible to connect an **OUTPUT** to an + **INPUT** door via an :class:`~Edge`. """ + class DoorType(models.TextChoices): + INPUT = "input", _("Input") + OUTPUT = "output", _("output") + uuid = models.UUIDField( primary_key=True, editable=False, @@ -241,32 +326,247 @@ class Edge(models.Model): unique=True, ) - in_node = models.ForeignKey( - Node, - related_name="out_edges", + door_type = models.CharField( + max_length=20, + blank=False, + null=False, + choices=DoorType.choices, + default=DoorType.OUTPUT, + ) + + node = models.ForeignKey( + to=Node, on_delete=models.CASCADE, + related_name="node_doors", ) - out_node = models.ForeignKey( - Node, + name = models.CharField( + max_length=512, + blank=False, + null=False, + ) + + order = models.IntegerField( + default=0, + ) + + is_default = models.BooleanField( + default=False, + ) + + code = models.TextField( + null=False, + blank=False, + default="", + ) + + # only here for type-hints on reverse-relations + out_edges: models.QuerySet["Edge"] + in_edges: models.QuerySet["Edge"] + + class Meta(TypedModelMeta): + ordering = [ + "node", + "is_default", + "order", + "name", + ] + verbose_name = _("Node door") + verbose_name_plural = _("Node doors") + + constraints = [ + models.UniqueConstraint( + fields=["node", "door_type"], + # see https://stackoverflow.com/a/72586940 + condition=Q(is_default=True), + name="unique_default_per_type_and_node", + ), + ] + + def save(self, *args, **kwargs): + try: + ast.parse(self.code) + except SyntaxError as e: + log.debug(f"Syntax error on node door {self}: {e}") + raise e + except Exception as e: + log.error(f"Unexpected error on saving {self}: {e}") + raise e + # ignore args/kwargs b/c of "Cannot force both insert and updating in model saving" problem + return super().save() + + def __str__(self) -> str: + return f"{self.node}: {self.door_type}_{self.name}" + + +@receiver(signals.post_save, sender=NodeDoor, dispatch_uid="update_node_door_ws") +def update_node_door_ws(sender, instance: NodeDoor, **kwargs) -> None: + channel_layer = get_channel_layer() + if channel_layer is None: + log.error( + "Failed to obtain a handle on the channel layer to distribute node_door updates" + ) + return + # sorry for this atrocity - there seems to be race conditions with signals + # which makes updates out-dated, see + # https://docs.djangoproject.com/en/dev/topics/db/transactions/#performing-actions-after-commit + # it is possible that the instance is not available anymore after the commit, so + # so we store it here in memory + node_uuid = instance.node.uuid + graph_uuid = instance.node.graph.uuid + transaction.on_commit( + lambda: async_to_sync(GenCasterChannel.send_node_update)( + channel_layer, node_uuid + ) + ) + transaction.on_commit( + lambda: async_to_sync(GenCasterChannel.send_graph_update)( + channel_layer, + graph_uuid, + ) + ) + + +@receiver(signals.post_delete, sender=NodeDoor, dispatch_uid="delete_node_door_ws") +def delete_node_door_ws(*args, **kwargs): + return update_node_door_ws(*args, **kwargs) + + +class Edge(models.Model): + """Connects two :class:`~Node` with each other by + using their respective :class:`~NodeDoor`. + + .. important:: + + It is important to note that an edge flows from + ``out_node_door`` to ``in_node_door`` as we follow + the notion from the perspective of a + :class:`story_graph.models.Node` rather than from the + edge. + + + .. graphviz:: + + digraph Connection { + rank = same; + subgraph cluster_node_a { + rank = same; + label = "NODE_A"; + NODE_A [shape=Msquare, label="NODE_A\\n\\nscript_cell_1\\nscript_cell_2"]; + subgraph cluster_in_nodes_a { + label = "IN_NODES"; + in_node_door_a [label="in_node_door"]; + } + subgraph cluster_out_nodes_a { + label = "OUT_NODES"; + out_node_door_a_1 [label="out_node_door 1"]; + out_node_door_a_2 [label="out_node_door 2"]; + } + in_node_door_a -> NODE_A [label="DB\\nreference"]; + {out_node_door_a_1, out_node_door_a_2} -> NODE_A; + in_node_door_a -> NODE_A [style=dashed, color=red, fontcolor=red, label="Engine\\nProgression"]; + NODE_A -> out_node_door_a_1 [style=dashed, color=red]; + } + + edge_ [shape=Msquare, label="EDGE"]; + edge_ -> out_node_door_a_1 [label="out_node_door"]; + edge_ -> in_node_door_b [label="in_node_door"]; + out_node_door_a_1 -> edge_ [style=dashed, color=red]; + edge_ -> in_node_door_b [style=dashed, color=red]; + + subgraph cluster_node_b { + rank = same; + label = "NODE_B"; + NODE_B [shape=Msquare]; + subgraph cluster_in_nodes_b { + label = "IN_NODES"; + in_node_door_b [label="in_node_door"]; + } + subgraph cluster_out_nodes_b { + label = "OUT_NODES"; + out_node_door_b_1 [label="out_node_door 1"]; + out_node_door_b_2 [label="out_node_door 2"]; + } + in_node_door_b -> NODE_B; + {out_node_door_b_1, out_node_door_b_2} -> NODE_B; + in_node_door_b -> NODE_B [style=dashed, color=red]; + NODE_B -> out_node_door_b_1 [style=dashed, color=red]; + } + } + """ + + uuid = models.UUIDField( + primary_key=True, + editable=False, + default=uuid.uuid4, + unique=True, + ) + + in_node_door = models.ForeignKey( + NodeDoor, related_name="in_edges", on_delete=models.CASCADE, + # this should not be none but as we + # added it during the lifecycle of + # gencaster it is optional as otherwise + # all prior data has to be deleted + null=True, ) - class Meta: + out_node_door = models.ForeignKey( + NodeDoor, + related_name="out_edges", + on_delete=models.CASCADE, + # see in_node_door + null=True, + ) + + def save(self, *args, **kwargs): + """Checks if ``in_node_door`` and ``out_node_door`` have their + respective types in order to avoid any *wrong* directions within + our graph. + """ + if self.in_node_door: + if self.in_node_door.door_type != NodeDoor.DoorType.INPUT: + raise ValidationError(_("in_node_door needs to be an input door")) + if self.out_node_door: + if self.out_node_door.door_type != NodeDoor.DoorType.OUTPUT: + raise ValidationError(_("out_node_door needs to be an output door")) + super().save(*args, **kwargs) + + class Meta(TypedModelMeta): constraints = [ models.UniqueConstraint( - fields=["in_node", "out_node"], + fields=["in_node_door", "out_node_door"], name="unique_edge", - ) + ), ] def __str__(self) -> str: - return f"{self.in_node} -> {self.out_node}" + return f"{self.in_node_door} -> {self.out_node_door}" class AudioCell(models.Model): + """Stores information for playback of static audio files.""" + class PlaybackChoices(models.TextChoices): + """Different kinds of playback. + + .. list-table:: Playback types + :header-rows: 1 + + * - Name + - Description + * - ``SYNC`` + - Plays back an audio file and waits for the + playback to finish before continuing the + execution of the script cells. + * - ``ASYNC`` + - Plays back an audio file and immediately + continues the execution of script cells. + This is fitting for e.g. background music. + """ + SYNC_PLAYBACK = ["sync_playback", _("Sync playback")] ASYNC_PLAYBACK = ["async_playback", _("Async playback")] @@ -302,7 +602,56 @@ def __str__(self) -> str: class CellType(models.TextChoices): - """Choice of foobar""" + """A :class:`~story_graph.models.ScriptCell` can contain + different types of code, each with unique functionality. + + Both, the database and :class:`~story_graph.engine.Engine`, + implement some specific details according to these types. + + .. list-table:: Cell types + :header-rows: 1 + + * - Name + - Description + - Database + - Engine + * - Markdown + - Allows to write arbitrary text which will get + rendered as an audio file via a text to speech service, + see :class:`~stream.models.TextToSpeech` for conversion + and :class:`~story_graph.markdown_parser.GencasterRenderer` + for the extended Markdown syntax. + - - :class:`~stream.models.TextToSpeech` + - - :func:`~story_graph.engine.Engine.execute_markdown_code` + - :class:`~story_graph.markdown_parser.GencasterRenderer` + * - Python + - Allows to execute python code via :func:`exec` which allows + to trigger e.g. Dialogs in the frontend + (see :class:`~stream.frontend_types.Dialog`) + or calculate or fetch any kind of data and store its value + as a :class:`~stream.models.StreamVariable`. + - + - - :func:`~story_graph.engine.Engine.execute_python_cell` + * - SuperCollider + - Executes *sclang* code on the associated server. + This can be used to control the sonic content on the server. + - - :class:`~stream.models.StreamInstruction` + - - :func:`~story_graph.engine.Engine.execute_sc_code` + - :ref:`OSC Server` + * - Comment + - Does not get executed, but allows to put comments into + the graph. + - + - + * - Audio + - Allows to playback static audio files. + The instruction will be translated into *sclang* code and will + be executed as such on the associated stream. + - - :class:`~story_graph.models.AudioCell` + - :class:`~stream.models.AudioFile` + - - :func:`~story_graph.engine.Engine.execute_audio_cell` + + """ MARKDOWN = ["markdown", _("Markdown")] PYTHON = ["python", _("Python")] diff --git a/caster-back/story_graph/tests.py b/caster-back/story_graph/tests.py index b01550a8..f4f38fce 100644 --- a/caster-back/story_graph/tests.py +++ b/caster-back/story_graph/tests.py @@ -2,8 +2,10 @@ import random from datetime import datetime from typing import Dict, Optional +from unittest import mock from asgiref.sync import async_to_sync, sync_to_async +from django.core.exceptions import ValidationError from django.db.utils import IntegrityError from django.test import TransactionTestCase from mistletoe import Document @@ -11,9 +13,18 @@ from stream.models import StreamVariable -from .engine import Engine, ScriptCellTimeout +from .engine import Engine, GraphDeadEnd, InvalidPythonCode, ScriptCellTimeout from .markdown_parser import GencasterRenderer -from .models import AudioCell, CellType, Edge, Graph, Node, ScriptCell +from .models import ( + AudioCell, + CellType, + Edge, + Graph, + Node, + NodeDoor, + NodeDoorMissing, + ScriptCell, +) class GraphTestCase(TransactionTestCase): @@ -62,34 +73,127 @@ def setUp(self) -> None: self.out_node: Node = mixer.blend(Node, graph=self.graph) # type: ignore @staticmethod - def get_edge(**kwargs) -> Edge: + def get_edge(create_nodes: bool = True, **kwargs) -> Edge: + if create_nodes: + node_a = NodeTestCase.get_node() + node_b = NodeTestCase.get_node() + kwargs["in_node_door"] = node_b.get_default_in_door() + kwargs["out_node_door"] = node_a.get_default_out_door() return mixer.blend(Edge, **kwargs) # type: ignore def test_fail_unique(self): Edge( - in_node=self.in_node, - out_node=self.out_node, + in_node_door=self.in_node.get_default_in_door(), + out_node_door=self.out_node.get_default_out_door(), ).save() # creating the same edge should fail with self.assertRaises(IntegrityError): Edge( - in_node=self.in_node, - out_node=self.out_node, + in_node_door=self.in_node.get_default_in_door(), + out_node_door=self.out_node.get_default_out_door(), ).save() - # but reverse is possible - Edge( - in_node=self.out_node, - out_node=self.in_node, - ).save() - def test_loop(self): Edge( - in_node=self.in_node, - out_node=self.in_node, + in_node_door=self.in_node.get_default_in_door(), + out_node_door=self.out_node.get_default_out_door(), ).save() + def test_in_node_door_is_input_door(self): + node = NodeTestCase.get_node() + edge = Edge( + in_node_door=node.get_default_out_door(), + out_node_door=node.get_default_out_door(), + ) + + with self.assertRaises(ValidationError): + edge.save() + + edge = Edge( + in_node_door=node.get_default_in_door(), + out_node_door=node.get_default_in_door(), + ) + + with self.assertRaises(ValidationError): + edge.save() + + async def test_outgoing_edges(self): + graph = await sync_to_async(GraphTestCase.get_graph)() + node_a = await sync_to_async(NodeTestCase.get_node)( + graph=graph, + ) + node_b = await sync_to_async(NodeTestCase.get_node)( + graph=graph, + ) + node_door_out_a = await node_a.aget_default_out_door() + node_door_in_b = await node_b.aget_default_in_door() + edge = await Edge.objects.acreate( + in_node_door=node_door_in_b, + out_node_door=node_door_out_a, + ) + self.assertEqual((await node_door_out_a.out_edges.afirst()).uuid, edge.uuid) # type: ignore + self.assertEqual((await node_door_in_b.in_edges.afirst()).uuid, edge.uuid) # type: ignore + + +class NodeDoorTestCase(TransactionTestCase): + @staticmethod + def get_node_door(**kwargs) -> NodeDoor: + return mixer.blend(NodeDoor, **kwargs) # type: ignore + + def test_create_default_doors(self): + node = NodeTestCase.get_node() + self.assertEqual(NodeDoor.objects.count(), 2) + default_in_door = node.get_default_in_door() + self.assertEqual(default_in_door.door_type, NodeDoor.DoorType.INPUT) + self.assertEqual(default_in_door.node.uuid, node.uuid) + + default_out_door = node.get_default_out_door() + self.assertEqual(default_out_door.door_type, NodeDoor.DoorType.OUTPUT) + self.assertEqual(default_out_door.node.uuid, node.uuid) + + def test_default_unique(self): + node = NodeTestCase.get_node() + + with self.assertRaises(IntegrityError): + NodeDoor( + node=node, + is_default=True, + door_type=NodeDoor.DoorType.INPUT, + ).save() + + with self.assertRaises(IntegrityError): + NodeDoor( + node=node, + is_default=True, + door_type=NodeDoor.DoorType.OUTPUT, + ).save() + + def test_get_default_door_missing_exception(self): + node = NodeTestCase.get_node() + NodeDoor.objects.all().delete() + with self.assertRaises(NodeDoorMissing): + node.get_default_in_door() + with self.assertRaises(NodeDoorMissing): + node.get_default_out_door() + + def test_invalid_python_code(self): + node = NodeTestCase.get_node() + door = node.get_default_in_door() + door.code = "2+/+2" + with self.assertRaises(SyntaxError): + door.save() + + # same as above but async as gql runs async + # therefore it makes sense to also test if the + # async variant calls the sync save code + async def test_invalid_python_code_async(self): + node = await sync_to_async(NodeTestCase.get_node)() + door = await node.aget_default_in_door() + door.code = "2+/+2" + with self.assertRaises(SyntaxError): + await door.asave() + class GencasterMarkdownTestCase(TransactionTestCase): @staticmethod @@ -256,8 +360,8 @@ async def test_non_blocking_exhausting(self): await sync_to_async(self.setup_graph_without_start)() entry_node = await self.graph.acreate_entry_node() await Edge.objects.acreate( - out_node=self.node, - in_node=entry_node, + out_node_door=await self.node.aget_default_out_door(), + in_node_door=await entry_node.aget_default_in_door(), ) self.node.is_blocking_node = False await sync_to_async(self.node.save)() @@ -273,14 +377,20 @@ def setup_with_script_cell( cell_code: str, stream_variables: Optional[Dict] = None, cell_type: CellType = CellType.PYTHON, + cell_kwargs: Optional[Dict] = None, ): from stream.tests import StreamTestCase + cell_kwargs = cell_kwargs if cell_kwargs else {} + self.graph = GraphTestCase.get_graph() self.stream = StreamTestCase.get_stream() entry_node = async_to_sync(self.graph.acreate_entry_node) self.script_cell = ScriptCellTestCase.get_script_cell( - node=entry_node, cell_type=cell_type, cell_code=cell_code + node=entry_node, + cell_type=cell_type, + cell_code=cell_code, + **cell_kwargs, ) async def helper_create_delayed_stream_variable( @@ -455,3 +565,290 @@ async def test_yield_dialog(self): self.assertEqual(dialog.content[0].text, "Hello World") # type: ignore self.assertEqual(len(dialog.buttons), 1) self.assertEqual(dialog.buttons[0].text, "OK") + + @mock.patch("stream.models.StreamPoint.speak_on_stream") + async def test_execute_markdown_code(self, speak_mock: mock.MagicMock): + await sync_to_async(self.setup_with_script_cell)( + "Hello world", + None, + cell_type=CellType.MARKDOWN, + ) + engine = Engine(self.graph, self.stream, raise_exceptions=True) + with mock.patch.object(engine, "wait_for_finished_instruction") as patch: + x = engine.start().__aiter__() + with self.assertRaises(StopAsyncIteration): + for _ in range(2): + await asyncio.wait_for(x.__anext__(), 0.2) + assert patch.called + speak_mock.assert_called_once_with("Hello world") + + @mock.patch("stream.models.StreamPoint.send_raw_instruction") + async def text_execute_sc_code(self, sc_instruction_mock: mock.MagicMock): + # @todo this yields no coverage although the tests makes it obvious + # that the code is executed + from stream.models import StreamInstruction + + await sync_to_async(self.setup_with_script_cell)( + "2+2", + None, + cell_type=CellType.SUPERCOLLIDER, + ) + engine = Engine(self.graph, self.stream, raise_exceptions=True) + with mock.patch.object(engine, "wait_for_finished_instruction") as patch: + x = engine.start().__aiter__() + with self.assertRaises(StopAsyncIteration): + for _ in range(4): + await asyncio.wait_for(x.__anext__(), 0.2) + assert patch.called + self.assertEqual(await StreamInstruction.objects.acount(), 1) + sc_instruction_mock.assert_called_once_with("2+2") + + @mock.patch("stream.models.StreamPoint.play_audio_file") + async def test_execute_audio_cell(self, play_audio_file_mock: mock.MagicMock): + audio_cell = await sync_to_async(AudioCellTestCase.get_audio_cell)() + await sync_to_async(self.setup_with_script_cell)( + cell_code="2+2", + stream_variables=None, + cell_type=CellType.AUDIO, + cell_kwargs={"audio_cell": audio_cell}, + ) + engine = Engine(self.graph, self.stream, raise_exceptions=True) + with mock.patch.object(engine, "wait_for_finished_instruction") as patch: + x = engine.start().__aiter__() + with self.assertRaises(StopAsyncIteration): + for _ in range(4): + await asyncio.wait_for(x.__anext__(), 0.2) + assert patch.called + play_audio_file_mock.assert_called_once_with( + audio_cell.audio_file, audio_cell.playback + ) + + async def test_wait_for_finished_instruction_timeout(self): + from stream.models import StreamInstruction + + await sync_to_async(self.setup_with_script_cell)( + cell_code="2+2", + cell_type=CellType.SUPERCOLLIDER, + ) + engine = Engine(self.graph, self.stream, raise_exceptions=True) + instruction = StreamInstruction( + stream_point=self.stream.stream_point, + state=StreamInstruction.InstructionState.SENT, + instruction_text="", + ) + await instruction.asave() + + with self.assertRaises(asyncio.TimeoutError): + await engine.wait_for_finished_instruction( + instruction=instruction, + timeout=0.01, + interval=0.001, + ) + + async def test_wait_for_finished_instruction(self): + from stream.models import StreamInstruction + + async def set_instruction_finished_with_delay( + stream_instruction: StreamInstruction, delay: float + ): + await asyncio.sleep(delay) + stream_instruction.state = StreamInstruction.InstructionState.FINISHED + await stream_instruction.asave() + + await sync_to_async(self.setup_with_script_cell)( + cell_code="2+2", + cell_type=CellType.SUPERCOLLIDER, + ) + engine = Engine(self.graph, self.stream, raise_exceptions=True) + instruction = StreamInstruction( + stream_point=self.stream.stream_point, + state=StreamInstruction.InstructionState.SENT, + instruction_text="", + ) + await instruction.asave() + + job = asyncio.gather( + set_instruction_finished_with_delay(instruction, 0.1), + engine.wait_for_finished_instruction( + instruction=instruction, + timeout=1.0, + interval=0.1, + ), + ) + await asyncio.wait_for(job, timeout=0.5) + + self.assertEqual((await StreamInstruction.objects.afirst()).state, StreamInstruction.InstructionState.FINISHED) # type: ignore + + async def test_evaluate_python_code(self): + await sync_to_async(self.setup_with_script_cell)( + cell_code="2+2", + cell_type=CellType.SUPERCOLLIDER, + ) + engine = Engine(self.graph, self.stream, raise_exceptions=True) + + with self.assertRaises(InvalidPythonCode): + await engine._evaluate_python_code("2+") + + with self.assertRaises(InvalidPythonCode): + await engine._evaluate_python_code("'foobar'") + + self.assertTrue(await engine._evaluate_python_code("2==2")) + self.assertFalse(await engine._evaluate_python_code("2==1")) + + async def test_get_next_node(self): + await sync_to_async(self.setup_with_script_cell)( + "2+2", + None, + cell_type=CellType.PYTHON, + ) + engine = Engine(self.graph, self.stream, raise_exceptions=True) + # start node + node_a: Node = await Node.objects.afirst() # type: ignore + node_b = await Node.objects.acreate( + graph=self.graph, + ) + # make sure all default doors are there + self.assertEqual(await NodeDoor.objects.acount(), 4) + await Edge.objects.acreate( + out_node_door=await node_a.aget_default_out_door(), + in_node_door=await node_b.aget_default_in_door(), + ) + self.assertEqual(await Edge.objects.acount(), 1) + engine._current_node = node_a + next_node = await engine.get_next_node() + self.assertEqual(next_node.uuid, node_b.uuid) + + async def test_get_next_node_with_vars(self): + await sync_to_async(self.setup_with_script_cell)( + "2+2", + None, + cell_type=CellType.PYTHON, + ) + engine = Engine(self.graph, self.stream, raise_exceptions=True) + # start node + node_a: Node = await Node.objects.afirst() # type: ignore + node_b = await Node.objects.acreate( + graph=self.graph, + ) + node_c = await Node.objects.acreate( + graph=self.graph, + ) + await Edge.objects.acreate( + out_node_door=await node_a.aget_default_out_door(), + in_node_door=await node_b.aget_default_in_door(), + ) + custom_node_door = await NodeDoor.objects.acreate( + door_type=NodeDoor.DoorType.OUTPUT, + node=node_a, + name="foobar", + is_default=False, + code="2==2", + ) + + await Edge.objects.acreate( + out_node_door=custom_node_door, + in_node_door=await node_c.aget_default_in_door(), + ) + self.assertEqual(await Edge.objects.acount(), 2) + # make sure all default doors are there + self.assertEqual(await NodeDoor.objects.acount(), 7) + + engine._current_node = node_a + next_node = await engine.get_next_node() + self.assertEqual(next_node.uuid, node_c.uuid) + + async def test_failed_node_door_code(self): + await sync_to_async(self.setup_with_script_cell)( + "2+2", + None, + cell_type=CellType.PYTHON, + ) + engine = Engine(self.graph, self.stream, raise_exceptions=True) + # start node + node_a: Node = await Node.objects.afirst() # type: ignore + node_b = await Node.objects.acreate( + graph=self.graph, + ) + node_c = await Node.objects.acreate( + graph=self.graph, + ) + await Edge.objects.acreate( + out_node_door=await node_a.aget_default_out_door(), + in_node_door=await node_b.aget_default_in_door(), + ) + custom_node_door = await NodeDoor.objects.acreate( + door_type=NodeDoor.DoorType.OUTPUT, + node=node_a, + name="foobar", + is_default=False, + code="foo+bar", + ) + + await Edge.objects.acreate( + out_node_door=custom_node_door, + in_node_door=await node_c.aget_default_in_door(), + ) + self.assertEqual(await Edge.objects.acount(), 2) + # make sure all default doors are there + self.assertEqual(await NodeDoor.objects.acount(), 7) + + engine._current_node = node_a + next_node = await engine.get_next_node() + self.assertEqual(next_node.uuid, node_b.uuid) + + async def test_node_door_code_false(self): + await sync_to_async(self.setup_with_script_cell)( + "2+2", + None, + cell_type=CellType.PYTHON, + ) + engine = Engine(self.graph, self.stream, raise_exceptions=True) + # start node + node_a: Node = await Node.objects.afirst() # type: ignore + node_b = await Node.objects.acreate( + graph=self.graph, + ) + node_c = await Node.objects.acreate( + graph=self.graph, + ) + await Edge.objects.acreate( + out_node_door=await node_a.aget_default_out_door(), + in_node_door=await node_b.aget_default_in_door(), + ) + stream_variable = await StreamVariable.objects.acreate( + stream=self.stream, + key="foo", + value="bar", + ) + custom_node_door = await NodeDoor.objects.acreate( + door_type=NodeDoor.DoorType.OUTPUT, + node=node_a, + name="foobar", + is_default=False, + code='vars["foo"]=="bar"', + ) + + await Edge.objects.acreate( + out_node_door=custom_node_door, + in_node_door=await node_c.aget_default_in_door(), + ) + self.assertEqual(await Edge.objects.acount(), 2) + # make sure all default doors are there + self.assertEqual(await NodeDoor.objects.acount(), 7) + + engine._current_node = node_a + next_node = await engine.get_next_node() + self.assertEqual(next_node.uuid, node_c.uuid) + + async def test_run_into_dead_end(self): + await sync_to_async(self.setup_with_script_cell)( + "2+2", + None, + cell_type=CellType.PYTHON, + ) + start_node = await Node.objects.afirst() + engine = Engine(self.graph, self.stream, raise_exceptions=True) + engine._current_node = start_node # type: ignore + + with self.assertRaises(GraphDeadEnd): + await engine.get_next_node() diff --git a/caster-back/story_graph/types.py b/caster-back/story_graph/types.py index 899a1fb2..20f19a23 100644 --- a/caster-back/story_graph/types.py +++ b/caster-back/story_graph/types.py @@ -21,6 +21,31 @@ CellType = strawberry.enum(models.CellType) # type: ignore PlaybackType = strawberry.enum(models.AudioCell.PlaybackChoices) # type: ignore TemplateType = strawberry.enum(models.Graph.GraphDetailTemplate) # type: ignore +NodeDoorType = strawberry.enum(models.NodeDoor.DoorType) # type: ignore + + +def create_python_highlight_string(e: SyntaxError) -> str: + """Creates from a given error a string which highlights + the error, so it will return for example + + foo++ + ^ + + """ + if not (e.text and e.lineno and e.offset and e.end_offset): + raise Exception(f"Missing syntax error information {e}") + t = e.text.split("\n") + patch = [" "] * e.end_offset + patch[max(e.offset - 1, 0) : max(e.end_offset - 1, 0)] = "^" + t.insert(e.lineno, "".join(patch)) + return "\n".join(t) + + +@strawberry.type +class InvalidPythonCode: + error_type: str + error_message: str + error_code: str @strawberry.input @@ -43,8 +68,8 @@ class NodeUpdate: @strawberry.input class EdgeInput: - node_in_uuid: uuid.UUID - node_out_uuid: uuid.UUID + node_door_in_uuid: uuid.UUID + node_door_out_uuid: uuid.UUID @strawberry.django.filters.filter(models.Graph, lookups=True) @@ -67,7 +92,7 @@ class Graph: @strawberry.django.field def edges(self) -> List["Edge"]: - return models.Edge.objects.filter(in_node__graph=self) # type: ignore + return models.Edge.objects.filter(out_node_door__node__graph=self) # type: ignore @strawberry.django.type(models.Node) @@ -79,16 +104,64 @@ class Node: position_y: auto is_entry_node: auto - in_edges: List["Edge"] - out_edges: List["Edge"] script_cells: List["ScriptCell"] + node_doors: List["NodeDoor"] + + @strawberry.django.field + def in_node_doors(self) -> List["NodeDoor"]: + return models.NodeDoor.objects.filter( + node=self, + door_type=models.NodeDoor.DoorType.INPUT, + ) # type: ignore + + @strawberry.django.field + def out_node_doors(self) -> List["NodeDoor"]: + return models.NodeDoor.objects.filter( + node=self, + door_type=models.NodeDoor.DoorType.OUTPUT, + ) # type: ignore + + +@strawberry.django.type(models.NodeDoor) +class NodeDoor: + uuid: auto + door_type: NodeDoorType # type: ignore + node: Node + name: auto + order: auto + is_default: auto + code: auto + + +@strawberry.django.input(models.NodeDoor) +class NodeDoorInputCreate: + # default is disabled as it is not possible for a user + # to create a default door + door_type: NodeDoorType # type: ignore + name: auto + order: auto + code: auto + + +# @strawberry.django.input(models.NodeDoor) - using this makes +# some mandatory fields optional - so we use a manual setup +@strawberry.input +class NodeDoorInputUpdate: + uuid: uuid.UUID + door_type: NodeDoorType = models.NodeDoor.DoorType.OUTPUT # type: ignore + name: Optional[str] = None + order: Optional[int] = None + code: Optional[str] = None + + +NodeDoorResponse = strawberry.union("NodeDoorResponse", [NodeDoor, InvalidPythonCode]) @strawberry.django.type(models.Edge) class Edge: uuid: auto - in_node: Node - out_node: Node + in_node_door: NodeDoor + out_node_door: NodeDoor @strawberry.django.type(models.AudioCell) diff --git a/caster-back/stream/admin.py b/caster-back/stream/admin.py index 9b5d1a3e..7b6b058d 100644 --- a/caster-back/stream/admin.py +++ b/caster-back/stream/admin.py @@ -22,7 +22,13 @@ class StreamLogInline(admin.TabularInline): fields = ["created_date", "name", "level", "message"] - readonly_fields = fields + search_fields = [ + "name", + "level", + "message", + ] + + readonly_fields = ["created_date", "name", "level", "message"] @admin.register(Stream) @@ -41,6 +47,14 @@ class StreamAdmin(admin.ModelAdmin): "num_listeners", ] + autocomplete_fields = [ + "stream_point", + ] + + search_fields = [ + "uuid", + ] + @admin.register(StreamPoint) class StreamPointAdmin(admin.ModelAdmin): @@ -55,12 +69,19 @@ class StreamPointAdmin(admin.ModelAdmin): "host", "port", "last_live", + "uuid", ] list_filter = [ "host", ] + search_fields = [ + "host", + "port", + "uuid", + ] + @admin.register(StreamInstruction) class StreamInstructionAdmin(admin.ModelAdmin): @@ -82,6 +103,15 @@ class StreamInstructionAdmin(admin.ModelAdmin): "return_value", ] + search_fields = [ + "uuid", + "return_value", + ] + + autocomplete_fields = [ + "stream_point", + ] + @admin.register(AudioFile) class AudioFileAdmin(admin.ModelAdmin): @@ -98,6 +128,11 @@ class AudioFileAdmin(admin.ModelAdmin): "modified_date", ] + search_fields = [ + "uuid", + "name", + ] + list_filter = ["created_date", "auto_generated"] @@ -123,6 +158,11 @@ class TextToSpeechAdmin(admin.ModelAdmin): "voice_name", ] + search_fields = [ + "text", + "uuid", + ] + @admin.register(StreamVariable) class StreamVariableAdmin(admin.ModelAdmin): @@ -138,6 +178,17 @@ class StreamVariableAdmin(admin.ModelAdmin): "stream__stream_point", ] + search_fields = [ + "uuid", + "key", + "value", + "stream__graph__name", + ] + + autocomplete_fields = [ + "stream", + ] + @admin.register(StreamLog) class StreamLogAdmin(admin.ModelAdmin): @@ -152,4 +203,9 @@ class StreamLogAdmin(admin.ModelAdmin): "level", ] + autocomplete_fields = [ + "stream_point", + "stream", + ] + search_fields = ["message"] diff --git a/caster-back/stream/apps.py b/caster-back/stream/apps.py index c9c38939..d33f56a1 100644 --- a/caster-back/stream/apps.py +++ b/caster-back/stream/apps.py @@ -75,7 +75,7 @@ def emit(self, record: LogRecord) -> None: self._queue.put(record) # see https://docs.python.org/3/howto/logging-cookbook.html#dealing-with-handlers-that-block - log_queue = Queue(-1) + log_queue: Queue = Queue(-1) root_logger = logging.getLogger() QueueHandler(log_queue) diff --git a/caster-back/stream/frontend_types.py b/caster-back/stream/frontend_types.py index d742f546..b00da944 100644 --- a/caster-back/stream/frontend_types.py +++ b/caster-back/stream/frontend_types.py @@ -113,7 +113,7 @@ def ok( ], **kwargs ): - """Constructor for a OK button which will""" + """Constructor for a OK button which will set the StreamVariable ``button`` to ``OK``.""" return cls( text=text, value=value, @@ -134,8 +134,7 @@ def cancel( **kwargs ): """Constructor for a cancel button which will simply close - the dialog and set the :class:`~story_graph.models.StreamVariable` ``CANCEL`` to ``'true'`` - (but as a string!). + the dialog and set the :class:`~story_graph.models.StreamVariable` ``button`` to ``'cancel'``. """ return cls( text=text, diff --git a/caster-editor/package-lock.json b/caster-editor/package-lock.json index 2dad0243..251c41ba 100644 --- a/caster-editor/package-lock.json +++ b/caster-editor/package-lock.json @@ -16,13 +16,13 @@ "@toast-ui/editor": "^3.2.2", "@urql/exchange-multipart-fetch": "^1.0.1", "@urql/vue": "^1.0.4", + "@vue-flow/core": "^1.21.2", "codemirror": "^6.0.1", "element-plus": "^2.3.5", "graphql": "^16.6.0", "gsap": "^3.11.4", "pinia": "^2.0.33", "subscriptions-transport-ws": "^0.11.0", - "v-network-graph": "^0.9.3", "vue": "^3.2.45", "vue-codemirror": "^6.1.1", "vue-router": "^4.1.6", @@ -1349,11 +1349,6 @@ "ms": "^2.1.1" } }, - "node_modules/@dash14/svg-pan-zoom": { - "version": "3.6.9", - "resolved": "https://registry.npmjs.org/@dash14/svg-pan-zoom/-/svg-pan-zoom-3.6.9.tgz", - "integrity": "sha512-6u+KTQec+9+3bRdk2mReix8AGsp2mB40cw0iYfQQzo22QBkeCNpXl2amnfwQzK7xB9oH/62Wvf2z7l6l2w+csA==" - }, "node_modules/@element-plus/icons-vue": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.1.0.tgz", @@ -3695,6 +3690,108 @@ "typescript": "*" } }, + "node_modules/@vue-flow/core": { + "version": "1.21.2", + "resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.21.2.tgz", + "integrity": "sha512-XsxE6o85zWjyjEZuxZYT02I1aujWptRfw+8GMzAiGXzsl2Fxy3TRaLDkmqcSlrWRU3y4X6o1fX6WEKPIVkURQQ==", + "dependencies": { + "@vueuse/core": "^10.1.2", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "peerDependencies": { + "vue": "^3.2.25" + } + }, + "node_modules/@vue-flow/core/node_modules/@types/web-bluetooth": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.17.tgz", + "integrity": "sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==" + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/core": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.2.1.tgz", + "integrity": "sha512-c441bfMbkAwTNwVRHQ0zdYZNETK//P84rC01aP2Uy/aRFCiie9NE/k9KdIXbno0eDYP5NPUuWv0aA/I4Unr/7w==", + "dependencies": { + "@types/web-bluetooth": "^0.0.17", + "@vueuse/metadata": "10.2.1", + "@vueuse/shared": "10.2.1", + "vue-demi": ">=0.14.5" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.5.tgz", + "integrity": "sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/metadata": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.2.1.tgz", + "integrity": "sha512-3Gt68mY/i6bQvFqx7cuGBzrCCQu17OBaGWS5JdwISpMsHnMKKjC2FeB5OAfMcCQ0oINfADP3i9A4PPRo0peHdQ==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/shared": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.2.1.tgz", + "integrity": "sha512-QWHq2bSuGptkcxx4f4M/fBYC3Y8d3M2UYyLsyzoPgEoVzJURQ0oJeWXu79OiLlBb8gTKkqe4mO85T/sf39mmiw==", + "dependencies": { + "vue-demi": ">=0.14.5" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.5.tgz", + "integrity": "sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@vue/compiler-core": { "version": "3.2.47", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.47.tgz", @@ -5615,37 +5712,57 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-dispatch": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "optional": true, - "peer": true, "engines": { "node": ">=12" } }, - "node_modules/d3-force": { + "node_modules/d3-drag": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", - "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "optional": true, - "peer": true, + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", "dependencies": { "d3-dispatch": "1 - 3", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" + "d3-selection": "3" }, "engines": { "node": ">=12" } }, - "node_modules/d3-quadtree": { + "node_modules/d3-ease": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", - "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "optional": true, - "peer": true, + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "engines": { "node": ">=12" } @@ -5654,8 +5771,39 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "optional": true, - "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, "engines": { "node": ">=12" } @@ -9019,11 +9167,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mitt": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.0.tgz", - "integrity": "sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==" - }, "node_modules/mlly": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.2.0.tgz", @@ -11716,25 +11859,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/v-network-graph": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/v-network-graph/-/v-network-graph-0.9.3.tgz", - "integrity": "sha512-po6IyJ7VBNSsXa6EV+oyjuktZnQM+pCSLR/DAcMkZjJ9c2oNLi0G6Et9RjK/mCO/4r3mnk8u1Z/XmjkRPLbT5w==", - "dependencies": { - "@dash14/svg-pan-zoom": "^3.6.9", - "lodash-es": "^4.17.21", - "mitt": "^3.0.0" - }, - "peerDependencies": { - "d3-force": "^3.0.0", - "vue": "^3.2.45" - }, - "peerDependenciesMeta": { - "d3-force": { - "optional": true - } - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -13642,11 +13766,6 @@ } } }, - "@dash14/svg-pan-zoom": { - "version": "3.6.9", - "resolved": "https://registry.npmjs.org/@dash14/svg-pan-zoom/-/svg-pan-zoom-3.6.9.tgz", - "integrity": "sha512-6u+KTQec+9+3bRdk2mReix8AGsp2mB40cw0iYfQQzo22QBkeCNpXl2amnfwQzK7xB9oH/62Wvf2z7l6l2w+csA==" - }, "@element-plus/icons-vue": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.1.0.tgz", @@ -15399,6 +15518,64 @@ "@volar/vue-language-core": "1.6.5" } }, + "@vue-flow/core": { + "version": "1.21.2", + "resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.21.2.tgz", + "integrity": "sha512-XsxE6o85zWjyjEZuxZYT02I1aujWptRfw+8GMzAiGXzsl2Fxy3TRaLDkmqcSlrWRU3y4X6o1fX6WEKPIVkURQQ==", + "requires": { + "@vueuse/core": "^10.1.2", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "dependencies": { + "@types/web-bluetooth": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.17.tgz", + "integrity": "sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==" + }, + "@vueuse/core": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.2.1.tgz", + "integrity": "sha512-c441bfMbkAwTNwVRHQ0zdYZNETK//P84rC01aP2Uy/aRFCiie9NE/k9KdIXbno0eDYP5NPUuWv0aA/I4Unr/7w==", + "requires": { + "@types/web-bluetooth": "^0.0.17", + "@vueuse/metadata": "10.2.1", + "@vueuse/shared": "10.2.1", + "vue-demi": ">=0.14.5" + }, + "dependencies": { + "vue-demi": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.5.tgz", + "integrity": "sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==", + "requires": {} + } + } + }, + "@vueuse/metadata": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.2.1.tgz", + "integrity": "sha512-3Gt68mY/i6bQvFqx7cuGBzrCCQu17OBaGWS5JdwISpMsHnMKKjC2FeB5OAfMcCQ0oINfADP3i9A4PPRo0peHdQ==" + }, + "@vueuse/shared": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.2.1.tgz", + "integrity": "sha512-QWHq2bSuGptkcxx4f4M/fBYC3Y8d3M2UYyLsyzoPgEoVzJURQ0oJeWXu79OiLlBb8gTKkqe4mO85T/sf39mmiw==", + "requires": { + "vue-demi": ">=0.14.5" + }, + "dependencies": { + "vue-demi": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.5.tgz", + "integrity": "sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==", + "requires": {} + } + } + } + } + }, "@vue/compiler-core": { "version": "3.2.47", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.47.tgz", @@ -16890,38 +17067,71 @@ } } }, + "d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, "d3-dispatch": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "optional": true, - "peer": true + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==" }, - "d3-force": { + "d3-drag": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", - "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "optional": true, - "peer": true, + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", "requires": { "d3-dispatch": "1 - 3", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" + "d3-selection": "3" } }, - "d3-quadtree": { + "d3-ease": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", - "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "optional": true, - "peer": true + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "requires": { + "d3-color": "1 - 3" + } + }, + "d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==" }, "d3-timer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "optional": true, - "peer": true + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, + "d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "requires": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + } + }, + "d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + } }, "dashdash": { "version": "1.14.1", @@ -19439,11 +19649,6 @@ "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", "dev": true }, - "mitt": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.0.tgz", - "integrity": "sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==" - }, "mlly": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.2.0.tgz", @@ -21430,16 +21635,6 @@ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true }, - "v-network-graph": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/v-network-graph/-/v-network-graph-0.9.3.tgz", - "integrity": "sha512-po6IyJ7VBNSsXa6EV+oyjuktZnQM+pCSLR/DAcMkZjJ9c2oNLi0G6Et9RjK/mCO/4r3mnk8u1Z/XmjkRPLbT5w==", - "requires": { - "@dash14/svg-pan-zoom": "^3.6.9", - "lodash-es": "^4.17.21", - "mitt": "^3.0.0" - } - }, "v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/caster-editor/package.json b/caster-editor/package.json index e81d914f..4fc8a941 100644 --- a/caster-editor/package.json +++ b/caster-editor/package.json @@ -28,13 +28,13 @@ "@toast-ui/editor": "^3.2.2", "@urql/exchange-multipart-fetch": "^1.0.1", "@urql/vue": "^1.0.4", + "@vue-flow/core": "^1.21.2", "codemirror": "^6.0.1", "element-plus": "^2.3.5", "graphql": "^16.6.0", "gsap": "^3.11.4", "pinia": "^2.0.33", "subscriptions-transport-ws": "^0.11.0", - "v-network-graph": "^0.9.3", "vue": "^3.2.45", "vue-codemirror": "^6.1.1", "vue-router": "^4.1.6", diff --git a/caster-editor/src/assets/scss/_elementplus.scss b/caster-editor/src/assets/scss/_elementplus.scss index d8227d1d..baf31ff6 100644 --- a/caster-editor/src/assets/scss/_elementplus.scss +++ b/caster-editor/src/assets/scss/_elementplus.scss @@ -12,12 +12,18 @@ --el-button-text-color: #{$black} !important; --el-button-hover-text-color: #{$black} !important; + + --el-input-border-radius: 0 !important; + --el-input-focus-border-color: #{$green-light} !important; + --el-disabled-bg-color: #{$grey-light} !important; } .el-button--primary { --el-color-primary: #{$green-light} !important; --el-button-hover-border-color: #{$green-light} !important; --el-button-hover-bg-color: #{$green-light} !important; + --el-button-disabled-bg-color: #{$grey} !important; + --el-button-disabled-border-color: #{$grey} !important; } .el-button--danger { @@ -64,6 +70,17 @@ } } +.el-input { + background-color: #{$grey-light}; + box-shadow: unset; +} + +.el-textarea, +.el-textarea__inner { + background-color: #{$grey-light}; + box-shadow: unset; +} + /* MODALS */ .el-overlay-dialog, .el-overlay { diff --git a/caster-editor/src/assets/scss/_vueflow.scss b/caster-editor/src/assets/scss/_vueflow.scss new file mode 100644 index 00000000..0b889e08 --- /dev/null +++ b/caster-editor/src/assets/scss/_vueflow.scss @@ -0,0 +1,14 @@ +/* ============ Vue Flow ============ */ + +.vue-flow__pane.draggable { + cursor: move !important; +} + +.gencaster-default-node { + background-color: $white; + border: 1px solid $mainBlack; + + &.selected { + box-shadow: 0px 0px 7px 0px $green-light; + } +} diff --git a/caster-editor/src/assets/scss/main.scss b/caster-editor/src/assets/scss/main.scss index 4eb9589c..4722f3b4 100644 --- a/caster-editor/src/assets/scss/main.scss +++ b/caster-editor/src/assets/scss/main.scss @@ -5,4 +5,5 @@ @import "typography"; @import "general"; @import "elementplus"; +@import "vueflow"; @import "interface"; // besides element plus diff --git a/caster-editor/src/assets/scss/variables.module.scss b/caster-editor/src/assets/scss/variables.module.scss index 117815a5..bce471d5 100644 --- a/caster-editor/src/assets/scss/variables.module.scss +++ b/caster-editor/src/assets/scss/variables.module.scss @@ -45,6 +45,9 @@ $buttonScale: 1.1; // menu $menuHeight: 32px; +// node +$nodeDefaultWidth: 160px; + // old $bodyBackground: white; $mainWhite: white; @@ -54,6 +57,7 @@ $hoverColor: lightgrey; // transition // $transitionDefault: 300ms ease; // $transitionFast: 1750ms ease; + :export { greenLight: $green-light; grey: $grey; @@ -64,4 +68,5 @@ $hoverColor: lightgrey; alertRed: $alertRed; white: $white; white08: $white08; + nodeDefaultWidth: $nodeDefaultWidth; } diff --git a/caster-editor/src/components/DialogAddNode.vue b/caster-editor/src/components/DialogAddNode.vue index 51c142b0..16af314b 100644 --- a/caster-editor/src/components/DialogAddNode.vue +++ b/caster-editor/src/components/DialogAddNode.vue @@ -1,44 +1,10 @@ - - + + diff --git a/caster-editor/src/components/DialogAddNodeDoor.vue b/caster-editor/src/components/DialogAddNodeDoor.vue new file mode 100644 index 00000000..7eb1fe9b --- /dev/null +++ b/caster-editor/src/components/DialogAddNodeDoor.vue @@ -0,0 +1,68 @@ + + + diff --git a/caster-editor/src/components/DialogExitGraph.vue b/caster-editor/src/components/DialogExitGraph.vue index c8687c96..976caa11 100644 --- a/caster-editor/src/components/DialogExitGraph.vue +++ b/caster-editor/src/components/DialogExitGraph.vue @@ -55,7 +55,7 @@ const exitGraph = () => { showDialog.value = false; selectedNodeForEditorUuid.value = undefined; - interfaceStore.resetScriptCellUpdates(); + interfaceStore.resetUpdates(); router.push({ path: "/graph", diff --git a/caster-editor/src/components/FlowNodeDefault.vue b/caster-editor/src/components/FlowNodeDefault.vue new file mode 100644 index 00000000..0f45c981 --- /dev/null +++ b/caster-editor/src/components/FlowNodeDefault.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/caster-editor/src/components/Graph.vue b/caster-editor/src/components/Graph.vue index 0d854e48..ffa5e124 100644 --- a/caster-editor/src/components/Graph.vue +++ b/caster-editor/src/components/Graph.vue @@ -1,91 +1,206 @@