+ These cues are skipped by default — either their identifier was not found in the uploaded
+ CSV, or it has no numeric prefix. Check the box to assign them a new identifier.
+
+ These cues are skipped by default — either their identifier was not found in the uploaded
+ CSV, or it has no numeric prefix. Check the box to assign them a new identifier.
+
+
+
+
+
+
+ {{ cueTypePrefix(item.cue.cue_type_id) }}
+ {{ item.originalIdent || '(empty)' }}
+
+
+
+
+
+
+
+
+ One or more identifiers are invalid or duplicate within the same cue type.
+
+
+
+
+
+
+ Cancel
+
+
+ Back
+
+
+ Next
+
+
+
+ Confirm Renumber
+
+
+
+
+
+
+
+
diff --git a/docs/pages/cue_config.md b/docs/pages/cue_config.md
index 17b38be9..38cbf1b8 100644
--- a/docs/pages/cue_config.md
+++ b/docs/pages/cue_config.md
@@ -80,6 +80,76 @@ Clicking a group button opens the **Edit Cue Group** dialog, where you can:
A script line can freely mix individual cues and groups simultaneously. The label preview in the dialog updates live as you make changes.
+### Renumbering Cues
+
+The **Renumber Cues** feature resynchronises DigiScript's cue identifiers after you perform a renum/reorder on your MagicQ lighting console. When MagicQ collapses point cues (e.g. 3.1, 3.2) into sequential integers, DigiScript's cue identifiers become stale — this feature updates them to match.
+
+#### When to use it
+
+Use Renumber Cues after performing a renum/reorder on your MagicQ console. Export the **before-renum** cue stack from MagicQ first (see below), then use it to guide the renumber in DigiScript.
+
+#### How it works
+
+MagicQ sorts all cues in the stack numerically and reassigns sequential integers starting at 1. DigiScript replicates this by reading the full cue list from a MagicQ CSV export, so it can correctly place the cues it knows about — even when some console cues are intentionally omitted from the script.
+
+For example, if the console has cues 1, 2, 2.1, 3, 4 but DigiScript only has 1 and 3:
+
+| Console cues | After MagicQ renum | DigiScript before | DigiScript after |
+|---|---|---|---|
+| 1, 2, 2.1, 3, 4 | 1, 2, 3, 4, 5 | 1, 3 | 1, 4 |
+
+DigiScript's "3" becomes "4" because cue 2.1 occupies position 3 in the full sequence — even though 2.1 is not in DigiScript.
+
+#### Exporting the CSV from MagicQ
+
+Before running a renum in MagicQ, export the cue stack window as a CSV:
+
+1. In MagicQ, open the **Cue Stack** window for your show
+2. Press the **Cue Stack** title bar and choose **Export to CSV**
+3. Save the file to your computer
+
+The exported file contains a `Cue id` column with the pre-renum cue numbers — this is the file to upload.
+
+#### Accessing the feature in DigiScript
+
+1. Navigate to **Cues → Cue Configuration**
+2. Click the **Renumber Cues** button in the toolbar
+
+#### Step 1 — Configure
+
+- **MagicQ Cue Stack CSV (before renumber)**: Upload the CSV exported from MagicQ before the renum was run. Once a valid file is loaded, a confirmation line shows how many cues were found.
+- **Cue Types to Renumber**: Check one or more cue types. All checked types are processed together against the same CSV mapping.
+
+The **Next** button is enabled once both a valid CSV has been loaded and at least one cue type is selected.
+
+Click **Next** to preview the changes.
+
+#### Step 2 — Preview
+
+The preview shows two sections:
+
+**Changed Cues** — cues whose identifier will change. The table shows the current identifier and the proposed new identifier. You can edit the proposed new identifier if needed.
+
+**Unmatched Cues** — cues that are skipped by default for one of two reasons:
+
+- The cue's numeric identifier (or prefix) was not found in the uploaded CSV — for example, a cue that no longer exists on the console
+- The cue has no numeric identifier at all (e.g. free-form text like "INTRO")
+
+For text-suffix cues (e.g. "2.1 - Blackout") where the numeric prefix *is* found in the CSV, DigiScript pre-fills the suggested new identifier (e.g. "3 - Blackout") so you can include it with one click.
+
+Tick the **Include** checkbox next to any unmatched cue you want to reassign, and adjust the new identifier if needed.
+
+DigiScript validates that all final identifiers within each cue type are unique and non-empty. The **Confirm Renumber** button remains disabled until validation passes.
+
+Click **Confirm Renumber** to apply the changes, or **Back** to return to the configuration step.
+
+#### What is not changed
+
+- `label_override` on cue groups is preserved
+- Group membership and sort order within groups are unchanged
+- Script positions (which line a cue is on) are unchanged
+- Cues in other script revisions are not affected
+
### Cues and Script Revisions
Cues are tied to script revisions - when you add or modify cues, the changes only affect the currently loaded revision. This allows you to maintain different cue configurations for different versions of your script.
diff --git a/server/controllers/api/constants.py b/server/controllers/api/constants.py
index 720a54d7..714fa37f 100644
--- a/server/controllers/api/constants.py
+++ b/server/controllers/api/constants.py
@@ -82,6 +82,14 @@
ERROR_IDENTIFIER_MISSING = "Identifier missing"
ERROR_LINE_ID_MISSING = "Line ID missing"
+# Cue renumber
+ERROR_RENUMBER_OPERATIONS_MISSING = "Operations list is required"
+ERROR_RENUMBER_OPERATIONS_EMPTY = "Operations list cannot be empty"
+ERROR_RENUMBER_INVALID_IDENT = (
+ "All idents must be non-empty strings of 50 characters or less"
+)
+ERROR_RENUMBER_CUE_NOT_IN_REVISION = "Cue is not associated with the current revision"
+
# Stage direction styles
ERROR_TEXT_FORMAT_INVALID = "Text format missing or invalid"
ERROR_TEXT_COLOUR_MISSING = "Text colour missing"
diff --git a/server/controllers/api/v1/show/cues.py b/server/controllers/api/v1/show/cues.py
index ea5154fa..94702c44 100644
--- a/server/controllers/api/v1/show/cues.py
+++ b/server/controllers/api/v1/show/cues.py
@@ -19,6 +19,10 @@
ERROR_INVALID_ID,
ERROR_LINE_ID_MISSING,
ERROR_PREFIX_MISSING,
+ ERROR_RENUMBER_CUE_NOT_IN_REVISION,
+ ERROR_RENUMBER_INVALID_IDENT,
+ ERROR_RENUMBER_OPERATIONS_EMPTY,
+ ERROR_RENUMBER_OPERATIONS_MISSING,
ERROR_SHOW_NOT_FOUND,
)
from models.cue import Cue, CueAssociation, CueGroup, CueType
@@ -1084,3 +1088,102 @@ async def delete(self):
self.set_status(200)
await self.finish({"message": "Successfully deleted cue group"})
await self.application.ws_send_to_all("NOOP", "LOAD_CUES", {})
+
+
+@ApiRoute("show/cues/renumber", ApiVersion.V1)
+class CueRenumberController(BaseAPIController):
+ @requires_show
+ @no_live_session
+ async def post(self):
+ """Bulk-renumber cues in the current revision using sequential integer assignment.
+
+ :raises HTTPError: 400 if operations are missing/invalid, 401 if user lacks WRITE
+ permission on a cue type, 409 if a live session is active.
+ """
+ current_show = self.get_current_show()
+ with self.make_session() as session:
+ show = session.get(Show, current_show["id"])
+ script: Script = session.scalars(
+ select(Script).where(Script.show_id == show.id)
+ ).first()
+ if not script or not script.current_revision:
+ self.set_status(400)
+ await self.finish(
+ {"message": "Script does not have a current revision"}
+ )
+ return
+ revision: ScriptRevision = session.get(
+ ScriptRevision, script.current_revision
+ )
+
+ data = escape.json_decode(self.request.body)
+ operations = data.get("operations")
+
+ if operations is None:
+ self.set_status(400)
+ await self.finish({"message": ERROR_RENUMBER_OPERATIONS_MISSING})
+ return
+ if not operations:
+ self.set_status(400)
+ await self.finish({"message": ERROR_RENUMBER_OPERATIONS_EMPTY})
+ return
+
+ # Validate all idents upfront before touching any DB state
+ for op in operations:
+ new_ident = op.get("new_ident", "")
+ if (
+ not isinstance(new_ident, str)
+ or not new_ident.strip()
+ or len(new_ident.strip()) > 50
+ ):
+ self.set_status(400)
+ await self.finish({"message": ERROR_RENUMBER_INVALID_IDENT})
+ return
+
+ # Check RBAC write permission for each distinct cue type
+ seen_type_ids: set[int] = set()
+ for op in operations:
+ cue = session.get(Cue, op.get("cue_id"))
+ if cue and cue.cue_type_id not in seen_type_ids:
+ seen_type_ids.add(cue.cue_type_id)
+ cue_type = session.get(CueType, cue.cue_type_id)
+ if cue_type:
+ self.requires_role(cue_type, Role.WRITE)
+
+ # Apply operations — frontend guarantees one entry per unique cue_id
+ for op in operations:
+ cue_id = op.get("cue_id")
+ new_ident = op["new_ident"].strip()
+
+ cue = session.get(Cue, cue_id)
+ if not cue:
+ self.set_status(400)
+ await self.finish({"message": ERROR_CUE_NOT_FOUND})
+ return
+
+ current_rev_assocs = [
+ a for a in cue.revision_associations if a.revision_id == revision.id
+ ]
+ if not current_rev_assocs:
+ self.set_status(400)
+ await self.finish({"message": ERROR_RENUMBER_CUE_NOT_IN_REVISION})
+ return
+
+ other_rev_assocs = [
+ a for a in cue.revision_associations if a.revision_id != revision.id
+ ]
+ if not other_rev_assocs:
+ # Cue is only used in this revision — update in place
+ cue.ident = new_ident
+ else:
+ # Cue is shared with other revisions — fork it
+ new_cue = Cue(ident=new_ident, cue_type_id=cue.cue_type_id)
+ session.add(new_cue)
+ session.flush()
+ for assoc in current_rev_assocs:
+ assoc.cue = new_cue
+
+ session.commit()
+ self.set_status(200)
+ await self.finish({"message": "Successfully renumbered cues"})
+ await self.application.ws_send_to_all("NOOP", "LOAD_CUES", {})
diff --git a/server/test/controllers/api/v1/show/test_cues.py b/server/test/controllers/api/v1/show/test_cues.py
index 2c39afe0..cc8a440b 100644
--- a/server/test/controllers/api/v1/show/test_cues.py
+++ b/server/test/controllers/api/v1/show/test_cues.py
@@ -1821,3 +1821,285 @@ def test_line_position_stable_after_patch(self):
# Group must still come before the individual cue
group_after = next(c for c in cues_after if c["group_id"] is not None)
self.assertLess(group_after["line_position"], individual_after["line_position"])
+
+
+class TestCueRenumber(DigiScriptTestCase):
+ """Test suite for POST /api/v1/show/cues/renumber endpoint."""
+
+ def setUp(self):
+ super().setUp()
+ with self._app.get_db().sessionmaker() as session:
+ show = Show(name="Test Show", script_mode=ShowScriptType.FULL)
+ session.add(show)
+ session.flush()
+ self.show_id = show.id
+
+ script = Script(show_id=show.id)
+ session.add(script)
+ session.flush()
+
+ revision = ScriptRevision(
+ script_id=script.id, revision=1, description="Initial"
+ )
+ session.add(revision)
+ session.flush()
+ self.revision_id = revision.id
+ script.current_revision = revision.id
+
+ act = Act(show_id=show.id, name="Act 1")
+ session.add(act)
+ session.flush()
+
+ scene = Scene(show_id=show.id, act_id=act.id, name="Scene 1")
+ session.add(scene)
+ session.flush()
+
+ line = ScriptLine(
+ act_id=act.id,
+ scene_id=scene.id,
+ page=1,
+ line_type=ScriptLineType.DIALOGUE,
+ )
+ session.add(line)
+ session.flush()
+ self.line_id = line.id
+
+ assoc = ScriptLineRevisionAssociation(
+ revision_id=revision.id, line_id=line.id
+ )
+ session.add(assoc)
+
+ cue_type = CueType(
+ show_id=show.id,
+ prefix="LX",
+ description="Lighting",
+ colour="#ff0000",
+ )
+ session.add(cue_type)
+ session.flush()
+ self.cue_type_id = cue_type.id
+
+ session.commit()
+
+ self._app.digi_settings.settings["current_show"].set_value(self.show_id)
+ self.admin_token = self._create_and_login_admin()
+
+ def _add_cue(self, ident: str) -> int:
+ """Create a cue via API and return its ID."""
+ resp = self.fetch(
+ "/api/v1/show/cues",
+ method="POST",
+ body=tornado.escape.json_encode(
+ {"cueType": self.cue_type_id, "ident": ident, "lineId": self.line_id}
+ ),
+ headers={"Authorization": f"Bearer {self.admin_token}"},
+ )
+ self.assertEqual(200, resp.code, f"Failed to create cue '{ident}'")
+ # Fetch all cues and find the one just created
+ cues_resp = self.fetch(
+ "/api/v1/show/cues",
+ headers={"Authorization": f"Bearer {self.admin_token}"},
+ )
+ cues_data = tornado.escape.json_decode(cues_resp.body)
+ line_cues = cues_data["cues"].get(str(self.line_id), [])
+ return next(c["id"] for c in line_cues if c["ident"] == ident)
+
+ def _renumber(self, operations: list, token: str | None = None) -> object:
+ """Call the renumber endpoint and return the response."""
+ return self.fetch(
+ "/api/v1/show/cues/renumber",
+ method="POST",
+ body=tornado.escape.json_encode({"operations": operations}),
+ headers={"Authorization": f"Bearer {token or self.admin_token}"},
+ )
+
+ def test_renumber_success_simple(self):
+ """Cues sorted by float value get sequential integer idents."""
+ id_21 = self._add_cue("2.1")
+ id_1 = self._add_cue("1")
+ id_3 = self._add_cue("3")
+
+ resp = self._renumber(
+ [
+ {"cue_id": id_21, "new_ident": "2"},
+ {"cue_id": id_1, "new_ident": "1"},
+ {"cue_id": id_3, "new_ident": "3"},
+ ]
+ )
+ self.assertEqual(200, resp.code)
+
+ with self._app.get_db().sessionmaker() as session:
+ self.assertEqual("1", session.get(Cue, id_1).ident)
+ self.assertEqual("2", session.get(Cue, id_21).ident)
+ self.assertEqual("3", session.get(Cue, id_3).ident)
+
+ def test_renumber_in_place_when_no_other_revision(self):
+ """Cue only in the current revision is updated in place (no fork)."""
+ cue_id = self._add_cue("2.1")
+
+ resp = self._renumber([{"cue_id": cue_id, "new_ident": "1"}])
+ self.assertEqual(200, resp.code)
+
+ with self._app.get_db().sessionmaker() as session:
+ cue = session.get(Cue, cue_id)
+ self.assertEqual("1", cue.ident)
+ # Same object ID — no fork created
+ self.assertEqual(cue_id, cue.id)
+
+ def test_renumber_applies_fork_correctly(self):
+ """Cue shared with another revision is forked; other revision is untouched."""
+ cue_id = self._add_cue("3")
+
+ with self._app.get_db().sessionmaker() as session:
+ script = session.scalars(
+ select(Script).where(Script.show_id == self.show_id)
+ ).first()
+ # Create a second revision that also references the same cue
+ rev2 = ScriptRevision(script_id=script.id, revision=2, description="Second")
+ session.add(rev2)
+ session.flush()
+ session.add(
+ CueAssociation(revision_id=rev2.id, line_id=self.line_id, cue_id=cue_id)
+ )
+ session.commit()
+ rev2_id = rev2.id
+
+ resp = self._renumber([{"cue_id": cue_id, "new_ident": "1"}])
+ self.assertEqual(200, resp.code)
+
+ with self._app.get_db().sessionmaker() as session:
+ original_cue = session.get(Cue, cue_id)
+ # Original cue should be unchanged (belongs to rev2)
+ self.assertEqual("3", original_cue.ident)
+
+ # Current revision should now reference a new cue with the new ident
+ assoc = session.get(
+ CueAssociation,
+ {
+ "revision_id": self.revision_id,
+ "line_id": self.line_id,
+ "cue_id": cue_id,
+ },
+ )
+ self.assertIsNone(assoc, "Old association should have been replaced")
+
+ rev_assocs = session.scalars(
+ select(CueAssociation).where(
+ CueAssociation.revision_id == self.revision_id
+ )
+ ).all()
+ self.assertEqual(1, len(rev_assocs))
+ new_cue = session.get(Cue, rev_assocs[0].cue_id)
+ self.assertIsNotNone(new_cue)
+ self.assertEqual("1", new_cue.ident)
+ self.assertNotEqual(cue_id, new_cue.id)
+
+ # Second revision still references the original cue unchanged
+ rev2_assocs = session.scalars(
+ select(CueAssociation).where(CueAssociation.revision_id == rev2_id)
+ ).all()
+ self.assertEqual(1, len(rev2_assocs))
+ self.assertEqual(cue_id, rev2_assocs[0].cue_id)
+
+ def test_renumber_empty_operations_returns_400(self):
+ """Empty operations list returns 400."""
+ resp = self._renumber([])
+ self.assertEqual(400, resp.code)
+ body = tornado.escape.json_decode(resp.body)
+ self.assertIn("Operations list cannot be empty", body["message"])
+
+ def test_renumber_missing_operations_key_returns_400(self):
+ """Request body without 'operations' key returns 400."""
+ resp = self.fetch(
+ "/api/v1/show/cues/renumber",
+ method="POST",
+ body=tornado.escape.json_encode({}),
+ headers={"Authorization": f"Bearer {self.admin_token}"},
+ )
+ self.assertEqual(400, resp.code)
+ body = tornado.escape.json_decode(resp.body)
+ self.assertIn("Operations list is required", body["message"])
+
+ def test_renumber_ident_too_long_returns_400(self):
+ """Ident longer than 50 characters returns 400."""
+ cue_id = self._add_cue("1")
+ resp = self._renumber([{"cue_id": cue_id, "new_ident": "x" * 51}])
+ self.assertEqual(400, resp.code)
+ body = tornado.escape.json_decode(resp.body)
+ self.assertIn("idents must be non-empty", body["message"])
+
+ def test_renumber_empty_ident_returns_400(self):
+ """Empty string ident returns 400."""
+ cue_id = self._add_cue("1")
+ resp = self._renumber([{"cue_id": cue_id, "new_ident": ""}])
+ self.assertEqual(400, resp.code)
+
+ def test_renumber_cue_not_in_revision_returns_400(self):
+ """Cue that belongs to a different revision returns 400."""
+ with self._app.get_db().sessionmaker() as session:
+ script = session.scalars(
+ select(Script).where(Script.show_id == self.show_id)
+ ).first()
+ rev2 = ScriptRevision(script_id=script.id, revision=2, description="Other")
+ session.add(rev2)
+ session.flush()
+ orphan_cue = Cue(cue_type_id=self.cue_type_id, ident="99")
+ session.add(orphan_cue)
+ session.flush()
+ # Associate with rev2 ONLY — not the current revision
+ session.add(
+ CueAssociation(
+ revision_id=rev2.id, line_id=self.line_id, cue_id=orphan_cue.id
+ )
+ )
+ session.commit()
+ orphan_id = orphan_cue.id
+
+ resp = self._renumber([{"cue_id": orphan_id, "new_ident": "1"}])
+ self.assertEqual(400, resp.code)
+ body = tornado.escape.json_decode(resp.body)
+ self.assertIn("not associated with the current revision", body["message"])
+
+ def test_renumber_requires_show(self):
+ """Returns 400 when no show is loaded in settings."""
+ self._app.digi_settings.settings["current_show"].set_value(None)
+ try:
+ resp = self._renumber([{"cue_id": 1, "new_ident": "1"}])
+ self.assertEqual(400, resp.code)
+ self.assertIn(b"No show loaded", resp.body)
+ finally:
+ self._app.digi_settings.settings["current_show"].set_value(self.show_id)
+
+ def test_renumber_no_live_session(self):
+ """Returns 409 when a live show session is active."""
+ with self._app.get_db().sessionmaker() as session:
+ show_session = ShowSession(
+ show_id=self.show_id,
+ script_revision_id=self.revision_id,
+ )
+ session.add(show_session)
+ session.flush()
+ session_id = show_session.id
+ show = session.get(Show, self.show_id)
+ show.current_session_id = session_id
+ session.commit()
+
+ try:
+ resp = self._renumber([{"cue_id": 1, "new_ident": "1"}])
+ self.assertEqual(409, resp.code)
+ finally:
+ with self._app.get_db().sessionmaker() as session:
+ show = session.get(Show, self.show_id)
+ show.current_session_id = None
+ ss = session.get(ShowSession, session_id)
+ if ss:
+ session.delete(ss)
+ session.commit()
+
+ def test_renumber_forbidden_without_role(self):
+ """Non-admin user without WRITE role on the cue type gets 403."""
+ cue_id = self._add_cue("2.1")
+ user_token = self._create_and_login_user(self.admin_token)
+
+ resp = self._renumber([{"cue_id": cue_id, "new_ident": "1"}], token=user_token)
+ self.assertEqual(403, resp.code)