From 5db7d32f7f665b2ec17e479472275678bce8964f Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Thu, 2 Jul 2026 23:41:19 +0100 Subject: [PATCH 1/6] Add Cue Renumber feature for MagicQ console sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows users to renumber cue identifiers using the MagicQ sequential algorithm (sorting float idents, assigning integers 1, 2, 3…), keeping DigiScript labels in sync after a MagicQ console renum operation. - Backend: POST /api/v1/show/cues/renumber with fork-safe bulk update (forks cues shared across revisions, updates all current-rev lines) - Algorithm composable/utility (Vue 3 + Vue 2): regex matching, sort, sequential assignment, deduplication by cue ID - Two-step modal UI (both clients): type selection → preview with editable idents and opt-in unmatched section; full uniqueness validation - 11 backend tests, 29 Vitest unit tests per client, 7 E2E tests - Documentation update in docs/pages/cue_config.md Co-Authored-By: Claude Sonnet 4.6 --- .../e2e/tests/08-show-config-cues.spec.ts | 45 +++ .../components/show/config/cues/CueEditor.vue | 4 + .../show/config/cues/CueRenumberModal.vue | 262 ++++++++++++++++ .../src/composables/useCueRenumber.test.ts | 196 ++++++++++++ client-v3/src/composables/useCueRenumber.ts | 77 +++++ client-v3/src/stores/script.ts | 16 + client/src/js/cueRenumberUtils.test.ts | 187 ++++++++++++ client/src/js/cueRenumberUtils.ts | 76 +++++ client/src/store/modules/script.ts | 17 ++ .../show/config/cues/CueEditor.vue | 7 +- .../show/config/cues/CueRenumberModal.vue | 260 ++++++++++++++++ docs/pages/cue_config.md | 49 +++ server/controllers/api/constants.py | 8 + server/controllers/api/v1/show/cues.py | 103 +++++++ .../test/controllers/api/v1/show/test_cues.py | 282 ++++++++++++++++++ 15 files changed, 1588 insertions(+), 1 deletion(-) create mode 100644 client-v3/src/components/show/config/cues/CueRenumberModal.vue create mode 100644 client-v3/src/composables/useCueRenumber.test.ts create mode 100644 client-v3/src/composables/useCueRenumber.ts create mode 100644 client/src/js/cueRenumberUtils.test.ts create mode 100644 client/src/js/cueRenumberUtils.ts create mode 100644 client/src/vue_components/show/config/cues/CueRenumberModal.vue diff --git a/client-v3/e2e/tests/08-show-config-cues.spec.ts b/client-v3/e2e/tests/08-show-config-cues.spec.ts index 37d96171e..664df0b81 100644 --- a/client-v3/e2e/tests/08-show-config-cues.spec.ts +++ b/client-v3/e2e/tests/08-show-config-cues.spec.ts @@ -119,6 +119,51 @@ test('Go to Page submits and navigates to the requested page', async () => { await expect(page.locator('p.mb-0:has-text("Current Page: 1")')).toBeVisible(); }); +// ── Cue Renumber ────────────────────────────────────────────────────────── + +test('Renumber Cues button is visible in Cue Configuration tab toolbar', async () => { + await page.click( + '.nav-link:has-text("Cue Configuration"), button[role="tab"]:has-text("Configuration")' + ); + await expect(page.locator('button:has-text("Renumber Cues")')).toBeVisible(); +}); + +test('opens Renumber Cues modal at step 1', async () => { + await page.click('button:has-text("Renumber Cues")'); + await waitForModal(page, 'Renumber Cues'); + // Step 1 shows the method selector + await expect(page.locator('.modal.show select')).toBeVisible(); +}); + +test('Next button is disabled until a cue type is selected', async () => { + await expect(page.locator('.modal.show button:has-text("Next")')).toBeDisabled(); +}); + +test('Next button enables after selecting a cue type', async () => { + // Select the first cue type checkbox + await page.click('.modal.show input[type="checkbox"]:first-of-type'); + await expect(page.locator('.modal.show button:has-text("Next")')).toBeEnabled(); +}); + +test('step 2 shows empty state when no cues are placed', async () => { + await page.click('.modal.show button:has-text("Next")'); + // No cues have been placed in the script at this point in the serial suite + await expect( + page.locator('.modal.show p.text-muted:has-text("No cues require renumbering")') + ).toBeVisible(); +}); + +test('Back button returns to step 1', async () => { + await page.click('.modal.show button:has-text("Back")'); + // Step 1 is shown again + await expect(page.locator('.modal.show button:has-text("Next")')).toBeVisible(); +}); + +test('Cancel closes the Renumber Cues modal', async () => { + await page.click('.modal.show button:has-text("Cancel")'); + await waitForModalClosed(page); +}); + // ── Cue Counts ──────────────────────────────────────────────────────────── test('switches to Cue Counts sub-tab', async () => { diff --git a/client-v3/src/components/show/config/cues/CueEditor.vue b/client-v3/src/components/show/config/cues/CueEditor.vue index 03e5fafd3..50e9473a2 100644 --- a/client-v3/src/components/show/config/cues/CueEditor.vue +++ b/client-v3/src/components/show/config/cues/CueEditor.vue @@ -5,6 +5,7 @@ Go to Page Go to Cue + Renumber Cues @@ -67,6 +68,7 @@ + @@ -78,6 +80,7 @@ import { useShowStore } from '@/stores/show'; import { useUserStore } from '@/stores/user'; import ScriptLineCueEditor from '@/components/show/config/cues/ScriptLineCueEditor.vue'; import JumpToCueModal from '@/components/show/config/cues/JumpToCueModal.vue'; +import CueRenumberModal from '@/components/show/config/cues/CueRenumberModal.vue'; const scriptStore = useScriptStore(); const showStore = useShowStore(); @@ -89,6 +92,7 @@ const changingPage = ref(false); const goToPageModal = ref | null>(null); const jumpToCueModal = ref | null>(null); +const renumberModal = ref | null>(null); const pageInputNo = ref(1); diff --git a/client-v3/src/components/show/config/cues/CueRenumberModal.vue b/client-v3/src/components/show/config/cues/CueRenumberModal.vue new file mode 100644 index 000000000..b980367f8 --- /dev/null +++ b/client-v3/src/components/show/config/cues/CueRenumberModal.vue @@ -0,0 +1,262 @@ + + + diff --git a/client-v3/src/composables/useCueRenumber.test.ts b/client-v3/src/composables/useCueRenumber.test.ts new file mode 100644 index 000000000..6c0e21176 --- /dev/null +++ b/client-v3/src/composables/useCueRenumber.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import { NUMERIC_IDENT_REGEX, computeRenumber, useCueRenumber } from './useCueRenumber'; +import type { CueWithLineId } from './useCueRenumber'; +import type { Cue } from '@/types/api/cues'; + +function makeCue(id: number, ident: string | null, cueTypeId = 1): CueWithLineId { + return { + id, + ident, + cue_type_id: cueTypeId, + group_id: null, + sort_order: null, + line_position: null, + line_id: 100, + }; +} + +describe('NUMERIC_IDENT_REGEX', () => { + it.each(['1', '42', '2.1', '100.01', '0', '9.99'])('matches numeric ident "%s"', (ident) => { + expect(NUMERIC_IDENT_REGEX.test(ident)).toBe(true); + }); + + it.each(['', 'LX-1', '2.1x', '1.', ' 1', 'Q1 GO', '1a', 'INTRO', '1.111', '.5'])( + 'rejects non-numeric ident "%s"', + (ident) => { + expect(NUMERIC_IDENT_REGEX.test(ident)).toBe(false); + } + ); +}); + +describe('computeRenumber', () => { + it('assigns sequential integers sorted by parseFloat', () => { + const cues = ['2.1', '1', '3', '2', '2.2'].map((ident, i) => makeCue(i, ident)); + const { allMatched, changes } = computeRenumber(cues); + + expect(allMatched.map((m) => m.computedIdent)).toEqual(['1', '2', '3', '4', '5']); + // "1"→"1" and "2"→"2" stay the same; "2.1"→"3", "2.2"→"4", "3"→"5" change + expect(changes).toHaveLength(3); + }); + + it('returns empty changes when cues are already sequential', () => { + const cues = ['1', '2', '3'].map((ident, i) => makeCue(i, ident)); + const { allMatched, changes } = computeRenumber(cues); + + expect(changes).toHaveLength(0); + expect(allMatched).toHaveLength(3); + expect(allMatched.map((m) => m.computedIdent)).toEqual(['1', '2', '3']); + }); + + it('allMatched contains ALL matched cues including unchanged ones', () => { + const cues = ['1', '2', '3'].map((ident, i) => makeCue(i, ident)); + const { allMatched } = computeRenumber(cues); + expect(allMatched).toHaveLength(3); + }); + + it('separates non-numeric idents into unmatched', () => { + const cues = [makeCue(1, '1'), makeCue(2, 'LX-INTRO'), makeCue(3, '2'), makeCue(4, '')]; + const { allMatched, changes, unmatched } = computeRenumber(cues); + + expect(allMatched).toHaveLength(2); + expect(changes).toHaveLength(0); // 1→1, 2→2 — no changes + expect(unmatched).toHaveLength(2); + expect(unmatched.every((u) => !u.include)).toBe(true); + expect(unmatched.every((u) => u.newIdent === '')).toBe(true); + }); + + it('treats null ident as unmatched', () => { + const cues = [makeCue(1, null), makeCue(2, '1')]; + const { unmatched, allMatched } = computeRenumber(cues); + expect(unmatched).toHaveLength(1); + expect(allMatched).toHaveLength(1); + }); + + it('deduplicates same cue_id across multiple lines', () => { + // cue 1 appears on two lines — should only be counted once + const cue1 = { ...makeCue(1, '3'), line_id: 100 }; + const cue1dup = { ...makeCue(1, '3'), line_id: 200 }; + const cue2 = makeCue(2, '1'); + + const { allMatched, changes } = computeRenumber([cue1, cue1dup, cue2]); + expect(allMatched).toHaveLength(2); + // "1"→"1" no change; "3"→"2" is a change + expect(changes).toHaveLength(1); + expect(changes[0].oldIdent).toBe('3'); + expect(changes[0].newIdent).toBe('2'); + }); + + it('sorts by float value, not lexicographic', () => { + const cues = ['10', '2', '1'].map((ident, i) => makeCue(i, ident)); + const { allMatched } = computeRenumber(cues); + // sorted by parseFloat: 1, 2, 10 → get idents 1, 2, 3 + const originalIdents = allMatched.map((m) => m.cue.ident); + expect(originalIdents).toEqual(['1', '2', '10']); + }); + + it('maps old ident to new ident correctly in changes', () => { + // "1" sorts to position 1 → computedIdent "1" (no change) + // "2.1" sorts to position 2 → computedIdent "2" (change) + const cues = [makeCue(1, '2.1'), makeCue(2, '1')]; + const { changes } = computeRenumber(cues); + expect(changes).toHaveLength(1); + expect(changes[0].oldIdent).toBe('2.1'); + expect(changes[0].newIdent).toBe('2'); + }); + + it('cue already at correct position does not appear in changes', () => { + const cues = [makeCue(1, '2.1'), makeCue(2, '1')]; + const { changes } = computeRenumber(cues); + const identOneInChanges = changes.find((c) => c.oldIdent === '1'); + expect(identOneInChanges).toBeUndefined(); + }); + + it('handles MagicQ renumber example correctly', () => { + const idents = ['1', '2', '2.1', '2.2', '3', '4', '4.1', '4.2', '5']; + const cues = idents.map((ident, i) => makeCue(i, ident)); + const { allMatched, changes } = computeRenumber(cues); + + expect(allMatched.map((m) => m.computedIdent)).toEqual([ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + ]); + // All except the first (1→1) should be in changes + expect(changes.length).toBe(7); + }); +}); + +describe('useCueRenumber / flattenCuesForType', () => { + it('extracts cues for the given type from the cues dict', () => { + const { flattenCuesForType } = useCueRenumber(); + const cues: Record = { + '10': [ + { + id: 1, + cue_type_id: 1, + ident: 'A', + group_id: null, + sort_order: null, + line_position: null, + }, + { + id: 2, + cue_type_id: 2, + ident: 'B', + group_id: null, + sort_order: null, + line_position: null, + }, + ], + '20': [ + { + id: 3, + cue_type_id: 1, + ident: 'C', + group_id: null, + sort_order: null, + line_position: null, + }, + ], + }; + + const result = flattenCuesForType(cues, 1); + expect(result).toHaveLength(2); + expect(result.map((c) => c.id)).toEqual([1, 3]); + expect(result.find((c) => c.id === 1)?.line_id).toBe(10); + expect(result.find((c) => c.id === 3)?.line_id).toBe(20); + }); + + it('returns empty array when no cues match the type', () => { + const { flattenCuesForType } = useCueRenumber(); + const cues: Record = { + '10': [ + { + id: 1, + cue_type_id: 2, + ident: 'A', + group_id: null, + sort_order: null, + line_position: null, + }, + ], + }; + expect(flattenCuesForType(cues, 1)).toHaveLength(0); + }); + + it('returns empty array for empty cues dict', () => { + const { flattenCuesForType } = useCueRenumber(); + expect(flattenCuesForType({}, 1)).toHaveLength(0); + }); +}); diff --git a/client-v3/src/composables/useCueRenumber.ts b/client-v3/src/composables/useCueRenumber.ts new file mode 100644 index 000000000..8ddb4dcc0 --- /dev/null +++ b/client-v3/src/composables/useCueRenumber.ts @@ -0,0 +1,77 @@ +import type { Cue } from '@/types/api/cues'; + +export const NUMERIC_IDENT_REGEX = /^\d+(\.\d{1,2})?$/; + +export interface CueWithLineId extends Cue { + line_id: number; +} + +export interface RenumberChange { + cue: CueWithLineId; + oldIdent: string; + newIdent: string; +} + +export interface RenumberUnmatched { + cue: CueWithLineId; + originalIdent: string; + newIdent: string; + include: boolean; +} + +export interface RenumberAllMatched { + cue: CueWithLineId; + computedIdent: string; +} + +export interface RenumberResult { + allMatched: RenumberAllMatched[]; + changes: RenumberChange[]; + unmatched: RenumberUnmatched[]; +} + +export function computeRenumber(cues: CueWithLineId[]): RenumberResult { + const uniqueCues = [...new Map(cues.map((c) => [c.id, c])).values()]; + const matched: CueWithLineId[] = []; + const unmatched: RenumberUnmatched[] = []; + + for (const cue of uniqueCues) { + const ident = cue.ident?.trim() ?? ''; + if (NUMERIC_IDENT_REGEX.test(ident)) { + matched.push(cue); + } else { + unmatched.push({ cue, originalIdent: ident, newIdent: '', include: false }); + } + } + + matched.sort((a, b) => parseFloat(a.ident!) - parseFloat(b.ident!)); + + const allMatched: RenumberAllMatched[] = []; + const changes: RenumberChange[] = []; + matched.forEach((cue, index) => { + const computedIdent = String(index + 1); + allMatched.push({ cue, computedIdent }); + if (cue.ident !== computedIdent) { + changes.push({ cue, oldIdent: cue.ident ?? '', newIdent: computedIdent }); + } + }); + + return { allMatched, changes, unmatched }; +} + +export function useCueRenumber() { + function flattenCuesForType(cues: Record, cueTypeId: number): CueWithLineId[] { + const result: CueWithLineId[] = []; + for (const [lineIdStr, cueList] of Object.entries(cues)) { + const lineId = Number(lineIdStr); + for (const cue of cueList) { + if (cue.cue_type_id === cueTypeId) { + result.push({ ...cue, line_id: lineId }); + } + } + } + return result; + } + + return { computeRenumber, flattenCuesForType }; +} diff --git a/client-v3/src/stores/script.ts b/client-v3/src/stores/script.ts index 11cc42d31..635d454cc 100644 --- a/client-v3/src/stores/script.ts +++ b/client-v3/src/stores/script.ts @@ -319,6 +319,22 @@ export const useScriptStore = defineStore('script', { } }, + async renumberCues(operations: { cue_id: number; new_ident: string }[]): Promise { + const response = await fetch(makeURL('/api/v1/show/cues/renumber'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ operations }), + }); + if (response.ok) { + await this.loadCues(); + toast.success('Cues renumbered successfully!'); + } else { + const data = await response.json().catch(() => ({})); + toast.error(data?.message ?? 'Unable to renumber cues'); + throw new Error(data?.message ?? 'Failed to renumber cues'); + } + }, + async deleteCue(cue: { cueId: number; lineId: number }): Promise { const params = new URLSearchParams({ cueId: String(cue.cueId), diff --git a/client/src/js/cueRenumberUtils.test.ts b/client/src/js/cueRenumberUtils.test.ts new file mode 100644 index 000000000..07422326e --- /dev/null +++ b/client/src/js/cueRenumberUtils.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; +import { NUMERIC_IDENT_REGEX, computeRenumber, flattenCuesForType } from './cueRenumberUtils'; +import type { CueWithLineId } from './cueRenumberUtils'; +import type { Cue } from '@/types/api/cues'; + +function makeCue(id: number, ident: string | null, cueTypeId = 1): CueWithLineId { + return { + id, + ident, + cue_type_id: cueTypeId, + group_id: null, + sort_order: null, + line_position: null, + line_id: 100, + }; +} + +describe('NUMERIC_IDENT_REGEX', () => { + it.each(['1', '42', '2.1', '100.01', '0', '9.99'])('matches numeric ident "%s"', (ident) => { + expect(NUMERIC_IDENT_REGEX.test(ident)).toBe(true); + }); + + it.each(['', 'LX-1', '2.1x', '1.', ' 1', 'Q1 GO', '1a', 'INTRO', '1.111', '.5'])( + 'rejects non-numeric ident "%s"', + (ident) => { + expect(NUMERIC_IDENT_REGEX.test(ident)).toBe(false); + } + ); +}); + +describe('computeRenumber', () => { + it('assigns sequential integers sorted by parseFloat', () => { + const cues = ['2.1', '1', '3', '2', '2.2'].map((ident, i) => makeCue(i, ident)); + const { allMatched, changes } = computeRenumber(cues); + + expect(allMatched.map((m) => m.computedIdent)).toEqual(['1', '2', '3', '4', '5']); + // "1"→"1" and "2"→"2" stay the same; "2.1"→"3", "2.2"→"4", "3"→"5" change + expect(changes).toHaveLength(3); + }); + + it('returns empty changes when cues are already sequential', () => { + const cues = ['1', '2', '3'].map((ident, i) => makeCue(i, ident)); + const { allMatched, changes } = computeRenumber(cues); + + expect(changes).toHaveLength(0); + expect(allMatched).toHaveLength(3); + expect(allMatched.map((m) => m.computedIdent)).toEqual(['1', '2', '3']); + }); + + it('allMatched contains ALL matched cues including unchanged ones', () => { + const cues = ['1', '2', '3'].map((ident, i) => makeCue(i, ident)); + const { allMatched } = computeRenumber(cues); + expect(allMatched).toHaveLength(3); + }); + + it('separates non-numeric idents into unmatched', () => { + const cues = [makeCue(1, '1'), makeCue(2, 'LX-INTRO'), makeCue(3, '2'), makeCue(4, '')]; + const { allMatched, changes, unmatched } = computeRenumber(cues); + + expect(allMatched).toHaveLength(2); + expect(changes).toHaveLength(0); + expect(unmatched).toHaveLength(2); + expect(unmatched.every((u) => !u.include)).toBe(true); + expect(unmatched.every((u) => u.newIdent === '')).toBe(true); + }); + + it('treats null ident as unmatched', () => { + const cues = [makeCue(1, null), makeCue(2, '1')]; + const { unmatched, allMatched } = computeRenumber(cues); + expect(unmatched).toHaveLength(1); + expect(allMatched).toHaveLength(1); + }); + + it('deduplicates same cue_id across multiple lines', () => { + const cue1 = { ...makeCue(1, '3'), line_id: 100 }; + const cue1dup = { ...makeCue(1, '3'), line_id: 200 }; + const cue2 = makeCue(2, '1'); + + const { allMatched, changes } = computeRenumber([cue1, cue1dup, cue2]); + expect(allMatched).toHaveLength(2); + expect(changes).toHaveLength(1); + expect(changes[0].oldIdent).toBe('3'); + expect(changes[0].newIdent).toBe('2'); + }); + + it('sorts by float value, not lexicographic', () => { + const cues = ['10', '2', '1'].map((ident, i) => makeCue(i, ident)); + const { allMatched } = computeRenumber(cues); + const originalIdents = allMatched.map((m) => m.cue.ident); + expect(originalIdents).toEqual(['1', '2', '10']); + }); + + it('maps old ident to new ident correctly in changes', () => { + const cues = [makeCue(1, '2.1'), makeCue(2, '1')]; + const { changes } = computeRenumber(cues); + expect(changes).toHaveLength(1); + expect(changes[0].oldIdent).toBe('2.1'); + expect(changes[0].newIdent).toBe('2'); + }); + + it('cue already at correct position does not appear in changes', () => { + const cues = [makeCue(1, '2.1'), makeCue(2, '1')]; + const { changes } = computeRenumber(cues); + const identOneInChanges = changes.find((c) => c.oldIdent === '1'); + expect(identOneInChanges).toBeUndefined(); + }); + + it('handles MagicQ renumber example correctly', () => { + const idents = ['1', '2', '2.1', '2.2', '3', '4', '4.1', '4.2', '5']; + const cues = idents.map((ident, i) => makeCue(i, ident)); + const { allMatched, changes } = computeRenumber(cues); + + expect(allMatched.map((m) => m.computedIdent)).toEqual([ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + ]); + expect(changes.length).toBe(7); + }); +}); + +describe('flattenCuesForType', () => { + it('extracts cues for the given type from the cues dict', () => { + const cues: Record = { + '10': [ + { + id: 1, + cue_type_id: 1, + ident: 'A', + group_id: null, + sort_order: null, + line_position: null, + }, + { + id: 2, + cue_type_id: 2, + ident: 'B', + group_id: null, + sort_order: null, + line_position: null, + }, + ], + '20': [ + { + id: 3, + cue_type_id: 1, + ident: 'C', + group_id: null, + sort_order: null, + line_position: null, + }, + ], + }; + + const result = flattenCuesForType(cues, 1); + expect(result).toHaveLength(2); + expect(result.map((c) => c.id)).toEqual([1, 3]); + expect(result.find((c) => c.id === 1)?.line_id).toBe(10); + expect(result.find((c) => c.id === 3)?.line_id).toBe(20); + }); + + it('returns empty array when no cues match the type', () => { + const cues: Record = { + '10': [ + { + id: 1, + cue_type_id: 2, + ident: 'A', + group_id: null, + sort_order: null, + line_position: null, + }, + ], + }; + expect(flattenCuesForType(cues, 1)).toHaveLength(0); + }); + + it('returns empty array for empty cues dict', () => { + expect(flattenCuesForType({}, 1)).toHaveLength(0); + }); +}); diff --git a/client/src/js/cueRenumberUtils.ts b/client/src/js/cueRenumberUtils.ts new file mode 100644 index 000000000..484e22a34 --- /dev/null +++ b/client/src/js/cueRenumberUtils.ts @@ -0,0 +1,76 @@ +import type { Cue } from '@/types/api/cues'; + +export const NUMERIC_IDENT_REGEX = /^\d+(\.\d{1,2})?$/; + +export interface CueWithLineId extends Cue { + line_id: number; +} + +export interface RenumberChange { + cue: CueWithLineId; + oldIdent: string; + newIdent: string; +} + +export interface RenumberUnmatched { + cue: CueWithLineId; + originalIdent: string; + newIdent: string; + include: boolean; +} + +export interface RenumberAllMatched { + cue: CueWithLineId; + computedIdent: string; +} + +export interface RenumberResult { + allMatched: RenumberAllMatched[]; + changes: RenumberChange[]; + unmatched: RenumberUnmatched[]; +} + +export function computeRenumber(cues: CueWithLineId[]): RenumberResult { + const uniqueCues = [...new Map(cues.map((c) => [c.id, c])).values()]; + const matched: CueWithLineId[] = []; + const unmatched: RenumberUnmatched[] = []; + + for (const cue of uniqueCues) { + const ident = cue.ident?.trim() ?? ''; + if (NUMERIC_IDENT_REGEX.test(ident)) { + matched.push(cue); + } else { + unmatched.push({ cue, originalIdent: ident, newIdent: '', include: false }); + } + } + + matched.sort((a, b) => parseFloat(a.ident!) - parseFloat(b.ident!)); + + const allMatched: RenumberAllMatched[] = []; + const changes: RenumberChange[] = []; + matched.forEach((cue, index) => { + const computedIdent = String(index + 1); + allMatched.push({ cue, computedIdent }); + if (cue.ident !== computedIdent) { + changes.push({ cue, oldIdent: cue.ident ?? '', newIdent: computedIdent }); + } + }); + + return { allMatched, changes, unmatched }; +} + +export function flattenCuesForType( + cues: Record, + cueTypeId: number +): CueWithLineId[] { + const result: CueWithLineId[] = []; + for (const [lineIdStr, cueList] of Object.entries(cues)) { + const lineId = Number(lineIdStr); + for (const cue of cueList) { + if (cue.cue_type_id === cueTypeId) { + result.push({ ...cue, line_id: lineId }); + } + } + } + return result; +} diff --git a/client/src/store/modules/script.ts b/client/src/store/modules/script.ts index 97cef7642..c4830dc64 100644 --- a/client/src/store/modules/script.ts +++ b/client/src/store/modules/script.ts @@ -228,6 +228,23 @@ const module: Module = { VueToast.$toast.error('Unable to delete cue'); } }, + async RENUMBER_CUES(context, operations: { cue_id: number; new_ident: string }[]) { + const response = await fetch(makeURL('/api/v1/show/cues/renumber'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ operations }), + }); + if (response.ok) { + await context.dispatch('LOAD_CUES'); + VueToast.$toast.success('Cues renumbered successfully!'); + } else { + const data = await response.json().catch(() => ({})); + const msg = data?.message ?? 'Unable to renumber cues'; + log.error(msg); + VueToast.$toast.error(msg); + throw new Error(msg); + } + }, async ADD_CUE_GROUP( context, payload: { diff --git a/client/src/vue_components/show/config/cues/CueEditor.vue b/client/src/vue_components/show/config/cues/CueEditor.vue index 72d10cd0a..3dc684a15 100644 --- a/client/src/vue_components/show/config/cues/CueEditor.vue +++ b/client/src/vue_components/show/config/cues/CueEditor.vue @@ -5,6 +5,9 @@ Go to Page Go to Cue + + Renumber Cues + @@ -74,6 +77,7 @@ + + + + + + + + + +
+ + Cancel + + + Back + + + Next + + + + Confirm Renumber + +
+
+ + + diff --git a/docs/pages/cue_config.md b/docs/pages/cue_config.md index 17b38be96..56bbe2196 100644 --- a/docs/pages/cue_config.md +++ b/docs/pages/cue_config.md @@ -80,6 +80,55 @@ 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 lets you perform the same sequential renumber operation that MagicQ lighting consoles call "renum". When MagicQ collapses point cues (e.g. 3.1, 3.2) into sequential integers, DigiScript's cue identifiers become stale. This feature resynchronises them. + +#### When to use it + +Use Renumber Cues after performing a renum/reorder on your MagicQ console so that DigiScript's cue labels match the updated cue numbers in MagicQ. + +#### How it works + +The MagicQ algorithm sorts cues numerically and assigns sequential integers starting at 1: + +| Before | After | +|--------|-------| +| 1, 2, 2.1, 2.2, 3, 4, 4.1, 4.2, 5 | 1, 2, 3, 4, 5, 6, 7, 8, 9 | + +Each selected cue type is renumbered independently. + +#### Accessing the feature + +1. Navigate to **Cues → Cue Configuration** +2. Click the **Renumber Cues** button in the toolbar + +#### Step 1 — Configure + +- **Renumber Method**: Select the algorithm to use. Currently only **MagicQ (Sequential)** is available. +- **Cue Types to Renumber**: Check one or more cue types. Only checked types will have their cues renumbered. + +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 whose identifier does not match the numeric pattern (e.g. free-form text like "INTRO" or "1a"). These are skipped by default. Tick the **Include** checkbox next to any unmatched cue you want to reassign, then enter the new identifier manually. + +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 720a54d72..714fa37f2 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 ea5154fa9..10eb9f6b6 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 new_ident + or not isinstance(new_ident, str) + or len(new_ident) > 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 2c39afe06..cc8a440b4 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) From 09d3beda375506c4c4f7295759176831848b387c Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 3 Jul 2026 00:08:18 +0100 Subject: [PATCH 2/6] Fix cue renumber algorithm: prefix extraction, cross-type pooling Three bugs in the original renumber algorithm: 1. Per-type renumbering ran each selected cue type independently, causing each type to restart from 1. All selected types are now pooled into a single computeRenumber call before sequencing. 2. Cues with text suffixes (e.g. "2.1 - Blackout") were fully excluded. The new NUMERIC_PREFIX_REGEX extracts the leading numeric value and preserves the suffix. These cues consume a sequential slot and appear in the Unmatched section with a pre-computed suggestion (e.g. "3 - Blackout") that the user can opt into. 3. Replaces allMatchedByType Map with a flat allMatched array; step2Valid now filters by cue_type_id from the flat list. Co-Authored-By: Claude Sonnet 4.6 --- .../show/config/cues/CueRenumberModal.vue | 22 +++---- .../src/composables/useCueRenumber.test.ts | 65 ++++++++++++++++++- client-v3/src/composables/useCueRenumber.ts | 47 ++++++++++---- client/src/js/cueRenumberUtils.test.ts | 65 ++++++++++++++++++- client/src/js/cueRenumberUtils.ts | 47 ++++++++++---- .../show/config/cues/CueRenumberModal.vue | 22 +++---- 6 files changed, 220 insertions(+), 48 deletions(-) diff --git a/client-v3/src/components/show/config/cues/CueRenumberModal.vue b/client-v3/src/components/show/config/cues/CueRenumberModal.vue index b980367f8..422680474 100644 --- a/client-v3/src/components/show/config/cues/CueRenumberModal.vue +++ b/client-v3/src/components/show/config/cues/CueRenumberModal.vue @@ -156,7 +156,7 @@ const modal = ref | null>(null); const step = ref<1 | 2>(1); const method = ref<'magicq'>('magicq'); const selectedTypeIds = ref([]); -const allMatchedByType = ref>(new Map()); +const allMatched = ref([]); const changes = ref([]); const unmatched = ref([]); const submitting = ref(false); @@ -192,7 +192,7 @@ const totalOperations = computed( const step2Valid = computed(() => { for (const typeId of selectedTypeIds.value) { - const allForType = allMatchedByType.value.get(typeId) ?? []; + const allForType = allMatched.value.filter((m) => m.cue.cue_type_id === typeId); const changeOverrides = new Map( changes.value @@ -221,14 +221,14 @@ const step2Valid = computed(() => { function onNext(): void { changes.value = []; unmatched.value = []; - allMatchedByType.value = new Map(); - for (const typeId of selectedTypeIds.value) { - const cues = flattenCuesForType(scriptStore.cues, typeId); - const result = computeRenumber(cues); - allMatchedByType.value.set(typeId, result.allMatched); - changes.value.push(...result.changes); - unmatched.value.push(...result.unmatched); - } + allMatched.value = []; + const allCues = selectedTypeIds.value.flatMap((typeId) => + flattenCuesForType(scriptStore.cues, typeId) + ); + const result = computeRenumber(allCues); + allMatched.value = result.allMatched; + changes.value = result.changes; + unmatched.value = result.unmatched; step.value = 2; } @@ -254,7 +254,7 @@ function onHidden(): void { method.value = 'magicq'; changes.value = []; unmatched.value = []; - allMatchedByType.value = new Map(); + allMatched.value = []; submitting.value = false; } diff --git a/client-v3/src/composables/useCueRenumber.test.ts b/client-v3/src/composables/useCueRenumber.test.ts index 6c0e21176..194f702cb 100644 --- a/client-v3/src/composables/useCueRenumber.test.ts +++ b/client-v3/src/composables/useCueRenumber.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { NUMERIC_IDENT_REGEX, computeRenumber, useCueRenumber } from './useCueRenumber'; +import { + NUMERIC_IDENT_REGEX, + NUMERIC_PREFIX_REGEX, + computeRenumber, + useCueRenumber, +} from './useCueRenumber'; import type { CueWithLineId } from './useCueRenumber'; import type { Cue } from '@/types/api/cues'; @@ -28,6 +33,25 @@ describe('NUMERIC_IDENT_REGEX', () => { ); }); +describe('NUMERIC_PREFIX_REGEX', () => { + it.each([ + ['1', '1', ''], + ['2.1', '2.1', ''], + ['1 - House', '1', ' - House'], + ['2.1 - Blackout', '2.1', ' - Blackout'], + ['202 - LASER', '202', ' - LASER'], + ])('extracts prefix and suffix from "%s"', (ident, prefix, suffix) => { + const match = NUMERIC_PREFIX_REGEX.exec(ident); + expect(match).not.toBeNull(); + expect(match![1]).toBe(prefix); + expect(match![2]).toBe(suffix); + }); + + it.each(['', 'LX-INTRO', 'Q1 GO', '.5', 'INTRO'])('does not match "%s"', (ident) => { + expect(NUMERIC_PREFIX_REGEX.exec(ident)).toBeNull(); + }); +}); + describe('computeRenumber', () => { it('assigns sequential integers sorted by parseFloat', () => { const cues = ['2.1', '1', '3', '2', '2.2'].map((ident, i) => makeCue(i, ident)); @@ -129,6 +153,45 @@ describe('computeRenumber', () => { // All except the first (1→1) should be in changes expect(changes.length).toBe(7); }); + + it('places text-suffix cue in unmatched with pre-computed slot ident', () => { + // Tim's example: 1, 2, 2.1 - Blackout, 3 → 1, 2, 3 - Blackout, 4 + const cues = [makeCue(1, '1'), makeCue(2, '2'), makeCue(3, '2.1 - Blackout'), makeCue(4, '3')]; + const { allMatched, changes, unmatched } = computeRenumber(cues); + + // Slots: "1"→slot1, "2"→slot2, "2.1 - Blackout"→slot3, "3"→slot4 + expect(allMatched).toHaveLength(3); // only fully numeric + expect(allMatched.map((m) => m.computedIdent)).toEqual(['1', '2', '4']); + + expect(changes).toHaveLength(1); // "3" → "4" + expect(changes[0].oldIdent).toBe('3'); + expect(changes[0].newIdent).toBe('4'); + + expect(unmatched).toHaveLength(1); + expect(unmatched[0].originalIdent).toBe('2.1 - Blackout'); + expect(unmatched[0].newIdent).toBe('3 - Blackout'); + expect(unmatched[0].include).toBe(false); + }); + + it('text-suffix cue consumes a slot, shifting later pure cues', () => { + const cues = [makeCue(1, '1 - House'), makeCue(2, '53'), makeCue(3, '56')]; + const { allMatched, changes, unmatched } = computeRenumber(cues); + + // "1 - House" takes slot 1, so "53"→slot2="2", "56"→slot3="3" + expect(allMatched.map((m) => m.computedIdent)).toEqual(['2', '3']); + expect(changes).toHaveLength(2); + expect(unmatched).toHaveLength(1); + expect(unmatched[0].newIdent).toBe('1 - House'); + }); + + it('fully non-numeric cue goes to unmatched with empty newIdent', () => { + const cues = [makeCue(1, 'LX-INTRO'), makeCue(2, '1')]; + const { allMatched, unmatched } = computeRenumber(cues); + + expect(allMatched).toHaveLength(1); + expect(unmatched).toHaveLength(1); + expect(unmatched[0].newIdent).toBe(''); + }); }); describe('useCueRenumber / flattenCuesForType', () => { diff --git a/client-v3/src/composables/useCueRenumber.ts b/client-v3/src/composables/useCueRenumber.ts index 8ddb4dcc0..24497cab7 100644 --- a/client-v3/src/composables/useCueRenumber.ts +++ b/client-v3/src/composables/useCueRenumber.ts @@ -1,6 +1,8 @@ import type { Cue } from '@/types/api/cues'; export const NUMERIC_IDENT_REGEX = /^\d+(\.\d{1,2})?$/; +// Extracts a leading numeric prefix (up to 2 decimal places) plus any trailing suffix. +export const NUMERIC_PREFIX_REGEX = /^(\d+(?:\.\d{1,2})?)(.*)$/; export interface CueWithLineId extends Cue { line_id: number; @@ -32,31 +34,52 @@ export interface RenumberResult { export function computeRenumber(cues: CueWithLineId[]): RenumberResult { const uniqueCues = [...new Map(cues.map((c) => [c.id, c])).values()]; - const matched: CueWithLineId[] = []; - const unmatched: RenumberUnmatched[] = []; + + interface ParsedCue { + cue: CueWithLineId; + numericValue: number; + suffix: string; + isFullyNumeric: boolean; + } + + const withPrefix: ParsedCue[] = []; + const fullyUnmatched: RenumberUnmatched[] = []; for (const cue of uniqueCues) { const ident = cue.ident?.trim() ?? ''; - if (NUMERIC_IDENT_REGEX.test(ident)) { - matched.push(cue); + const match = NUMERIC_PREFIX_REGEX.exec(ident); + if (match) { + const suffix = match[2]; + withPrefix.push({ + cue, + numericValue: parseFloat(match[1]), + suffix, + isFullyNumeric: suffix.trim() === '', + }); } else { - unmatched.push({ cue, originalIdent: ident, newIdent: '', include: false }); + fullyUnmatched.push({ cue, originalIdent: ident, newIdent: '', include: false }); } } - matched.sort((a, b) => parseFloat(a.ident!) - parseFloat(b.ident!)); + withPrefix.sort((a, b) => a.numericValue - b.numericValue); const allMatched: RenumberAllMatched[] = []; const changes: RenumberChange[] = []; - matched.forEach((cue, index) => { - const computedIdent = String(index + 1); - allMatched.push({ cue, computedIdent }); - if (cue.ident !== computedIdent) { - changes.push({ cue, oldIdent: cue.ident ?? '', newIdent: computedIdent }); + const prefixUnmatched: RenumberUnmatched[] = []; + + withPrefix.forEach(({ cue, suffix, isFullyNumeric }, index) => { + const newIdent = String(index + 1) + suffix; + if (isFullyNumeric) { + allMatched.push({ cue, computedIdent: newIdent }); + if ((cue.ident?.trim() ?? '') !== newIdent) { + changes.push({ cue, oldIdent: cue.ident ?? '', newIdent }); + } + } else { + prefixUnmatched.push({ cue, originalIdent: cue.ident ?? '', newIdent, include: false }); } }); - return { allMatched, changes, unmatched }; + return { allMatched, changes, unmatched: [...prefixUnmatched, ...fullyUnmatched] }; } export function useCueRenumber() { diff --git a/client/src/js/cueRenumberUtils.test.ts b/client/src/js/cueRenumberUtils.test.ts index 07422326e..9e899bcc5 100644 --- a/client/src/js/cueRenumberUtils.test.ts +++ b/client/src/js/cueRenumberUtils.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { NUMERIC_IDENT_REGEX, computeRenumber, flattenCuesForType } from './cueRenumberUtils'; +import { + NUMERIC_IDENT_REGEX, + NUMERIC_PREFIX_REGEX, + computeRenumber, + flattenCuesForType, +} from './cueRenumberUtils'; import type { CueWithLineId } from './cueRenumberUtils'; import type { Cue } from '@/types/api/cues'; @@ -28,6 +33,25 @@ describe('NUMERIC_IDENT_REGEX', () => { ); }); +describe('NUMERIC_PREFIX_REGEX', () => { + it.each([ + ['1', '1', ''], + ['2.1', '2.1', ''], + ['1 - House', '1', ' - House'], + ['2.1 - Blackout', '2.1', ' - Blackout'], + ['202 - LASER', '202', ' - LASER'], + ])('extracts prefix and suffix from "%s"', (ident, prefix, suffix) => { + const match = NUMERIC_PREFIX_REGEX.exec(ident); + expect(match).not.toBeNull(); + expect(match![1]).toBe(prefix); + expect(match![2]).toBe(suffix); + }); + + it.each(['', 'LX-INTRO', 'Q1 GO', '.5', 'INTRO'])('does not match "%s"', (ident) => { + expect(NUMERIC_PREFIX_REGEX.exec(ident)).toBeNull(); + }); +}); + describe('computeRenumber', () => { it('assigns sequential integers sorted by parseFloat', () => { const cues = ['2.1', '1', '3', '2', '2.2'].map((ident, i) => makeCue(i, ident)); @@ -123,6 +147,45 @@ describe('computeRenumber', () => { ]); expect(changes.length).toBe(7); }); + + it('places text-suffix cue in unmatched with pre-computed slot ident', () => { + // Tim's example: 1, 2, 2.1 - Blackout, 3 → 1, 2, 3 - Blackout, 4 + const cues = [makeCue(1, '1'), makeCue(2, '2'), makeCue(3, '2.1 - Blackout'), makeCue(4, '3')]; + const { allMatched, changes, unmatched } = computeRenumber(cues); + + // Slots: "1"→slot1, "2"→slot2, "2.1 - Blackout"→slot3, "3"→slot4 + expect(allMatched).toHaveLength(3); // only fully numeric + expect(allMatched.map((m) => m.computedIdent)).toEqual(['1', '2', '4']); + + expect(changes).toHaveLength(1); // "3" → "4" + expect(changes[0].oldIdent).toBe('3'); + expect(changes[0].newIdent).toBe('4'); + + expect(unmatched).toHaveLength(1); + expect(unmatched[0].originalIdent).toBe('2.1 - Blackout'); + expect(unmatched[0].newIdent).toBe('3 - Blackout'); + expect(unmatched[0].include).toBe(false); + }); + + it('text-suffix cue consumes a slot, shifting later pure cues', () => { + const cues = [makeCue(1, '1 - House'), makeCue(2, '53'), makeCue(3, '56')]; + const { allMatched, changes, unmatched } = computeRenumber(cues); + + // "1 - House" takes slot 1, so "53"→slot2="2", "56"→slot3="3" + expect(allMatched.map((m) => m.computedIdent)).toEqual(['2', '3']); + expect(changes).toHaveLength(2); + expect(unmatched).toHaveLength(1); + expect(unmatched[0].newIdent).toBe('1 - House'); + }); + + it('fully non-numeric cue goes to unmatched with empty newIdent', () => { + const cues = [makeCue(1, 'LX-INTRO'), makeCue(2, '1')]; + const { allMatched, unmatched } = computeRenumber(cues); + + expect(allMatched).toHaveLength(1); + expect(unmatched).toHaveLength(1); + expect(unmatched[0].newIdent).toBe(''); + }); }); describe('flattenCuesForType', () => { diff --git a/client/src/js/cueRenumberUtils.ts b/client/src/js/cueRenumberUtils.ts index 484e22a34..797444284 100644 --- a/client/src/js/cueRenumberUtils.ts +++ b/client/src/js/cueRenumberUtils.ts @@ -1,6 +1,8 @@ import type { Cue } from '@/types/api/cues'; export const NUMERIC_IDENT_REGEX = /^\d+(\.\d{1,2})?$/; +// Extracts a leading numeric prefix (up to 2 decimal places) plus any trailing suffix. +export const NUMERIC_PREFIX_REGEX = /^(\d+(?:\.\d{1,2})?)(.*)$/; export interface CueWithLineId extends Cue { line_id: number; @@ -32,31 +34,52 @@ export interface RenumberResult { export function computeRenumber(cues: CueWithLineId[]): RenumberResult { const uniqueCues = [...new Map(cues.map((c) => [c.id, c])).values()]; - const matched: CueWithLineId[] = []; - const unmatched: RenumberUnmatched[] = []; + + interface ParsedCue { + cue: CueWithLineId; + numericValue: number; + suffix: string; + isFullyNumeric: boolean; + } + + const withPrefix: ParsedCue[] = []; + const fullyUnmatched: RenumberUnmatched[] = []; for (const cue of uniqueCues) { const ident = cue.ident?.trim() ?? ''; - if (NUMERIC_IDENT_REGEX.test(ident)) { - matched.push(cue); + const match = NUMERIC_PREFIX_REGEX.exec(ident); + if (match) { + const suffix = match[2]; + withPrefix.push({ + cue, + numericValue: parseFloat(match[1]), + suffix, + isFullyNumeric: suffix.trim() === '', + }); } else { - unmatched.push({ cue, originalIdent: ident, newIdent: '', include: false }); + fullyUnmatched.push({ cue, originalIdent: ident, newIdent: '', include: false }); } } - matched.sort((a, b) => parseFloat(a.ident!) - parseFloat(b.ident!)); + withPrefix.sort((a, b) => a.numericValue - b.numericValue); const allMatched: RenumberAllMatched[] = []; const changes: RenumberChange[] = []; - matched.forEach((cue, index) => { - const computedIdent = String(index + 1); - allMatched.push({ cue, computedIdent }); - if (cue.ident !== computedIdent) { - changes.push({ cue, oldIdent: cue.ident ?? '', newIdent: computedIdent }); + const prefixUnmatched: RenumberUnmatched[] = []; + + withPrefix.forEach(({ cue, suffix, isFullyNumeric }, index) => { + const newIdent = String(index + 1) + suffix; + if (isFullyNumeric) { + allMatched.push({ cue, computedIdent: newIdent }); + if ((cue.ident?.trim() ?? '') !== newIdent) { + changes.push({ cue, oldIdent: cue.ident ?? '', newIdent }); + } + } else { + prefixUnmatched.push({ cue, originalIdent: cue.ident ?? '', newIdent, include: false }); } }); - return { allMatched, changes, unmatched }; + return { allMatched, changes, unmatched: [...prefixUnmatched, ...fullyUnmatched] }; } export function flattenCuesForType( diff --git a/client/src/vue_components/show/config/cues/CueRenumberModal.vue b/client/src/vue_components/show/config/cues/CueRenumberModal.vue index a880a2da7..36b24352a 100644 --- a/client/src/vue_components/show/config/cues/CueRenumberModal.vue +++ b/client/src/vue_components/show/config/cues/CueRenumberModal.vue @@ -157,7 +157,7 @@ export default defineComponent({ step: 1 as 1 | 2, method: 'magicq' as const, selectedTypeIds: [] as number[], - allMatchedByType: new Map(), + allMatched: [] as RenumberAllMatched[], changes: [] as RenumberChange[], unmatched: [] as RenumberUnmatched[], submitting: false, @@ -184,7 +184,7 @@ export default defineComponent({ }, step2Valid(): boolean { for (const typeId of this.selectedTypeIds) { - const allForType = this.allMatchedByType.get(typeId) ?? []; + const allForType = this.allMatched.filter((m) => m.cue.cue_type_id === typeId); const changeOverrides = new Map( this.changes @@ -221,14 +221,14 @@ export default defineComponent({ onNext(): void { this.changes = []; this.unmatched = []; - this.allMatchedByType = new Map(); - for (const typeId of this.selectedTypeIds) { - const cues = flattenCuesForType((this as any).SCRIPT_CUES, typeId); - const result = computeRenumber(cues); - this.allMatchedByType.set(typeId, result.allMatched); - this.changes.push(...result.changes); - this.unmatched.push(...result.unmatched); - } + this.allMatched = []; + const allCues = this.selectedTypeIds.flatMap((typeId: number) => + flattenCuesForType((this as any).SCRIPT_CUES, typeId) + ); + const result = computeRenumber(allCues); + this.allMatched = result.allMatched; + this.changes = result.changes; + this.unmatched = result.unmatched; this.step = 2; }, async onConfirm(): Promise { @@ -252,7 +252,7 @@ export default defineComponent({ this.method = 'magicq'; this.changes = []; this.unmatched = []; - this.allMatchedByType = new Map(); + this.allMatched = []; this.submitting = false; }, }, From cc5d46431f0c56db69bb278f0e7acd1fa3bc5c2e Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 3 Jul 2026 00:36:34 +0100 Subject: [PATCH 3/6] Add MagicQ CSV-driven cue renumber (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the sequential algorithm with a CSV-upload workflow so DigiScript can simulate the exact MagicQ renum for a full console cue stack — including cues intentionally omitted from the script. - parseMagicQCsv: scans header row for "Cue id" column, builds old-float→new-integer Map from the exported pre-renum data - computeRenumber now requires a csvMapping argument; cues whose prefix is absent from the map land in Unmatched with no suggestion - Modal: method dropdown replaced with BFormFile CSV upload; "Next" gated on csvParsed && selectedTypeIds.length > 0 - Unmatched description updated to explain both reasons (not in CSV, no numeric prefix); text-suffix cues show pre-computed suggestion - Tests updated across both clients; 53 new tests for parseMagicQCsv and the updated computeRenumber signature (76/181 total pass) - Docs: Renumbering Cues section rewritten with CSV export steps and sparse-cue example Co-Authored-By: Claude Sonnet 4.6 --- .../show/config/cues/CueRenumberModal.vue | 58 +++++-- .../src/composables/useCueRenumber.test.ts | 147 +++++++++++++++--- client-v3/src/composables/useCueRenumber.ts | 60 +++++-- client/src/js/cueRenumberUtils.test.ts | 141 ++++++++++++++--- client/src/js/cueRenumberUtils.ts | 60 +++++-- .../show/config/cues/CueRenumberModal.vue | 58 +++++-- docs/pages/cue_config.md | 43 +++-- 7 files changed, 462 insertions(+), 105 deletions(-) diff --git a/client-v3/src/components/show/config/cues/CueRenumberModal.vue b/client-v3/src/components/show/config/cues/CueRenumberModal.vue index 422680474..5dc4258fa 100644 --- a/client-v3/src/components/show/config/cues/CueRenumberModal.vue +++ b/client-v3/src/components/show/config/cues/CueRenumberModal.vue @@ -11,12 +11,12 @@ >