diff --git a/client/package-lock.json b/client/package-lock.json index 0127a606..e4f4b2d6 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "0.26.0", + "version": "0.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "0.26.0", + "version": "0.27.0", "dependencies": { "bootstrap": "4.6.2", "bootstrap-vue": "2.23.1", diff --git a/client/package.json b/client/package.json index 7673ebff..617b0be6 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "client", - "version": "0.26.0", + "version": "0.27.0", "description": "DigiScript front end", "author": "DreamTeamProd", "private": true, diff --git a/client/src/store/modules/script.js b/client/src/store/modules/script.js index 7cfe85f9..3ac468a5 100644 --- a/client/src/store/modules/script.js +++ b/client/src/store/modules/script.js @@ -326,6 +326,17 @@ export default { Vue.$toast.error('Unable to edit stage direction style'); } }, + async GET_IMPORTABLE_STAGE_DIRECTION_STYLES() { + const response = await fetch( + `${makeURL('/api/v1/show/script/stage_direction_styles/import')}`, + { method: 'GET' } + ); + if (!response.ok) { + log.error('Unable to fetch importable stage direction styles'); + throw new Error('Failed to fetch importable styles'); + } + return response.json(); + }, async GET_COMPILED_SCRIPTS(context) { const response = await fetch(`${makeURL('/api/v1/show/script/compiled_scripts')}`, { method: 'GET', diff --git a/client/src/vue_components/show/config/script/StageDirectionStyles.vue b/client/src/vue_components/show/config/script/StageDirectionStyles.vue index 220b3e65..844bf6c9 100644 --- a/client/src/vue_components/show/config/script/StageDirectionStyles.vue +++ b/client/src/vue_components/show/config/script/StageDirectionStyles.vue @@ -11,6 +11,71 @@ New Style + + Import Style + + +
+ +
+
+ No styles available to import from other shows. +
+
+ + +
+ {{ show.name }} + + +
+
+ + + + + + + + +
+
+
{ + expanded[group.id] = true; + }); + this.styleGroupExpanded = expanded; + } catch (error) { + log.error('Error fetching importable stage direction styles:', error); + this.$toast.error('Failed to load styles for import'); + } finally { + this.isLoadingImport = false; + } + }, + resetImportState() { + this.importStyleGroups = []; + this.styleGroupExpanded = {}; + this.isLoadingImport = false; + this.isImporting = {}; + }, + toggleImportShow(showId) { + this.$set(this.styleGroupExpanded, showId, !this.styleGroupExpanded[showId]); + }, + async importStyle(style) { + this.$set(this.isImporting, style.id, true); + try { + await this.ADD_STAGE_DIRECTION_STYLE({ + description: style.description, + bold: style.bold, + italic: style.italic, + underline: style.underline, + textFormat: style.text_format, + textColour: style.text_colour, + enableBackgroundColour: style.enable_background_colour, + backgroundColour: style.background_colour, + }); + this.$toast.success(`Imported "${style.description}"`); + } catch (error) { + log.error('Error importing stage direction style:', error); + this.$toast.error(`Failed to import "${style.description}"`); + } finally { + this.$set(this.isImporting, style.id, false); + } + }, ...mapActions([ 'GET_STAGE_DIRECTION_STYLES', 'ADD_STAGE_DIRECTION_STYLE', 'DELETE_STAGE_DIRECTION_STYLE', 'UPDATE_STAGE_DIRECTION_STYLE', + 'GET_IMPORTABLE_STAGE_DIRECTION_STYLES', ]), }, }; diff --git a/docs/pages/script_config.md b/docs/pages/script_config.md index e7972c2d..6350439d 100644 --- a/docs/pages/script_config.md +++ b/docs/pages/script_config.md @@ -64,6 +64,30 @@ Click **New Revision** to create a new revision. You'll need to provide a descri When you create a new revision, its base state is copied from the currently loaded revision. Any changes you make to the script after that point will only affect the active revision - other revisions remain unchanged, preserving the complete history of your script. +### Stage Direction Styles + +The **Stage Direction Styles** tab lets you define the visual appearance of stage direction lines throughout your script. Each style controls the text formatting applied when that style is assigned to a stage direction line. + +#### Creating a New Style + +Click **New Style** to open the style editor. You can configure: + +- **Description** — a name for the style (e.g. "Narrator", "Whisper") +- **Default Styles** — toggle Bold, Italic, and Underline +- **Default Text Format** — Default, Uppercase, or Lowercase +- **Text Colour** — a colour picker for the text colour +- **Background Colour** — optionally enable and choose a background highlight colour + +A live preview of the style is shown at the top of the editor so you can see how it will appear in the script before saving. + +#### Importing Styles from Another Show + +Click **Import Style** to open the import modal. This shows all stage direction styles from every other show in your DigiScript instance, grouped by show name. Each style is displayed with its live preview so you can see exactly what it will look like. + +Click **Import** next to any style to copy it into the current show. The import creates an independent copy — changes to the imported style will not affect the original show, and vice versa. The modal stays open so you can import multiple styles in a single session. + +If no other shows have stage direction styles defined, a message is shown indicating there are no styles available to import. + ### Script Content The **Script** tab is where you edit the actual script content. When you first navigate to this tab, you'll see an empty script interface: diff --git a/electron/package-lock.json b/electron/package-lock.json index 7fe77b4d..8c6f90b7 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "digiscript-electron", - "version": "0.26.0", + "version": "0.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "digiscript-electron", - "version": "0.26.0", + "version": "0.27.0", "license": "GPL-3.0", "dependencies": { "bonjour-service": "^1.3.0", diff --git a/electron/package.json b/electron/package.json index a721fcdf..46d785e7 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "digiscript-electron", - "version": "0.26.0", + "version": "0.27.0", "description": "DigiScript Electron Desktop Application", "author": "DreamTeamProd", "license": "GPL-3.0", diff --git a/server/alembic_config/versions/d2e0b8414d17_fix_duplicate_sessions_user_id_fk.py b/server/alembic_config/versions/d2e0b8414d17_fix_duplicate_sessions_user_id_fk.py new file mode 100644 index 00000000..abfcb680 --- /dev/null +++ b/server/alembic_config/versions/d2e0b8414d17_fix_duplicate_sessions_user_id_fk.py @@ -0,0 +1,54 @@ +"""fix_duplicate_sessions_user_id_fk + +Migration 29471f7cf7d2 added the named FK fk_sessions_user_id_user to the +sessions table but did not drop the pre-existing unnamed FK on the same column. +Databases upgraded through that migration therefore ended up with two FK +constraints on sessions.user_id, which causes SQLAlchemy to emit a SAWarning +during every subsequent migration run. + +This migration drops the unnamed duplicate, leaving only the named constraint +with ON DELETE SET NULL. The try/except mirrors the pattern in a4d42ccfb71a: +databases that were created from scratch (never had the unnamed FK) would raise +IndexError from drop_constraint(None), which we safely ignore. + +Revision ID: d2e0b8414d17 +Revises: d3e9f0c1a2b4 +Create Date: 2026-05-06 19:13:55.162159 + +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "d2e0b8414d17" +down_revision: Union[str, None] = "d3e9f0c1a2b4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # The unnamed duplicate FK is invisible to SQLAlchemy reflection, so + # drop_constraint(None) finds nothing. Instead, drop and recreate the + # named constraint: this forces SQLite batch mode to rebuild the table + # from the reflected schema (which only sees the named FK), so the + # unnamed duplicate is silently excluded from the new table. + with op.batch_alter_table("sessions", schema=None) as batch_op: + batch_op.drop_constraint( + batch_op.f("fk_sessions_user_id_user"), type_="foreignkey" + ) + batch_op.create_foreign_key( + batch_op.f("fk_sessions_user_id_user"), + "user", + ["user_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + # Restore the unnamed FK (pre-29471f7cf7d2 state) alongside the named one. + with op.batch_alter_table("sessions", schema=None) as batch_op: + batch_op.create_foreign_key(None, "user", ["user_id"], ["id"]) diff --git a/server/controllers/api/show/script/stage_direction_styles.py b/server/controllers/api/show/script/stage_direction_styles.py index 0d9f87f0..5bd35009 100644 --- a/server/controllers/api/show/script/stage_direction_styles.py +++ b/server/controllers/api/show/script/stage_direction_styles.py @@ -16,7 +16,7 @@ from schemas.schemas import StageDirectionStyleSchema from utils.web.base_controller import BaseAPIController from utils.web.route import ApiRoute, ApiVersion -from utils.web.web_decorators import no_live_session, requires_show +from utils.web.web_decorators import api_authenticated, no_live_session, requires_show VALID_TEXT_FORMATS = ("default", "upper", "lower") @@ -229,3 +229,40 @@ async def delete(self): else: self.set_status(404) await self.finish({"message": ERROR_SHOW_NOT_FOUND}) + + +@ApiRoute("/show/script/stage_direction_styles/import", ApiVersion.V1) +class StageDirectionStylesImportController(BaseAPIController): + @api_authenticated + @requires_show + def get(self): + """ + Return all stage direction styles from all other shows, grouped by show. + + :returns: JSON with a ``shows`` key containing a list of show objects, + each with ``id``, ``name``, and ``styles`` fields. + """ + current_show_id = self.get_current_show()["id"] + schema = StageDirectionStyleSchema() + + with self.make_session() as session: + rows = session.execute( + select(Show, StageDirectionStyle) + .join(Script, Script.show_id == Show.id) + .join(StageDirectionStyle, StageDirectionStyle.script_id == Script.id) + .where(Show.id != current_show_id) + .order_by(Show.id) + ).all() + + shows_map: dict = {} + for show, style in rows: + if show.id not in shows_map: + shows_map[show.id] = { + "id": show.id, + "name": show.name, + "styles": [], + } + shows_map[show.id]["styles"].append(schema.dump(style)) + + self.set_status(200) + self.finish({"style_groups": list(shows_map.values())}) diff --git a/server/pyproject.toml b/server/pyproject.toml index f40b5941..69477a0a 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta" [project] name = "digiscript-server" -version = "0.26.0" +version = "0.27.0" description = "DigiScript server - Digital script management for theatrical shows" readme = "../README.md" requires-python = ">=3.13" diff --git a/server/test/controllers/api/show/script/test_stage_direction_styles.py b/server/test/controllers/api/show/script/test_stage_direction_styles.py index 0c8c70f3..261ff408 100644 --- a/server/test/controllers/api/show/script/test_stage_direction_styles.py +++ b/server/test/controllers/api/show/script/test_stage_direction_styles.py @@ -92,3 +92,161 @@ def test_get_stage_direction_styles_empty(self): self.assertEqual(200, response.code) response_body = tornado.escape.json_decode(response.body) self.assertEqual([], response_body["styles"]) + + +class TestStageDirectionStylesImport(DigiScriptTestCase): + """Test suite for GET /api/v1/show/script/stage_direction_styles/import.""" + + IMPORT_URL = "/api/v1/show/script/stage_direction_styles/import" + + def setUp(self): + super().setUp() + with self._app.get_db().sessionmaker() as session: + current_show = Show(name="Current Show", script_mode=ShowScriptType.FULL) + session.add(current_show) + session.flush() + self.current_show_id = current_show.id + + current_script = Script(show_id=current_show.id) + session.add(current_script) + session.flush() + current_revision = ScriptRevision( + script_id=current_script.id, revision=1, description="Initial" + ) + session.add(current_revision) + session.flush() + current_script.current_revision = current_revision.id + + current_style = StageDirectionStyle( + script_id=current_script.id, + description="Current Show Style", + bold=False, + italic=False, + underline=False, + text_format="default", + text_colour="#111111", + enable_background_colour=False, + background_colour=None, + ) + session.add(current_style) + + other_show = Show(name="Other Show", script_mode=ShowScriptType.FULL) + session.add(other_show) + session.flush() + self.other_show_id = other_show.id + + other_script = Script(show_id=other_show.id) + session.add(other_script) + session.flush() + other_revision = ScriptRevision( + script_id=other_script.id, revision=1, description="Initial" + ) + session.add(other_revision) + session.flush() + other_script.current_revision = other_revision.id + + other_style = StageDirectionStyle( + script_id=other_script.id, + description="Other Show Style", + bold=True, + italic=False, + underline=False, + text_format="upper", + text_colour="#222222", + enable_background_colour=False, + background_colour=None, + ) + session.add(other_style) + + empty_show = Show(name="Empty Show", script_mode=ShowScriptType.FULL) + session.add(empty_show) + session.flush() + self.empty_show_id = empty_show.id + + empty_script = Script(show_id=empty_show.id) + session.add(empty_script) + session.flush() + empty_revision = ScriptRevision( + script_id=empty_script.id, revision=1, description="Initial" + ) + session.add(empty_revision) + session.flush() + empty_script.current_revision = empty_revision.id + + session.commit() + + self._app.digi_settings.settings["current_show"].set_value(self.current_show_id) + + def _login_admin(self): + self.fetch( + "/api/v1/auth/create", + method="POST", + body=tornado.escape.json_encode( + {"username": "admin", "password": "adminpass", "is_admin": True} + ), + ) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=tornado.escape.json_encode( + {"username": "admin", "password": "adminpass"} + ), + ) + return tornado.escape.json_decode(resp.body)["access_token"] + + def test_get_import_requires_auth(self): + """GET without auth token returns 401.""" + response = self.fetch(self.IMPORT_URL) + self.assertEqual(401, response.code) + + def test_get_import_requires_show(self): + """GET with auth but no current show returns 400.""" + self._app.digi_settings.settings["current_show"].set_value(None) + token = self._login_admin() + response = self.fetch( + self.IMPORT_URL, + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(400, response.code) + + def test_get_import_excludes_current_show(self): + """Current show's styles must not appear in the import response.""" + token = self._login_admin() + response = self.fetch( + self.IMPORT_URL, + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(200, response.code) + body = tornado.escape.json_decode(response.body) + show_ids = [s["id"] for s in body["style_groups"]] + self.assertNotIn(self.current_show_id, show_ids) + + def test_get_import_includes_other_shows(self): + """Other shows with styles are returned with correct name and style data.""" + token = self._login_admin() + response = self.fetch( + self.IMPORT_URL, + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(200, response.code) + body = tornado.escape.json_decode(response.body) + other = next( + (s for s in body["style_groups"] if s["id"] == self.other_show_id), None + ) + self.assertIsNotNone(other) + self.assertEqual("Other Show", other["name"]) + self.assertEqual(1, len(other["styles"])) + self.assertEqual("Other Show Style", other["styles"][0]["description"]) + self.assertEqual("upper", other["styles"][0]["text_format"]) + + def test_get_import_skips_shows_with_no_styles(self): + """Shows that have a script but no styles are omitted from the response.""" + token = self._login_admin() + response = self.fetch( + self.IMPORT_URL, + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(200, response.code) + body = tornado.escape.json_decode(response.body) + show_ids = [s["id"] for s in body["style_groups"]] + self.assertNotIn(self.empty_show_id, show_ids)