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 37d96171..9a251b30 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,59 @@ 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 CSV dropzone and cue type checkboxes + await expect(page.locator('.modal.show .csv-dropzone')).toBeVisible(); +}); + +test('Next button is disabled until a CSV is uploaded and a cue type is selected', async () => { + await expect(page.locator('.modal.show button:has-text("Next")')).toBeDisabled(); +}); + +test('Next button enables after uploading a CSV and selecting a cue type', async () => { + // Upload a minimal MagicQ-style CSV via the hidden file input + const csv = 'Status ,Cue id ,Comment\n,1.00 ,first\n,2.00 ,second\n'; + await page.locator('.modal.show input[type="file"]').setInputFiles({ + name: 'test.csv', + mimeType: 'text/csv', + buffer: Buffer.from(csv), + }); + await expect(page.locator('.modal.show .csv-dropzone--loaded')).toBeVisible(); + // 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 — dropzone is visible + await expect(page.locator('.modal.show .csv-dropzone')).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 03e5fafd..50e9473a 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 00000000..49ba78d8 --- /dev/null +++ b/client-v3/src/components/show/config/cues/CueRenumberModal.vue @@ -0,0 +1,382 @@ + + + + + diff --git a/client-v3/src/composables/useCueRenumber.test.ts b/client-v3/src/composables/useCueRenumber.test.ts new file mode 100644 index 00000000..dd250856 --- /dev/null +++ b/client-v3/src/composables/useCueRenumber.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it } from 'vitest'; +import { + NUMERIC_IDENT_REGEX, + NUMERIC_PREFIX_REGEX, + parseMagicQCsv, + 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, + }; +} + +/** Builds the sequential mapping that MagicQ would produce for a given set of ident strings. */ +function makeMap(idents: string[]): Map { + const floats = idents.map(parseFloat).sort((a, b) => a - b); + const map = new Map(); + floats.forEach((f, i) => map.set(f, i + 1)); + return map; +} + +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('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', '1.111'])('does not match "%s"', (ident) => { + expect(NUMERIC_PREFIX_REGEX.exec(ident)).toBeNull(); + }); +}); + +describe('parseMagicQCsv', () => { + it('builds sequential mapping from Cue id column', () => { + const csv = `Status ,Cue id ,Cue text ,Comment\n* ,1.00 ,0001 ,First\n ,2.10 ,0002 ,Second\n ,3.00 ,0003 ,Third`; + const mapping = parseMagicQCsv(csv); + expect(mapping.get(1)).toBe(1); + expect(mapping.get(2.1)).toBe(2); + expect(mapping.get(3)).toBe(3); + expect(mapping.size).toBe(3); + }); + + it('finds Cue id column by header name regardless of column position', () => { + const csv = `Comment ,Cue id ,Status\nFirst ,1.00 ,*\nSecond ,2.00 ,`; + const mapping = parseMagicQCsv(csv); + expect(mapping.size).toBe(2); + expect(mapping.get(1)).toBe(1); + expect(mapping.get(2)).toBe(2); + }); + + it('assigns sequential integers in sort order, not CSV order', () => { + const csv = `Status ,Cue id ,Comment\n,3.00 ,third\n,1.00 ,first\n,2.00 ,second`; + const mapping = parseMagicQCsv(csv); + expect(mapping.get(1)).toBe(1); + expect(mapping.get(2)).toBe(2); + expect(mapping.get(3)).toBe(3); + }); + + it('trims whitespace from header and values', () => { + const csv = `Status , Cue id , Comment\n* , 1.00 ,comment\n , 2.10 ,comment`; + const mapping = parseMagicQCsv(csv); + expect(mapping.get(1)).toBe(1); + expect(mapping.get(2.1)).toBe(2); + }); + + it('skips rows with non-numeric cue id values', () => { + const csv = `Status ,Cue id ,Comment\n,1.00 ,ok\n,text ,skip\n,2.00 ,ok`; + const mapping = parseMagicQCsv(csv); + expect(mapping.size).toBe(2); + }); + + it('skips rows that are too short', () => { + const csv = `Status ,Cue id ,Comment\n,1.00 ,ok\nshortrow\n,2.00 ,ok`; + const mapping = parseMagicQCsv(csv); + expect(mapping.size).toBe(2); + }); + + it('returns empty map when Cue id column not found', () => { + const csv = `Status ,Cue ,Comment\n,1.00 ,text`; + expect(parseMagicQCsv(csv).size).toBe(0); + }); + + it('returns empty map for empty string', () => { + expect(parseMagicQCsv('').size).toBe(0); + }); + + it('handles the real MagicQ export format', () => { + const csv = `Status ,Cue id ,Cue text ,Wait ,Halt ,Delay ,Fade ,Pos ,Col ,Beam ,Cue ,Next cue ,Timing ,Track ,Block FX ,Cue only ,Macro ,Comment ,Audio ,Media\n* ,1.00 ,0001 ,00/00/05.23 ,Tc , 0.00s , 0.00s ,0.00s ,0.00s ,0.00s ,Q1(L)0001 ,Next ,Cue ,HLF ,No ,No , ,Lights On , ,\n ,2.00 ,0002 ,00/00/06.02 ,Tc , 0.00s , 0.00s ,0.00s ,0.00s ,0.00s ,Q2(L)0002 ,Next ,Cue ,HLF ,No ,No , ,Lights Off , ,`; + const mapping = parseMagicQCsv(csv); + expect(mapping.get(1)).toBe(1); + expect(mapping.get(2)).toBe(2); + }); +}); + +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 csvMapping = makeMap(['2.1', '1', '3', '2', '2.2']); + const { allMatched, changes } = computeRenumber(cues, csvMapping); + + 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 csvMapping = makeMap(['1', '2', '3']); + const { allMatched, changes } = computeRenumber(cues, csvMapping); + + 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, makeMap(['1', '2', '3'])); + 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, makeMap(['1', '2'])); + + 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, makeMap(['1'])); + 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], makeMap(['3', '1'])); + 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, makeMap(['10', '2', '1'])); + 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, makeMap(['2.1', '1'])); + 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, makeMap(['2.1', '1'])); + 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, makeMap(idents)); + + expect(allMatched.map((m) => m.computedIdent)).toEqual([ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + ]); + 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 csvMapping = makeMap(['1', '2', '2.1', '3']); + const cues = [makeCue(1, '1'), makeCue(2, '2'), makeCue(3, '2.1 - Blackout'), makeCue(4, '3')]; + const { allMatched, changes, unmatched } = computeRenumber(cues, csvMapping); + + expect(allMatched).toHaveLength(3); + expect(allMatched.map((m) => m.computedIdent)).toEqual(['1', '2', '4']); + + expect(changes).toHaveLength(1); + 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 in the CSV, shifting later pure cues', () => { + // CSV has 1, 53, 56 — "1 - House" in DigiScript matches CSV cue 1 + const csvMapping = makeMap(['1', '53', '56']); + const cues = [makeCue(1, '1 - House'), makeCue(2, '53'), makeCue(3, '56')]; + const { allMatched, changes, unmatched } = computeRenumber(cues, csvMapping); + + 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, makeMap(['1'])); + + expect(allMatched).toHaveLength(1); + expect(unmatched).toHaveLength(1); + expect(unmatched[0].newIdent).toBe(''); + }); + + it('cue with numeric prefix absent from CSV goes to unmatched with empty newIdent', () => { + // DigiScript has cues 1 and 5, but CSV only has 1, 2, 3 (cue 5 is not in the console export) + const csvMapping = new Map([ + [1, 1], + [2, 2], + [3, 3], + ]); + const cues = [makeCue(1, '1'), makeCue(2, '5')]; + const { allMatched, unmatched } = computeRenumber(cues, csvMapping); + + expect(allMatched).toHaveLength(1); + expect(allMatched[0].computedIdent).toBe('1'); + expect(unmatched).toHaveLength(1); + expect(unmatched[0].originalIdent).toBe('5'); + expect(unmatched[0].newIdent).toBe(''); + }); + + it('correctly handles sparse DigiScript cues against a full CSV', () => { + // Console has 1, 2, 2.1, 3, 4 — DigiScript only has 1 and 3 + // After renum: 1→1, 2→2, 2.1→3, 3→4, 4→5 — so DigiScript's "3" should become "4" + const csvMapping = makeMap(['1', '2', '2.1', '3', '4']); + const cues = [makeCue(1, '1'), makeCue(2, '3')]; + const { allMatched, changes } = computeRenumber(cues, csvMapping); + + expect(allMatched.map((m) => m.computedIdent)).toEqual(['1', '4']); + expect(changes).toHaveLength(1); + expect(changes[0].oldIdent).toBe('3'); + expect(changes[0].newIdent).toBe('4'); + }); +}); + +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 00000000..eedc9e32 --- /dev/null +++ b/client-v3/src/composables/useCueRenumber.ts @@ -0,0 +1,134 @@ +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})?)(?![\d.])(.*)$/; + +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[]; +} + +/** + * Parses a MagicQ cue stack CSV export and returns a mapping from each + * cue's pre-renum numeric ID (as a float) to its post-renum sequential integer. + * The "Cue id" column is located by scanning the header row. + * Returns an empty Map if the column cannot be found or the text is empty. + */ +export function parseMagicQCsv(csvText: string): Map { + const lines = csvText.split('\n'); + if (lines.length === 0) return new Map(); + + const headerCols = lines[0].split(',').map((c) => c.trim().toLowerCase()); + const cueIdIdx = headerCols.indexOf('cue id'); + if (cueIdIdx === -1) return new Map(); + + const ids: number[] = []; + for (const line of lines.slice(1)) { + const cols = line.split(','); + if (cols.length <= cueIdIdx) continue; + const val = parseFloat(cols[cueIdIdx].trim()); + if (!isNaN(val)) ids.push(val); + } + + ids.sort((a, b) => a - b); + const map = new Map(); + ids.forEach((id, i) => map.set(id, i + 1)); + return map; +} + +/** + * Computes renumber suggestions for a set of cues given a CSV-derived mapping. + * csvMapping maps each pre-renum cue ID (float) to its post-renum sequential integer. + * Cues whose numeric prefix is not present in the mapping go to unmatched with no suggestion. + */ +export function computeRenumber( + cues: CueWithLineId[], + csvMapping: Map +): RenumberResult { + const uniqueCues = [...new Map(cues.map((c) => [c.id, c])).values()]; + + interface ParsedCue { + cue: CueWithLineId; + numericValue: number; + suffix: string; + } + + const withPrefix: ParsedCue[] = []; + const fullyUnmatched: RenumberUnmatched[] = []; + + for (const cue of uniqueCues) { + const ident = cue.ident?.trim() ?? ''; + const match = NUMERIC_PREFIX_REGEX.exec(ident); + if (match) { + withPrefix.push({ cue, numericValue: parseFloat(match[1]), suffix: match[2] }); + } else { + fullyUnmatched.push({ cue, originalIdent: ident, newIdent: '', include: false }); + } + } + + withPrefix.sort((a, b) => a.numericValue - b.numericValue); + + const allMatched: RenumberAllMatched[] = []; + const changes: RenumberChange[] = []; + const prefixUnmatched: RenumberUnmatched[] = []; + + for (const { cue, numericValue, suffix } of withPrefix) { + const newInteger = csvMapping.get(numericValue); + if (newInteger === undefined) { + fullyUnmatched.push({ cue, originalIdent: cue.ident ?? '', newIdent: '', include: false }); + continue; + } + const newIdent = String(newInteger) + suffix; + if (suffix.trim() === '') { + 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: [...prefixUnmatched, ...fullyUnmatched] }; +} + +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 11cc42d3..635d454c 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 00000000..319aae6c --- /dev/null +++ b/client/src/js/cueRenumberUtils.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it } from 'vitest'; +import { + NUMERIC_IDENT_REGEX, + NUMERIC_PREFIX_REGEX, + parseMagicQCsv, + 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, + }; +} + +/** Builds the sequential mapping that MagicQ would produce for a given set of ident strings. */ +function makeMap(idents: string[]): Map { + const floats = idents.map(parseFloat).sort((a, b) => a - b); + const map = new Map(); + floats.forEach((f, i) => map.set(f, i + 1)); + return map; +} + +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('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', '1.111'])('does not match "%s"', (ident) => { + expect(NUMERIC_PREFIX_REGEX.exec(ident)).toBeNull(); + }); +}); + +describe('parseMagicQCsv', () => { + it('builds sequential mapping from Cue id column', () => { + const csv = `Status ,Cue id ,Cue text ,Comment\n* ,1.00 ,0001 ,First\n ,2.10 ,0002 ,Second\n ,3.00 ,0003 ,Third`; + const mapping = parseMagicQCsv(csv); + expect(mapping.get(1)).toBe(1); + expect(mapping.get(2.1)).toBe(2); + expect(mapping.get(3)).toBe(3); + expect(mapping.size).toBe(3); + }); + + it('finds Cue id column by header name regardless of column position', () => { + const csv = `Comment ,Cue id ,Status\nFirst ,1.00 ,*\nSecond ,2.00 ,`; + const mapping = parseMagicQCsv(csv); + expect(mapping.size).toBe(2); + expect(mapping.get(1)).toBe(1); + expect(mapping.get(2)).toBe(2); + }); + + it('assigns sequential integers in sort order, not CSV order', () => { + const csv = `Status ,Cue id ,Comment\n,3.00 ,third\n,1.00 ,first\n,2.00 ,second`; + const mapping = parseMagicQCsv(csv); + expect(mapping.get(1)).toBe(1); + expect(mapping.get(2)).toBe(2); + expect(mapping.get(3)).toBe(3); + }); + + it('trims whitespace from header and values', () => { + const csv = `Status , Cue id , Comment\n* , 1.00 ,comment\n , 2.10 ,comment`; + const mapping = parseMagicQCsv(csv); + expect(mapping.get(1)).toBe(1); + expect(mapping.get(2.1)).toBe(2); + }); + + it('skips rows with non-numeric cue id values', () => { + const csv = `Status ,Cue id ,Comment\n,1.00 ,ok\n,text ,skip\n,2.00 ,ok`; + const mapping = parseMagicQCsv(csv); + expect(mapping.size).toBe(2); + }); + + it('skips rows that are too short', () => { + const csv = `Status ,Cue id ,Comment\n,1.00 ,ok\nshortrow\n,2.00 ,ok`; + const mapping = parseMagicQCsv(csv); + expect(mapping.size).toBe(2); + }); + + it('returns empty map when Cue id column not found', () => { + const csv = `Status ,Cue ,Comment\n,1.00 ,text`; + expect(parseMagicQCsv(csv).size).toBe(0); + }); + + it('returns empty map for empty string', () => { + expect(parseMagicQCsv('').size).toBe(0); + }); + + it('handles the real MagicQ export format', () => { + const csv = `Status ,Cue id ,Cue text ,Wait ,Halt ,Delay ,Fade ,Pos ,Col ,Beam ,Cue ,Next cue ,Timing ,Track ,Block FX ,Cue only ,Macro ,Comment ,Audio ,Media\n* ,1.00 ,0001 ,00/00/05.23 ,Tc , 0.00s , 0.00s ,0.00s ,0.00s ,0.00s ,Q1(L)0001 ,Next ,Cue ,HLF ,No ,No , ,Lights On , ,\n ,2.00 ,0002 ,00/00/06.02 ,Tc , 0.00s , 0.00s ,0.00s ,0.00s ,0.00s ,Q2(L)0002 ,Next ,Cue ,HLF ,No ,No , ,Lights Off , ,`; + const mapping = parseMagicQCsv(csv); + expect(mapping.get(1)).toBe(1); + expect(mapping.get(2)).toBe(2); + }); +}); + +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 csvMapping = makeMap(['2.1', '1', '3', '2', '2.2']); + const { allMatched, changes } = computeRenumber(cues, csvMapping); + + 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 csvMapping = makeMap(['1', '2', '3']); + const { allMatched, changes } = computeRenumber(cues, csvMapping); + + 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, makeMap(['1', '2', '3'])); + 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, makeMap(['1', '2'])); + + 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, makeMap(['1'])); + 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], makeMap(['3', '1'])); + 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, makeMap(['10', '2', '1'])); + 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, makeMap(['2.1', '1'])); + 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, makeMap(['2.1', '1'])); + 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, makeMap(idents)); + + expect(allMatched.map((m) => m.computedIdent)).toEqual([ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + ]); + 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 csvMapping = makeMap(['1', '2', '2.1', '3']); + const cues = [makeCue(1, '1'), makeCue(2, '2'), makeCue(3, '2.1 - Blackout'), makeCue(4, '3')]; + const { allMatched, changes, unmatched } = computeRenumber(cues, csvMapping); + + expect(allMatched).toHaveLength(3); + expect(allMatched.map((m) => m.computedIdent)).toEqual(['1', '2', '4']); + + expect(changes).toHaveLength(1); + 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 in the CSV, shifting later pure cues', () => { + // CSV has 1, 53, 56 — "1 - House" in DigiScript matches CSV cue 1 + const csvMapping = makeMap(['1', '53', '56']); + const cues = [makeCue(1, '1 - House'), makeCue(2, '53'), makeCue(3, '56')]; + const { allMatched, changes, unmatched } = computeRenumber(cues, csvMapping); + + 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, makeMap(['1'])); + + expect(allMatched).toHaveLength(1); + expect(unmatched).toHaveLength(1); + expect(unmatched[0].newIdent).toBe(''); + }); + + it('cue with numeric prefix absent from CSV goes to unmatched with empty newIdent', () => { + // DigiScript has cues 1 and 5, but CSV only has 1, 2, 3 (cue 5 is not in the console export) + const csvMapping = new Map([ + [1, 1], + [2, 2], + [3, 3], + ]); + const cues = [makeCue(1, '1'), makeCue(2, '5')]; + const { allMatched, unmatched } = computeRenumber(cues, csvMapping); + + expect(allMatched).toHaveLength(1); + expect(allMatched[0].computedIdent).toBe('1'); + expect(unmatched).toHaveLength(1); + expect(unmatched[0].originalIdent).toBe('5'); + expect(unmatched[0].newIdent).toBe(''); + }); + + it('correctly handles sparse DigiScript cues against a full CSV', () => { + // Console has 1, 2, 2.1, 3, 4 — DigiScript only has 1 and 3 + // After renum: 1→1, 2→2, 2.1→3, 3→4, 4→5 — so DigiScript's "3" should become "4" + const csvMapping = makeMap(['1', '2', '2.1', '3', '4']); + const cues = [makeCue(1, '1'), makeCue(2, '3')]; + const { allMatched, changes } = computeRenumber(cues, csvMapping); + + expect(allMatched.map((m) => m.computedIdent)).toEqual(['1', '4']); + expect(changes).toHaveLength(1); + expect(changes[0].oldIdent).toBe('3'); + expect(changes[0].newIdent).toBe('4'); + }); +}); + +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 00000000..977212b7 --- /dev/null +++ b/client/src/js/cueRenumberUtils.ts @@ -0,0 +1,133 @@ +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})?)(?![\d.])(.*)$/; + +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[]; +} + +/** + * Parses a MagicQ cue stack CSV export and returns a mapping from each + * cue's pre-renum numeric ID (as a float) to its post-renum sequential integer. + * The "Cue id" column is located by scanning the header row. + * Returns an empty Map if the column cannot be found or the text is empty. + */ +export function parseMagicQCsv(csvText: string): Map { + const lines = csvText.split('\n'); + if (lines.length === 0) return new Map(); + + const headerCols = lines[0].split(',').map((c) => c.trim().toLowerCase()); + const cueIdIdx = headerCols.indexOf('cue id'); + if (cueIdIdx === -1) return new Map(); + + const ids: number[] = []; + for (const line of lines.slice(1)) { + const cols = line.split(','); + if (cols.length <= cueIdIdx) continue; + const val = parseFloat(cols[cueIdIdx].trim()); + if (!isNaN(val)) ids.push(val); + } + + ids.sort((a, b) => a - b); + const map = new Map(); + ids.forEach((id, i) => map.set(id, i + 1)); + return map; +} + +/** + * Computes renumber suggestions for a set of cues given a CSV-derived mapping. + * csvMapping maps each pre-renum cue ID (float) to its post-renum sequential integer. + * Cues whose numeric prefix is not present in the mapping go to unmatched with no suggestion. + */ +export function computeRenumber( + cues: CueWithLineId[], + csvMapping: Map +): RenumberResult { + const uniqueCues = [...new Map(cues.map((c) => [c.id, c])).values()]; + + interface ParsedCue { + cue: CueWithLineId; + numericValue: number; + suffix: string; + } + + const withPrefix: ParsedCue[] = []; + const fullyUnmatched: RenumberUnmatched[] = []; + + for (const cue of uniqueCues) { + const ident = cue.ident?.trim() ?? ''; + const match = NUMERIC_PREFIX_REGEX.exec(ident); + if (match) { + withPrefix.push({ cue, numericValue: parseFloat(match[1]), suffix: match[2] }); + } else { + fullyUnmatched.push({ cue, originalIdent: ident, newIdent: '', include: false }); + } + } + + withPrefix.sort((a, b) => a.numericValue - b.numericValue); + + const allMatched: RenumberAllMatched[] = []; + const changes: RenumberChange[] = []; + const prefixUnmatched: RenumberUnmatched[] = []; + + for (const { cue, numericValue, suffix } of withPrefix) { + const newInteger = csvMapping.get(numericValue); + if (newInteger === undefined) { + fullyUnmatched.push({ cue, originalIdent: cue.ident ?? '', newIdent: '', include: false }); + continue; + } + const newIdent = String(newInteger) + suffix; + if (suffix.trim() === '') { + 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: [...prefixUnmatched, ...fullyUnmatched] }; +} + +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 97cef764..c4830dc6 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 72d10cd0..3dc684a1 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 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)