diff --git a/caster-back/gencaster/distributor.py b/caster-back/gencaster/distributor.py index 22ddba1a..c2f964b7 100644 --- a/caster-back/gencaster/distributor.py +++ b/caster-back/gencaster/distributor.py @@ -11,6 +11,7 @@ from dataclasses import asdict, dataclass, field from typing import AsyncGenerator, Awaitable, Callable, List, Optional, Union +from channels.layers import get_channel_layer from channels_redis.core import RedisChannelLayer from strawberry.channels import GraphQLWSConsumer @@ -70,30 +71,36 @@ def __init__(self) -> None: pass @staticmethod - async def send_graph_update(layer: RedisChannelLayer, graph_uuid: uuid.UUID): + def _get_layer() -> RedisChannelLayer: + if layer := get_channel_layer(): + return layer + raise Exception("Could not obtain redis channel layer") + + @staticmethod + async def send_graph_update(graph_uuid: uuid.UUID): return await GenCasterChannel.send_message( - layer=layer, + layer=GenCasterChannel._get_layer(), message=GraphUpdateMessage(uuid=str(graph_uuid)), ) @staticmethod - async def send_node_update(layer: RedisChannelLayer, node_uuid: uuid.UUID): + async def send_node_update(node_uuid: uuid.UUID): return await GenCasterChannel.send_message( - layer=layer, message=NodeUpdateMessage(uuid=str(node_uuid)) + layer=GenCasterChannel._get_layer(), + message=NodeUpdateMessage(uuid=str(node_uuid)), ) @staticmethod - async def send_log_update( - layer: RedisChannelLayer, stream_log_message: "StreamLogUpdateMessage" - ): + async def send_log_update(stream_log_message: "StreamLogUpdateMessage"): return await GenCasterChannel.send_message( - layer=layer, message=stream_log_message + layer=GenCasterChannel._get_layer(), message=stream_log_message ) @staticmethod - async def send_streams_update(layer: RedisChannelLayer, stream_uuid: str): + async def send_streams_update(stream_uuid: str): return await GenCasterChannel.send_message( - layer=layer, message=StreamsUpdateMessage(uuid=str(stream_uuid)) + layer=GenCasterChannel._get_layer(), + message=StreamsUpdateMessage(uuid=str(stream_uuid)), ) @staticmethod diff --git a/caster-back/gencaster/schema.py b/caster-back/gencaster/schema.py index d8db5834..e37526f6 100644 --- a/caster-back/gencaster/schema.py +++ b/caster-back/gencaster/schema.py @@ -54,6 +54,7 @@ ScriptCell, ScriptCellInputCreate, ScriptCellInputUpdate, + UpdateGraphInput, create_python_highlight_string, ) from stream.exceptions import NoStreamAvailableException @@ -220,7 +221,7 @@ async def update_audio_file( audio_file.name = update_audio_file.name if update_audio_file.description: audio_file.description = update_audio_file.description - await sync_to_async(audio_file.save)() + await audio_file.asave() return audio_file # type: ignore @strawberry.mutation @@ -242,14 +243,7 @@ async def add_node(self, info: Info, new_node: NodeCreate) -> None: if new_value := getattr(new_node, field): setattr(node, field, new_value) - # asave not yet implemented in django 4.1 - await sync_to_async(node.save)() - - await GenCasterChannel.send_graph_update( - layer=info.context.channel_layer, - graph_uuid=graph.uuid, - ) - + await node.asave() return None @strawberry.mutation @@ -267,18 +261,7 @@ async def update_node(self, info: Info, node_update: NodeUpdate) -> None: if new_value := getattr(node_update, field): setattr(node, field, new_value) - await sync_to_async(node.save)() - - await GenCasterChannel.send_graph_update( - layer=info.context.channel_layer, - graph_uuid=node.graph.uuid, - ) - - await GenCasterChannel.send_node_update( - layer=info.context.channel_layer, - node_uuid=node.uuid, - ) - + await node.asave() return None @strawberry.mutation @@ -298,57 +281,19 @@ async def add_edge(self, info: Info, new_edge: EdgeInput) -> Edge: 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_door.node.graph.uuid, - ) return edge # type: ignore @strawberry.mutation async def delete_edge(self, info, edge_uuid: uuid.UUID) -> None: """Deletes a given :class:`~story_graph.models.Edge`.""" await graphql_check_authenticated(info) - try: - edge: story_graph_models.Edge = ( - await story_graph_models.Edge.objects.select_related( - "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}") - 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 + await story_graph_models.Edge.objects.filter(uuid=edge_uuid).adelete() @strawberry.mutation async def delete_node(self, info, node_uuid: uuid.UUID) -> None: """Deletes a given :class:`~story_graph.models.Node`.""" await graphql_check_authenticated(info) - try: - node: story_graph_models.Node = ( - await story_graph_models.Node.objects.select_related("graph").aget( - uuid=node_uuid - ) - ) - await story_graph_models.Node.objects.filter(uuid=node_uuid).adelete() - except Exception: - raise Exception(f"Could delete node {node_uuid}") - - await GenCasterChannel.send_graph_update( - layer=info.context.channel_layer, - graph_uuid=node.graph.uuid, - ) - - await GenCasterChannel.send_node_update( - layer=info.context.channel_layer, - node_uuid=node.uuid, - ) - - return None + await story_graph_models.Node.objects.filter(uuid=node_uuid).adelete() @strawberry.mutation async def create_script_cells( @@ -366,7 +311,6 @@ async def create_script_cells( ) except story_graph_models.Node.DoesNotExist as e: log.error(f"Received update on unknown node {node_uuid}") - # @todo return error raise e script_cells: List[story_graph_models.ScriptCell] = [] @@ -397,9 +341,6 @@ async def create_script_cells( log.debug(f"Created script cell {script_cell.uuid}") script_cells.append(script_cell) - await GenCasterChannel.send_node_update( - layer=info.context.channel_layer, node_uuid=node.uuid - ) return script_cells # type: ignore @strawberry.mutation @@ -438,13 +379,6 @@ async def update_script_cells( ).aupdate(**updates) script_cells.append(script_cell) - # send update to subscription if something was updated - if len(script_cells) > 0: - await GenCasterChannel.send_node_update( - layer=info.context.channel_layer, - node_uuid=await sync_to_async(lambda: script_cells[0].node.uuid)(), - ) - return script_cells # type: ignore @strawberry.mutation @@ -452,21 +386,10 @@ async def delete_script_cell(self, info, script_cell_uuid: uuid.UUID) -> None: """Deletes a given :class:`~story_graph.models.ScriptCell`.""" await graphql_check_authenticated(info) - # first get the node before the cell is deleted - node = await story_graph_models.Node.objects.filter( - script_cells__uuid=script_cell_uuid - ).afirst() - await story_graph_models.ScriptCell.objects.filter( uuid=script_cell_uuid ).adelete() - if node: - await GenCasterChannel.send_node_update( - layer=info.context.channel_layer, - node_uuid=node.uuid, - ) - @strawberry.mutation async def add_graph(self, info, graph_input: AddGraphInput) -> Graph: await graphql_check_authenticated(info) @@ -486,6 +409,23 @@ async def add_graph(self, info, graph_input: AddGraphInput) -> Graph: # https://docs.djangoproject.com/en/4.2/ref/models/instances/#django.db.models.Model.arefresh_from_db return await story_graph_models.Graph.objects.aget(uuid=graph.uuid) # type: ignore + @strawberry.mutation + async def update_graph( + self, info, graph_input: UpdateGraphInput, graph_uuid: uuid.UUID + ) -> Graph: + await graphql_check_authenticated(info) + + graph = await story_graph_models.Graph.objects.aget(uuid=graph_uuid) + + for key, value in graph_input.__dict__.items(): + if value == strawberry.UNSET: + continue + graph.__setattr__(key, value) + + await graph.asave() + + return graph # type: ignore + @strawberry.mutation async def add_audio_file(self, info, new_audio_file: AddAudioFile) -> AudioFileUploadResponse: # type: ignore if new_audio_file.file is None or len(new_audio_file.file) == 0: @@ -556,7 +496,7 @@ async def update_node_door( self, info, node_door_input: NodeDoorInputUpdate, - ) -> NodeDoorResponse: + ) -> NodeDoorResponse: # type: ignore await graphql_check_authenticated(info) node_door = await story_graph_models.NodeDoor.objects.aget( uuid=node_door_input.uuid diff --git a/caster-back/gencaster/tests.py b/caster-back/gencaster/tests.py index 4974a497..ad8c3151 100644 --- a/caster-back/gencaster/tests.py +++ b/caster-back/gencaster/tests.py @@ -194,8 +194,7 @@ async def test_delete_unavailable_edge(self): variable_values={"edgeUuid": str(uuid.uuid4())}, context_value=self.get_login_context(), ) - - self.assertGreaterEqual(len(resp.errors), 1) # type: ignore + self.assertIsNone(resp.data["deleteEdge"]) # type: ignore NODE_DELETE_MUTATION = """ mutation deleteNode($nodeUuid: UUID!) { @@ -226,7 +225,7 @@ async def test_delete_unavailable_node(self): context_value=self.get_login_context(), ) - self.assertGreaterEqual(len(resp.errors), 1) # type: ignore + self.assertIsNone(resp.data["deleteNode"]) # type: ignore CREATE_SCRIPT_CELL = """ mutation CreateScriptCells($nodeUuid: UUID!, $scriptCellInputs: [ScriptCellInputCreate!]!) { diff --git a/caster-back/operations.gql b/caster-back/operations.gql index e2ef38d9..5e3a9158 100644 --- a/caster-back/operations.gql +++ b/caster-back/operations.gql @@ -208,6 +208,25 @@ subscription node($uuid: UUID!) { } } +fragment GraphMetaData on Graph { + uuid + templateName + startText + slugName + name + endText + displayName + aboutText + streamAssignmentPolicy + publicVisible +} + +query GetGraph($graphUuid:ID!) { + graph(pk: $graphUuid) { + ...GraphMetaData + } +} + mutation CreateGraph($graphInput: AddGraphInput!) { addGraph(graphInput: $graphInput) { name @@ -220,6 +239,15 @@ mutation CreateGraph($graphInput: AddGraphInput!) { } } +mutation UpdateGraph($graphUuid:UUID!, $graphUpdate: UpdateGraphInput!) { + updateGraph( + graphInput: $graphUpdate + graphUuid: $graphUuid + ) { + uuid + } +} + subscription stream($graphUuid: UUID!) { streamInfo(graphUuid: $graphUuid) { __typename diff --git a/caster-back/schema.gql b/caster-back/schema.gql index b3f6906f..227ab87b 100644 --- a/caster-back/schema.gql +++ b/caster-back/schema.gql @@ -47,7 +47,7 @@ input AddGraphInput { publicVisible: Boolean """Manages the stream assignment for this graph""" - streamAssignmentPolicy: String + streamAssignmentPolicy: StreamAssignmentPolicy """ Allows to switch to a different template in the frontend with different connection flows or UI @@ -360,6 +360,14 @@ type Graph { """ templateName: GraphDetailTemplate! + """Manages the stream assignment for this graph""" + streamAssignmentPolicy: StreamAssignmentPolicy! + + """ + If the graph is not public it will not be listed in the frontend, yet it is still accessible via URL + """ + publicVisible: Boolean! + """ Text about the graph which will be displayed at the start of a stream - only if this is set """ @@ -492,6 +500,7 @@ type Mutation { """Deletes a given :class:`~story_graph.models.ScriptCell`.""" deleteScriptCell(scriptCellUuid: UUID!): Void addGraph(graphInput: AddGraphInput!): Graph! + updateGraph(graphInput: UpdateGraphInput!, graphUuid: UUID!): Graph! addAudioFile(newAudioFile: AddAudioFile!): AudioFileUploadResponse! createUpdateStreamVariable(streamVariables: [StreamVariableInput!]!): [StreamVariable!]! createNodeDoor(nodeDoorInput: NodeDoorInputCreate!, nodeUuid: UUID!): NodeDoor! @@ -758,6 +767,13 @@ type Stream { streamPoint: StreamPoint! } +"""An enumeration.""" +enum StreamAssignmentPolicy { + ONE_GRAPH_ONE_STREAM + ONE_USER_ONE_STREAM + DEACTIVATE +} + type StreamInfo { stream: Stream! streamInstruction: StreamInstruction @@ -930,6 +946,53 @@ input UpdateAudioFile { name: String } +""" +A collection of :class:`~Node` and :class:`~Edge`. +This can be considered a score as well as a program as it +has an entry point as a :class:`~Node` and can jump to any +other :class:`~Node`, also allowing for recursive loops/cycles. + +Each node can be considered a little program on its own which can consist +of multiple :class:`~ScriptCell` which can be coded in a variety of +languages which can control the frontend and the audio (by e.g. speaking +on the stream) or setting a background music. + +The story graph is a core concept and can be edited with a native editor. +""" +input UpdateGraphInput { + """Name of the graph""" + name: String + + """Will be used as a display name in the frontend""" + displayName: String + + """ + Text about the graph which will be displayed at the start of a stream - only if this is set + """ + startText: String + + """ + Text about the graph which can be accessed during a stream - only if this is set + """ + aboutText: String + + """Text which will be displayed at the end of a stream""" + endText: String + + """ + If the graph is not public it will not be listed in the frontend, yet it is still accessible via URL + """ + publicVisible: Boolean + + """Manages the stream assignment for this graph""" + streamAssignmentPolicy: StreamAssignmentPolicy + + """ + Allows to switch to a different template in the frontend with different connection flows or UI + """ + templateName: GraphDetailTemplate +} + scalar Upload """ diff --git a/caster-back/story_graph/models.py b/caster-back/story_graph/models.py index 30f6c9f5..5816eb96 100644 --- a/caster-back/story_graph/models.py +++ b/caster-back/story_graph/models.py @@ -8,7 +8,6 @@ import uuid 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 @@ -160,6 +159,20 @@ def __str__(self) -> str: return self.name +def update_graph_db_to_ws(graph_uuid: uuid.UUID): + # 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 + transaction.on_commit( + lambda: async_to_sync(GenCasterChannel.send_graph_update)(graph_uuid) + ) + + +@receiver(signals.post_save, sender=Graph, dispatch_uid="update_graph_ws") +def update_graph_ws(sender, instance: Graph, **kwargs) -> None: + update_graph_db_to_ws(instance.uuid) + + class Node(models.Model): """ A node. @@ -273,6 +286,22 @@ def __str__(self) -> str: return self.name +def update_node_db_to_ws(node_uuid: uuid.UUID): + # 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 + transaction.on_commit( + lambda: async_to_sync(GenCasterChannel.send_node_update)(node_uuid) + ) + + +@receiver(signals.post_delete, sender=Node, dispatch_uid="delete_node_ws") +@receiver(signals.post_save, sender=Node, dispatch_uid="update_node_ws") +def update_node_ws(sender, instance: Node, **kwargs) -> None: + update_node_db_to_ws(instance.uuid) + update_graph_db_to_ws(instance.graph.uuid) + + class NodeDoorMissing(Exception): """Exception that can be thrown if a node door is missing. Normally each node should have a default in- and out @@ -401,30 +430,8 @@ def __str__(self) -> str: @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, - ) - ) + update_graph_db_to_ws(instance.node.graph.uuid) + update_node_db_to_ws(instance.node.uuid) @receiver(signals.post_delete, sender=NodeDoor, dispatch_uid="delete_node_door_ws") @@ -546,6 +553,16 @@ def __str__(self) -> str: return f"{self.in_node_door} -> {self.out_node_door}" +@receiver(signals.pre_delete, sender=Edge, dispatch_uid="delete_edge_ws") +@receiver(signals.post_save, sender=Edge, dispatch_uid="update_edge_ws") +def update_edge_ws(sender, instance: Edge, **kwargs) -> None: + if instance.out_node_door: + update_graph_db_to_ws(instance.out_node_door.node.graph.uuid) + update_node_db_to_ws(instance.out_node_door.node.uuid) + if instance.in_node_door: + update_node_db_to_ws(instance.in_node_door.node.uuid) + + class AudioCell(models.Model): """Stores information for playback of static audio files.""" @@ -718,3 +735,9 @@ class Meta: def __str__(self) -> str: return f"{self.node}-{self.cell_order} ({self.cell_type})" + + +@receiver(signals.post_delete, sender=ScriptCell, dispatch_uid="delete_script_cell") +@receiver(signals.post_save, sender=ScriptCell, dispatch_uid="update_script_cell_ws") +def update_script_cell_ws(sender, instance: ScriptCell, **kwargs) -> None: + update_node_db_to_ws(instance.node.uuid) diff --git a/caster-back/story_graph/types.py b/caster-back/story_graph/types.py index 20f19a23..749a8ebd 100644 --- a/caster-back/story_graph/types.py +++ b/caster-back/story_graph/types.py @@ -22,6 +22,7 @@ PlaybackType = strawberry.enum(models.AudioCell.PlaybackChoices) # type: ignore TemplateType = strawberry.enum(models.Graph.GraphDetailTemplate) # type: ignore NodeDoorType = strawberry.enum(models.NodeDoor.DoorType) # type: ignore +StreamAssignmentPolicy = strawberry.enum(models.Graph.StreamAssignmentPolicy) # type: ignore def create_python_highlight_string(e: SyntaxError) -> str: @@ -85,6 +86,8 @@ class Graph: display_name: auto slug_name: auto template_name: TemplateType + stream_assignment_policy: StreamAssignmentPolicy + public_visible: auto start_text: auto about_text: auto end_text: auto @@ -95,6 +98,31 @@ def edges(self) -> List["Edge"]: return models.Edge.objects.filter(out_node_door__node__graph=self) # type: ignore +@strawberry.django.input(models.Graph) +class AddGraphInput: + name: auto + display_name: auto + slug_name: auto + start_text: auto + about_text: auto + end_text: auto + public_visible: auto + stream_assignment_policy: StreamAssignmentPolicy + template_name: TemplateType + + +@strawberry.django.input(model=models.Graph, partial=True) +class UpdateGraphInput: + name: auto + display_name: auto + start_text: auto + about_text: auto + end_text: auto + public_visible: auto + stream_assignment_policy: StreamAssignmentPolicy + template_name: TemplateType + + @strawberry.django.type(models.Node) class Node: uuid: auto @@ -210,16 +238,3 @@ class ScriptCellInputUpdate: cell_code: Optional[str] cell_order: Optional[int] audio_cell: Optional[AudioCellInput] - - -@strawberry.django.input(models.Graph) -class AddGraphInput: - name: auto - display_name: auto - slug_name: auto - start_text: auto - about_text: auto - end_text: auto - public_visible: auto - stream_assignment_policy: auto - template_name: TemplateType diff --git a/caster-back/stream/apps.py b/caster-back/stream/apps.py index d33f56a1..af1851a4 100644 --- a/caster-back/stream/apps.py +++ b/caster-back/stream/apps.py @@ -6,7 +6,6 @@ from threading import Thread from asgiref.sync import async_to_sync -from channels.layers import get_channel_layer from django.apps import AppConfig @@ -32,7 +31,6 @@ def __init__(self, level: int = logging.DEBUG) -> None: self._thread = Thread(target=self._loop) self._thread.daemon = True self._thread.start() - self._channel = get_channel_layer() self._event_loop = asyncio.get_event_loop() super().__init__(level) @@ -61,7 +59,6 @@ def _loop(self): ) async_to_sync(GenCasterChannel.send_log_update)( - self._channel, StreamLogUpdateMessage( uuid=str(stream_log.uuid), stream_point_uuid=str(stream_point.uuid) diff --git a/caster-back/stream/models.py b/caster-back/stream/models.py index 1e88bf5f..9cd8c099 100644 --- a/caster-back/stream/models.py +++ b/caster-back/stream/models.py @@ -5,7 +5,6 @@ from typing import Optional from asgiref.sync import async_to_sync -from channels.layers import get_channel_layer from django.conf import settings from django.contrib import admin from django.core.files import File @@ -335,9 +334,7 @@ def __str__(self) -> str: @receiver(signals.post_save, sender=Stream, dispatch_uid="update_streams_ws") def update_streams_ws(sender, instance: Stream, **kwargs): - async_to_sync(GenCasterChannel.send_streams_update)( - get_channel_layer(), str(instance.uuid) - ) + async_to_sync(GenCasterChannel.send_streams_update)(str(instance.uuid)) class StreamVariable(models.Model): diff --git a/caster-editor/src/components/MenuTabEdit.vue b/caster-editor/src/components/MenuTabEdit.vue index 6ed7c67e..0b143931 100644 --- a/caster-editor/src/components/MenuTabEdit.vue +++ b/caster-editor/src/components/MenuTabEdit.vue @@ -54,8 +54,9 @@ const removeSelection = async () => { }); if (error) { ElMessage.error(`Could not delete edge ${edgeUuid}: ${error.message}`); + } else { + ElMessage.info(`Deleted edge ${edgeUuid}`); } - ElMessage.info(`Deleted edge ${edgeUuid}`); }); selectedNodeUUIDs.value.forEach(async (nodeUuid) => { @@ -64,8 +65,9 @@ const removeSelection = async () => { }); if (error) { ElMessage.error(`Could not delete node ${nodeUuid}: ${error.message}`); + } else { + ElMessage.info(`Deleted node ${nodeUuid}`); } - ElMessage.info(`Deleted node ${nodeUuid}`); }); }; diff --git a/caster-editor/src/components/MenuTabHeader.vue b/caster-editor/src/components/MenuTabHeader.vue index 893716b4..fa888932 100644 --- a/caster-editor/src/components/MenuTabHeader.vue +++ b/caster-editor/src/components/MenuTabHeader.vue @@ -1,3 +1,10 @@ + + - - diff --git a/caster-editor/src/components/Meta.vue b/caster-editor/src/components/Meta.vue new file mode 100644 index 00000000..e39eda65 --- /dev/null +++ b/caster-editor/src/components/Meta.vue @@ -0,0 +1,250 @@ + + + + + + diff --git a/caster-editor/src/components/Wysiwyg.vue b/caster-editor/src/components/Wysiwyg.vue new file mode 100644 index 00000000..ffd39754 --- /dev/null +++ b/caster-editor/src/components/Wysiwyg.vue @@ -0,0 +1,83 @@ + + + + + + diff --git a/caster-editor/src/graphql.ts b/caster-editor/src/graphql.ts index a734c295..9940e210 100644 --- a/caster-editor/src/graphql.ts +++ b/caster-editor/src/graphql.ts @@ -63,7 +63,7 @@ export type AddGraphInput = { /** Text about the graph which will be displayed at the start of a stream - only if this is set */ startText?: InputMaybe; /** Manages the stream assignment for this graph */ - streamAssignmentPolicy?: InputMaybe; + streamAssignmentPolicy?: InputMaybe; /** Allows to switch to a different template in the frontend with different connection flows or UI */ templateName?: InputMaybe; }; @@ -362,10 +362,14 @@ export type Graph = { /** Name of the graph */ name: Scalars["String"]; nodes: Array; + /** If the graph is not public it will not be listed in the frontend, yet it is still accessible via URL */ + publicVisible: Scalars["Boolean"]; /** Will be used as a URL */ slugName: Scalars["String"]; /** Text about the graph which will be displayed at the start of a stream - only if this is set */ startText: Scalars["String"]; + /** Manages the stream assignment for this graph */ + streamAssignmentPolicy: StreamAssignmentPolicy; /** Allows to switch to a different template in the frontend with different connection flows or UI */ templateName: GraphDetailTemplate; uuid: Scalars["UUID"]; @@ -478,6 +482,7 @@ export type Mutation = { deleteScriptCell?: Maybe; /** Update metadata of an :class:`~stream.models.AudioFile` via a UUID */ updateAudioFile: AudioFile; + updateGraph: Graph; /** * Updates a given :class:`~story_graph.models.Node` which can be used * for renaming or moving it across the canvas. @@ -556,6 +561,12 @@ export type MutationUpdateAudioFileArgs = { uuid: Scalars["UUID"]; }; +/** Mutations for Gencaster via GraphQL. */ +export type MutationUpdateGraphArgs = { + graphInput: UpdateGraphInput; + graphUuid: Scalars["UUID"]; +}; + /** Mutations for Gencaster via GraphQL. */ export type MutationUpdateNodeArgs = { nodeUpdate: NodeUpdate; @@ -858,6 +869,13 @@ export type Stream = { uuid: Scalars["UUID"]; }; +/** An enumeration. */ +export enum StreamAssignmentPolicy { + Deactivate = "DEACTIVATE", + OneGraphOneStream = "ONE_GRAPH_ONE_STREAM", + OneUserOneStream = "ONE_USER_ONE_STREAM", +} + export type StreamInfo = { stream: Stream; streamInstruction?: Maybe; @@ -1037,6 +1055,38 @@ export type UpdateAudioFile = { name?: InputMaybe; }; +/** + * A collection of :class:`~Node` and :class:`~Edge`. + * This can be considered a score as well as a program as it + * has an entry point as a :class:`~Node` and can jump to any + * other :class:`~Node`, also allowing for recursive loops/cycles. + * + * Each node can be considered a little program on its own which can consist + * of multiple :class:`~ScriptCell` which can be coded in a variety of + * languages which can control the frontend and the audio (by e.g. speaking + * on the stream) or setting a background music. + * + * The story graph is a core concept and can be edited with a native editor. + */ +export type UpdateGraphInput = { + /** Text about the graph which can be accessed during a stream - only if this is set */ + aboutText?: InputMaybe; + /** Will be used as a display name in the frontend */ + displayName?: InputMaybe; + /** Text which will be displayed at the end of a stream */ + endText?: InputMaybe; + /** Name of the graph */ + name?: InputMaybe; + /** If the graph is not public it will not be listed in the frontend, yet it is still accessible via URL */ + publicVisible?: InputMaybe; + /** Text about the graph which will be displayed at the start of a stream - only if this is set */ + startText?: InputMaybe; + /** Manages the stream assignment for this graph */ + streamAssignmentPolicy?: InputMaybe; + /** Allows to switch to a different template in the frontend with different connection flows or UI */ + templateName?: InputMaybe; +}; + /** * Users within the Django authentication system are represented by this * model. @@ -1312,6 +1362,38 @@ export type NodeSubscription = { }; }; +export type GraphMetaDataFragment = { + uuid: any; + templateName: GraphDetailTemplate; + startText: string; + slugName: string; + name: string; + endText: string; + displayName: string; + aboutText: string; + streamAssignmentPolicy: StreamAssignmentPolicy; + publicVisible: boolean; +}; + +export type GetGraphQueryVariables = Exact<{ + graphUuid: Scalars["ID"]; +}>; + +export type GetGraphQuery = { + graph: { + uuid: any; + templateName: GraphDetailTemplate; + startText: string; + slugName: string; + name: string; + endText: string; + displayName: string; + aboutText: string; + streamAssignmentPolicy: StreamAssignmentPolicy; + publicVisible: boolean; + }; +}; + export type CreateGraphMutationVariables = Exact<{ graphInput: AddGraphInput; }>; @@ -1324,6 +1406,13 @@ export type CreateGraphMutation = { }; }; +export type UpdateGraphMutationVariables = Exact<{ + graphUuid: Scalars["UUID"]; + graphUpdate: UpdateGraphInput; +}>; + +export type UpdateGraphMutation = { updateGraph: { uuid: any } }; + export type StreamSubscriptionVariables = Exact<{ graphUuid: Scalars["UUID"]; }>; @@ -1674,6 +1763,20 @@ export const NodeDoorDetailFragmentDoc = gql` doorType } `; +export const GraphMetaDataFragmentDoc = gql` + fragment GraphMetaData on Graph { + uuid + templateName + startText + slugName + name + endText + displayName + aboutText + streamAssignmentPolicy + publicVisible + } +`; export const StreamInfoFragmentFragmentDoc = gql` fragment StreamInfoFragment on Stream { uuid @@ -1981,6 +2084,20 @@ export function useNodeSubscription( handler, ); } +export const GetGraphDocument = gql` + query GetGraph($graphUuid: ID!) { + graph(pk: $graphUuid) { + ...GraphMetaData + } + } + ${GraphMetaDataFragmentDoc} +`; + +export function useGetGraphQuery( + options: Omit, "query"> = {}, +) { + return Urql.useQuery({ query: GetGraphDocument, ...options }); +} export const CreateGraphDocument = gql` mutation CreateGraph($graphInput: AddGraphInput!) { addGraph(graphInput: $graphInput) { @@ -2000,6 +2117,19 @@ export function useCreateGraphMutation() { CreateGraphDocument, ); } +export const UpdateGraphDocument = gql` + mutation UpdateGraph($graphUuid: UUID!, $graphUpdate: UpdateGraphInput!) { + updateGraph(graphInput: $graphUpdate, graphUuid: $graphUuid) { + uuid + } + } +`; + +export function useUpdateGraphMutation() { + return Urql.useMutation( + UpdateGraphDocument, + ); +} export const StreamDocument = gql` subscription stream($graphUuid: UUID!) { streamInfo(graphUuid: $graphUuid) { diff --git a/caster-editor/src/stores/InterfaceStore.ts b/caster-editor/src/stores/InterfaceStore.ts index 3720670c..f9668c95 100644 --- a/caster-editor/src/stores/InterfaceStore.ts +++ b/caster-editor/src/stores/InterfaceStore.ts @@ -1,5 +1,5 @@ import { defineStore } from "pinia"; -import { type Ref, ref, computed } from "vue"; +import { type Ref, ref, computed, watch } from "vue"; import { type NodeSubscription, type ScriptCellInputUpdate, @@ -14,6 +14,7 @@ import { ElMessage } from "element-plus"; export enum Tab { Edit = "Edit", Play = "Play", + Meta = "Meta", } // some hack to avoid @@ -31,6 +32,12 @@ export const useInterfaceStore = defineStore("interface", () => { const tab: Ref = ref(Tab.Edit); + watch(tab, (newValue, oldValue) => { + if (oldValue == Tab.Edit && newValue == Tab.Meta) { + showNodeEditor.value = false; + } + }); + // this acts as a clutch between our local changes and the // updates from the server. const cachedNodeData: Ref = ref(undefined); diff --git a/caster-editor/src/views/GraphDetailView.vue b/caster-editor/src/views/GraphDetailView.vue index 8c4ab461..7994b61f 100644 --- a/caster-editor/src/views/GraphDetailView.vue +++ b/caster-editor/src/views/GraphDetailView.vue @@ -3,13 +3,14 @@ import { storeToRefs } from "pinia"; import { watch } from "vue"; import { useRoute, useRouter } from "vue-router"; import Graph from "@/components/Graph.vue"; +import Meta from "@/components/Meta.vue"; import Menu from "@/components/Menu.vue"; import NodeEditor from "@/components/NodeEditor.vue"; -import { useInterfaceStore } from "@/stores/InterfaceStore"; +import { useInterfaceStore, Tab } from "@/stores/InterfaceStore"; import { useGraphSubscription } from "@/graphql"; import { ElMessage } from "element-plus"; -const { showNodeEditor, selectedNodeForEditorUuid } = storeToRefs( +const { showNodeEditor, selectedNodeForEditorUuid, tab } = storeToRefs( useInterfaceStore(), ); @@ -40,6 +41,11 @@ watch(graphSubscription.error, () => { + + ; /** Manages the stream assignment for this graph */ - streamAssignmentPolicy?: InputMaybe; + streamAssignmentPolicy?: InputMaybe; /** Allows to switch to a different template in the frontend with different connection flows or UI */ templateName?: InputMaybe; }; @@ -362,10 +362,14 @@ export type Graph = { /** Name of the graph */ name: Scalars["String"]; nodes: Array; + /** If the graph is not public it will not be listed in the frontend, yet it is still accessible via URL */ + publicVisible: Scalars["Boolean"]; /** Will be used as a URL */ slugName: Scalars["String"]; /** Text about the graph which will be displayed at the start of a stream - only if this is set */ startText: Scalars["String"]; + /** Manages the stream assignment for this graph */ + streamAssignmentPolicy: StreamAssignmentPolicy; /** Allows to switch to a different template in the frontend with different connection flows or UI */ templateName: GraphDetailTemplate; uuid: Scalars["UUID"]; @@ -478,6 +482,7 @@ export type Mutation = { deleteScriptCell?: Maybe; /** Update metadata of an :class:`~stream.models.AudioFile` via a UUID */ updateAudioFile: AudioFile; + updateGraph: Graph; /** * Updates a given :class:`~story_graph.models.Node` which can be used * for renaming or moving it across the canvas. @@ -556,6 +561,12 @@ export type MutationUpdateAudioFileArgs = { uuid: Scalars["UUID"]; }; +/** Mutations for Gencaster via GraphQL. */ +export type MutationUpdateGraphArgs = { + graphInput: UpdateGraphInput; + graphUuid: Scalars["UUID"]; +}; + /** Mutations for Gencaster via GraphQL. */ export type MutationUpdateNodeArgs = { nodeUpdate: NodeUpdate; @@ -858,6 +869,13 @@ export type Stream = { uuid: Scalars["UUID"]; }; +/** An enumeration. */ +export enum StreamAssignmentPolicy { + Deactivate = "DEACTIVATE", + OneGraphOneStream = "ONE_GRAPH_ONE_STREAM", + OneUserOneStream = "ONE_USER_ONE_STREAM", +} + export type StreamInfo = { stream: Stream; streamInstruction?: Maybe; @@ -1037,6 +1055,38 @@ export type UpdateAudioFile = { name?: InputMaybe; }; +/** + * A collection of :class:`~Node` and :class:`~Edge`. + * This can be considered a score as well as a program as it + * has an entry point as a :class:`~Node` and can jump to any + * other :class:`~Node`, also allowing for recursive loops/cycles. + * + * Each node can be considered a little program on its own which can consist + * of multiple :class:`~ScriptCell` which can be coded in a variety of + * languages which can control the frontend and the audio (by e.g. speaking + * on the stream) or setting a background music. + * + * The story graph is a core concept and can be edited with a native editor. + */ +export type UpdateGraphInput = { + /** Text about the graph which can be accessed during a stream - only if this is set */ + aboutText?: InputMaybe; + /** Will be used as a display name in the frontend */ + displayName?: InputMaybe; + /** Text which will be displayed at the end of a stream */ + endText?: InputMaybe; + /** Name of the graph */ + name?: InputMaybe; + /** If the graph is not public it will not be listed in the frontend, yet it is still accessible via URL */ + publicVisible?: InputMaybe; + /** Text about the graph which will be displayed at the start of a stream - only if this is set */ + startText?: InputMaybe; + /** Manages the stream assignment for this graph */ + streamAssignmentPolicy?: InputMaybe; + /** Allows to switch to a different template in the frontend with different connection flows or UI */ + templateName?: InputMaybe; +}; + /** * Users within the Django authentication system are represented by this * model. @@ -1312,6 +1362,38 @@ export type NodeSubscription = { }; }; +export type GraphMetaDataFragment = { + uuid: any; + templateName: GraphDetailTemplate; + startText: string; + slugName: string; + name: string; + endText: string; + displayName: string; + aboutText: string; + streamAssignmentPolicy: StreamAssignmentPolicy; + publicVisible: boolean; +}; + +export type GetGraphQueryVariables = Exact<{ + graphUuid: Scalars["ID"]; +}>; + +export type GetGraphQuery = { + graph: { + uuid: any; + templateName: GraphDetailTemplate; + startText: string; + slugName: string; + name: string; + endText: string; + displayName: string; + aboutText: string; + streamAssignmentPolicy: StreamAssignmentPolicy; + publicVisible: boolean; + }; +}; + export type CreateGraphMutationVariables = Exact<{ graphInput: AddGraphInput; }>; @@ -1324,6 +1406,13 @@ export type CreateGraphMutation = { }; }; +export type UpdateGraphMutationVariables = Exact<{ + graphUuid: Scalars["UUID"]; + graphUpdate: UpdateGraphInput; +}>; + +export type UpdateGraphMutation = { updateGraph: { uuid: any } }; + export type StreamSubscriptionVariables = Exact<{ graphUuid: Scalars["UUID"]; }>; @@ -1674,6 +1763,20 @@ export const NodeDoorDetailFragmentDoc = gql` doorType } `; +export const GraphMetaDataFragmentDoc = gql` + fragment GraphMetaData on Graph { + uuid + templateName + startText + slugName + name + endText + displayName + aboutText + streamAssignmentPolicy + publicVisible + } +`; export const StreamInfoFragmentFragmentDoc = gql` fragment StreamInfoFragment on Stream { uuid @@ -1981,6 +2084,20 @@ export function useNodeSubscription( handler, ); } +export const GetGraphDocument = gql` + query GetGraph($graphUuid: ID!) { + graph(pk: $graphUuid) { + ...GraphMetaData + } + } + ${GraphMetaDataFragmentDoc} +`; + +export function useGetGraphQuery( + options: Omit, "query"> = {}, +) { + return Urql.useQuery({ query: GetGraphDocument, ...options }); +} export const CreateGraphDocument = gql` mutation CreateGraph($graphInput: AddGraphInput!) { addGraph(graphInput: $graphInput) { @@ -2000,6 +2117,19 @@ export function useCreateGraphMutation() { CreateGraphDocument, ); } +export const UpdateGraphDocument = gql` + mutation UpdateGraph($graphUuid: UUID!, $graphUpdate: UpdateGraphInput!) { + updateGraph(graphInput: $graphUpdate, graphUuid: $graphUuid) { + uuid + } + } +`; + +export function useUpdateGraphMutation() { + return Urql.useMutation( + UpdateGraphDocument, + ); +} export const StreamDocument = gql` subscription stream($graphUuid: UUID!) { streamInfo(graphUuid: $graphUuid) {