Skip to content

Commit b64f049

Browse files
authored
Release 0.20.0
Merge pull request #764 from dreamteamprod/dev
2 parents 8c6eb2a + 3e2afe7 commit b64f049

File tree

14 files changed

+785
-66
lines changed

14 files changed

+785
-66
lines changed

client/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "client",
3-
"version": "0.19.1",
3+
"version": "0.20.0",
44
"private": true,
55
"scripts": {
66
"build": "vite build",

client/src/views/show/ShowLiveView.vue

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,12 +276,12 @@ export default {
276276
return {
277277
elapsedTime: 0,
278278
elapsedTimer: null,
279-
currentLoadedPage: 0,
280-
currentMaxPage: 0,
279+
currentLoadedPage: 0, // Last loaded page number (1-based)
280+
currentMaxPage: 0, // Total number of pages in script (1-based)
281281
stoppingSession: false,
282282
initialLoad: false,
283-
currentFirstPage: 1,
284-
currentLastPage: 1,
283+
currentFirstPage: 1, // First visible page (1-based, initialized to page 1)
284+
currentLastPage: 1, // Last visible page (1-based, initialized to page 1)
285285
pageBatchSize: 3,
286286
startTime: null,
287287
previousFirstPage: 1,
@@ -328,7 +328,8 @@ export default {
328328
},
329329
computed: {
330330
pageIter() {
331-
return [...Array(this.currentMaxPage).keys()];
331+
// Generate 1-based page indices (DigiScript pages are numbered starting from 1, not 0)
332+
return [...Array(this.currentMaxPage).keys()].map((i) => i + 1);
332333
},
333334
isScriptFollowing() {
334335
if (this.loadedSessionData) {

client/src/vue_components/show/config/script/ScriptEditor.vue

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
v-if="editPages.includes(`page_${currentEditPage}_line_${index}`)"
101101
:key="`page_${currentEditPage}_line_${index}`"
102102
:line-index="index"
103+
:current-edit-page="currentEditPage"
103104
:acts="ACT_LIST"
104105
:scenes="SCENE_LIST"
105106
:characters="CHARACTER_LIST"
@@ -587,9 +588,14 @@ export default {
587588
}
588589
},
589590
async getPreviousLineForIndex(lineIndex) {
590-
if (lineIndex > 0) {
591-
return this.TMP_SCRIPT[this.currentEditPage][lineIndex - 1];
591+
// Search backwards from lineIndex - 1 on the current page, skipping deleted lines
592+
for (let i = lineIndex - 1; i >= 0; i--) {
593+
if (!this.DELETED_LINES(this.currentEditPage).includes(i)) {
594+
return this.TMP_SCRIPT[this.currentEditPage][i];
595+
}
592596
}
597+
598+
// No non-deleted lines before this index on current page, check previous pages
593599
if (this.currentEditPage > 1) {
594600
let loopPageNo = this.currentEditPage - 1;
595601
/* eslint-disable no-await-in-loop */
@@ -601,8 +607,12 @@ export default {
601607
await this.LOAD_SCRIPT_PAGE(loopPageNo);
602608
loopPage = this.GET_SCRIPT_PAGE(loopPageNo);
603609
}
604-
if (loopPage.length > 0) {
605-
return loopPage[loopPage.length - 1];
610+
// Find the last non-deleted line on this page
611+
const deletedLines = this.DELETED_LINES(loopPageNo);
612+
for (let i = loopPage.length - 1; i >= 0; i--) {
613+
if (!deletedLines.includes(i)) {
614+
return loopPage[i];
615+
}
606616
}
607617
loopPageNo -= 1;
608618
}
@@ -611,31 +621,43 @@ export default {
611621
return null;
612622
},
613623
async getNextLineForIndex(lineIndex) {
614-
// If there are lines after this one on the page, return the next line from the page
615-
if (lineIndex < this.TMP_SCRIPT[this.currentEditPage].length - 1) {
616-
return this.TMP_SCRIPT[this.currentEditPage][lineIndex + 1];
624+
// Search forwards from lineIndex + 1 on the current page, skipping deleted lines
625+
const currentPageLines = this.TMP_SCRIPT[this.currentEditPage];
626+
const deletedLines = this.DELETED_LINES(this.currentEditPage);
627+
for (let i = lineIndex + 1; i < currentPageLines.length; i++) {
628+
if (!deletedLines.includes(i)) {
629+
return currentPageLines[i];
630+
}
617631
}
618-
// See if there are any edit pages loaded which are after this page, and if so, return the
619-
// first line from the first page which contains lines
632+
633+
// No non-deleted lines after this index on current page, check next pages
634+
// See if there are any edit pages loaded which are after this page
620635
const editPages = Object.keys(this.TMP_SCRIPT).map((x) => parseInt(x, 10)).sort();
621636
for (let i = 0; i < editPages.length; i++) {
622637
const editPage = editPages[i];
623-
if (editPage <= this.currentEditPage) {
624-
break;
625-
}
626-
const pageContent = this.TMP_SCRIPT[editPage.toString()];
627-
if (pageContent.length > 0) {
628-
return pageContent[0];
638+
if (editPage > this.currentEditPage) {
639+
const pageContent = this.TMP_SCRIPT[editPage.toString()];
640+
const pageDeletedLines = this.DELETED_LINES(editPage);
641+
// Find the first non-deleted line on this page
642+
for (let j = 0; j < pageContent.length; j++) {
643+
if (!pageDeletedLines.includes(j)) {
644+
return pageContent[j];
645+
}
646+
}
629647
}
630648
}
631-
// Edit pages do not have any lines we can use, so try loading script pages up to the max
632-
// page that is saved
649+
650+
// Edit pages do not have any non-deleted lines, try loading script pages up to the max
633651
/* eslint-disable no-await-in-loop */
634652
for (let i = this.currentEditPage + 1; i <= this.currentMaxPage; i++) {
635653
await this.LOAD_SCRIPT_PAGE(i);
636654
const loopPage = this.GET_SCRIPT_PAGE(i);
637-
if (loopPage.length > 0) {
638-
return loopPage[0];
655+
const loopPageDeletedLines = this.DELETED_LINES(i);
656+
// Find the first non-deleted line on this page
657+
for (let j = 0; j < loopPage.length; j++) {
658+
if (!loopPageDeletedLines.includes(j)) {
659+
return loopPage[j];
660+
}
639661
}
640662
}
641663
/* eslint-enable no-await-in-loop */

client/src/vue_components/show/config/script/ScriptLineEditor.vue

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ export default {
117117
required: true,
118118
type: Number,
119119
},
120+
currentEditPage: {
121+
required: true,
122+
type: Number,
123+
},
120124
acts: {
121125
required: true,
122126
type: Array,
@@ -167,6 +171,8 @@ export default {
167171
},
168172
previousLine: null,
169173
nextLine: null,
174+
recalculationTimeout: null,
175+
abortController: null,
170176
};
171177
},
172178
validations: {
@@ -205,7 +211,13 @@ export default {
205211
},
206212
},
207213
computed: {
208-
...mapGetters(['SCENE_BY_ID', 'ACT_BY_ID']),
214+
...mapGetters(['SCENE_BY_ID', 'ACT_BY_ID', 'TMP_SCRIPT', 'ALL_DELETED_LINES']),
215+
currentPageScript() {
216+
return this.TMP_SCRIPT[this.currentEditPage.toString()] || [];
217+
},
218+
currentPageDeletedLines() {
219+
return this.ALL_DELETED_LINES[this.currentEditPage.toString()] || [];
220+
},
209221
nextActs() {
210222
// Start act is either the first act for the show, or the act of the previous line if there
211223
// is one
@@ -273,6 +285,29 @@ export default {
273285
];
274286
},
275287
},
288+
watch: {
289+
currentPageScript: {
290+
handler() {
291+
this.scheduleRecalculation();
292+
},
293+
deep: true,
294+
},
295+
currentPageDeletedLines: {
296+
handler() {
297+
this.scheduleRecalculation();
298+
},
299+
deep: true,
300+
},
301+
lineIndex() {
302+
this.scheduleRecalculation();
303+
},
304+
ALL_DELETED_LINES: {
305+
handler() {
306+
this.scheduleRecalculation();
307+
},
308+
deep: true,
309+
},
310+
},
276311
async created() {
277312
this.previousLine = await this.previousLineFn(this.lineIndex);
278313
this.nextLine = await this.nextLineFn(this.lineIndex);
@@ -283,7 +318,51 @@ export default {
283318
mounted() {
284319
this.$v.state.$touch();
285320
},
321+
beforeDestroy() {
322+
if (this.recalculationTimeout) {
323+
clearTimeout(this.recalculationTimeout);
324+
}
325+
if (this.abortController) {
326+
this.abortController.abort();
327+
}
328+
},
286329
methods: {
330+
scheduleRecalculation() {
331+
// Cancel any pending recalculation
332+
if (this.recalculationTimeout) {
333+
clearTimeout(this.recalculationTimeout);
334+
}
335+
336+
// Debounce recalculation by 100ms
337+
this.recalculationTimeout = setTimeout(() => {
338+
this.recalculatePreviousNextLines();
339+
}, 100);
340+
},
341+
async recalculatePreviousNextLines() {
342+
// Cancel any in-flight async operations
343+
if (this.abortController) {
344+
this.abortController.abort();
345+
}
346+
347+
// Create new abort controller for this operation
348+
this.abortController = new AbortController();
349+
const { signal } = this.abortController;
350+
351+
try {
352+
const prevLine = await this.previousLineFn(this.lineIndex);
353+
if (signal.aborted) return;
354+
this.previousLine = prevLine;
355+
356+
const nxtLine = await this.nextLineFn(this.lineIndex);
357+
if (signal.aborted) return;
358+
this.nextLine = nxtLine;
359+
} catch (error) {
360+
if (error.name !== 'AbortError') {
361+
// eslint-disable-next-line no-console
362+
console.error('Error recalculating previous/next lines:', error);
363+
}
364+
}
365+
},
287366
validateState(name) {
288367
const { $dirty, $error } = this.$v.state[name];
289368
return $dirty ? !$error : null;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""cleanup_orphaned_script_objects
2+
3+
Revision ID: 42d0eaa5d07e
4+
Revises: e1a2b3c4d5e6
5+
Create Date: 2025-12-12 09:54:39.096643
6+
7+
This migration cleans up orphaned script objects that were left behind due to
8+
bug #768. Orphaned objects include:
9+
- ScriptLine objects with no FK references
10+
- ScriptLinePart objects (cascade deleted with ScriptLine)
11+
- ScriptCuts objects (cascade deleted with ScriptLinePart)
12+
- Cue objects with no FK references
13+
14+
"""
15+
16+
from typing import Sequence, Union
17+
18+
from alembic import op
19+
import sqlalchemy as sa
20+
21+
22+
# revision identifiers, used by Alembic.
23+
revision: str = "42d0eaa5d07e"
24+
down_revision: Union[str, None] = "e1a2b3c4d5e6"
25+
branch_labels: Union[str, Sequence[str], None] = None
26+
depends_on: Union[str, Sequence[str], None] = None
27+
28+
29+
def upgrade() -> None:
30+
# Get database connection
31+
conn = op.get_bind()
32+
33+
# 1. Find and delete orphaned Cue objects
34+
# Cues are orphaned if they have no references in script_cue_association
35+
orphaned_cues = conn.execute(
36+
sa.text("""
37+
SELECT c.id
38+
FROM cue c
39+
LEFT JOIN script_cue_association sca ON c.id = sca.cue_id
40+
WHERE sca.cue_id IS NULL
41+
""")
42+
).fetchall()
43+
44+
if orphaned_cues:
45+
orphaned_cue_ids = [row[0] for row in orphaned_cues]
46+
print(f"Found {len(orphaned_cue_ids)} orphaned cue(s): {orphaned_cue_ids}")
47+
for cue_id in orphaned_cue_ids:
48+
conn.execute(sa.text("DELETE FROM cue WHERE id = :id"), {"id": cue_id})
49+
50+
# 2. Find and delete orphaned ScriptLine objects
51+
# Lines are orphaned if they have no references in:
52+
# - script_line_revision_association.line_id
53+
# - script_line_revision_association.next_line_id
54+
# - script_line_revision_association.previous_line_id
55+
# - script_cue_association.line_id
56+
orphaned_lines = conn.execute(
57+
sa.text("""
58+
SELECT sl.id
59+
FROM script_lines sl
60+
LEFT JOIN script_line_revision_association slra_line ON sl.id = slra_line.line_id
61+
LEFT JOIN script_line_revision_association slra_next ON sl.id = slra_next.next_line_id
62+
LEFT JOIN script_line_revision_association slra_prev ON sl.id = slra_prev.previous_line_id
63+
LEFT JOIN script_cue_association sca ON sl.id = sca.line_id
64+
WHERE slra_line.line_id IS NULL
65+
AND slra_next.next_line_id IS NULL
66+
AND slra_prev.previous_line_id IS NULL
67+
AND sca.line_id IS NULL
68+
""")
69+
).fetchall()
70+
71+
if orphaned_lines:
72+
orphaned_line_ids = [row[0] for row in orphaned_lines]
73+
print(
74+
f"Found {len(orphaned_line_ids)} orphaned script_line(s): {orphaned_line_ids}"
75+
)
76+
# ScriptLinePart and ScriptCuts will cascade delete via SQLAlchemy relationships
77+
for line_id in orphaned_line_ids:
78+
conn.execute(
79+
sa.text("DELETE FROM script_lines WHERE id = :id"), {"id": line_id}
80+
)
81+
82+
83+
def downgrade() -> None:
84+
# This migration only deletes orphaned data, so there's no meaningful downgrade
85+
# The orphaned data is already gone from the database by definition
86+
pass

server/controllers/api/show/cast.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ async def delete(self):
160160

161161

162162
@ApiRoute("show/cast/stats", ApiVersion.V1)
163-
class CharacterStatsController(BaseAPIController):
163+
class CastStatsController(BaseAPIController):
164164
async def get(self):
165165
current_show = self.get_current_show()
166166
show_id = current_show["id"]

server/controllers/api/show/script/script.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,31 @@ async def patch(self):
563563
)
564564
prev_association.next_line = None
565565
session.flush()
566+
567+
# Store line_id before deleting association for orphan cleanup
568+
line_id_to_cleanup = curr_association.line_id
569+
570+
# Delete any cue associations for this (revision, line) before deleting the line association
571+
# CueAssociation is revision-scoped, so we need to explicitly clean it up
572+
cue_assocs = session.scalars(
573+
select(CueAssociation).where(
574+
CueAssociation.revision_id == revision.id,
575+
CueAssociation.line_id == line_id_to_cleanup,
576+
)
577+
).all()
578+
cue_ids_to_cleanup = [ca.cue_id for ca in cue_assocs]
579+
for cue_assoc in cue_assocs:
580+
session.delete(cue_assoc)
581+
566582
session.delete(curr_association)
583+
584+
# Explicitly cleanup orphaned objects after deletion
585+
# This ensures immediate cleanup in PATCH operations
586+
ScriptLineRevisionAssociation.cleanup_orphaned_line(
587+
session, line_id_to_cleanup
588+
)
589+
for cue_id in cue_ids_to_cleanup:
590+
CueAssociation.cleanup_orphaned_cue(session, cue_id)
567591
elif index in status["updated"]:
568592
# Validate the line
569593
valid_status, valid_reason = self._validate_line(line)

0 commit comments

Comments
 (0)