Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "client",
"version": "0.27.3",
"version": "0.28.0",
"description": "DigiScript front end",
"author": "DreamTeamProd",
"private": true,
Expand Down
63 changes: 40 additions & 23 deletions client/src/store/modules/scriptConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,40 @@

import { makeURL } from '@/js/utils';

/**
* Computes the page status object (added/updated/deleted/inserted) to send to the PATCH endpoint.
*
* deepDiff.added fires on a line index whenever *any* nested property is new — including a new
* element in line_parts. Only lines with id == null are truly new; lines with an existing id that
* have nested additions must be treated as updates instead.
*
* :param actualScriptPage: The current saved page from the store (will be cloned internally).
* :param tmpScriptPage: The edited in-progress page.
* :param deletedLines: Array of line indices marked for deletion.
* :param insertedLines: Array of line indices that were inserted mid-page.
* :returns: { added, updated, deleted, inserted }
*/
export function computePageStatus(actualScriptPage, tmpScriptPage, deletedLines, insertedLines) {
const augmented = JSON.parse(JSON.stringify(actualScriptPage));

Check warning on line 21 in client/src/store/modules/scriptConfig.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `structuredClone(…)` over `JSON.parse(JSON.stringify(…))` to create a deep clone.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AZ4JRDhtHzl-VevYzhnG&open=AZ4JRDhtHzl-VevYzhnG&pullRequest=1002
JSON.parse(JSON.stringify(insertedLines))

Check warning on line 22 in client/src/store/modules/scriptConfig.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `structuredClone(…)` over `JSON.parse(JSON.stringify(…))` to create a deep clone.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AZ4JRDhtHzl-VevYzhnH&open=AZ4JRDhtHzl-VevYzhnH&pullRequest=1002
.sort((a, b) => a - b)
.forEach((lineIndex) => {
augmented.splice(lineIndex, 0, JSON.parse(JSON.stringify(tmpScriptPage[lineIndex])));

Check warning on line 25 in client/src/store/modules/scriptConfig.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `structuredClone(…)` over `JSON.parse(JSON.stringify(…))` to create a deep clone.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AZ4JRDhtHzl-VevYzhnI&open=AZ4JRDhtHzl-VevYzhnI&pullRequest=1002
});

const deepDiff = detailedDiff(augmented, tmpScriptPage);
const addedIndices = Object.keys(deepDiff.added).map((x) => parseInt(x, 10));

Check warning on line 29 in client/src/store/modules/scriptConfig.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AZ4JRDhtHzl-VevYzhnJ&open=AZ4JRDhtHzl-VevYzhnJ&pullRequest=1002
return {
added: addedIndices.filter((idx) => tmpScriptPage[idx]?.id == null),
updated: [
...Object.keys(deepDiff.updated).map((x) => parseInt(x, 10)),

Check warning on line 33 in client/src/store/modules/scriptConfig.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AZ4JRDhtHzl-VevYzhnK&open=AZ4JRDhtHzl-VevYzhnK&pullRequest=1002
...addedIndices.filter((idx) => tmpScriptPage[idx]?.id != null),
],
deleted: [...deletedLines],
inserted: [...insertedLines],
};
}

export default {
state: {
tmpScript: {},
Expand Down Expand Up @@ -137,30 +171,13 @@
return true;
},
async SAVE_CHANGED_PAGE(context, pageNo) {
let actualScriptPage = context.getters.GET_SCRIPT_PAGE(pageNo);
const tmpScriptPage = context.getters.TMP_SCRIPT[pageNo.toString()];

// Need to augment the actual script page to include the inserted pages, this is a hack,
// but it will allow all the other pages to show as not edited if the really haven't been
// changed
actualScriptPage = JSON.parse(JSON.stringify(actualScriptPage));
JSON.parse(JSON.stringify(context.getters.INSERTED_LINES(pageNo)))
.sort((a, b) => a - b)
.forEach((lineIndex) => {
actualScriptPage.splice(
lineIndex,
0,
JSON.parse(JSON.stringify(tmpScriptPage[lineIndex]))
);
});

const deepDiff = detailedDiff(actualScriptPage, tmpScriptPage);
const pageStatus = {
added: Object.keys(deepDiff.added).map((x) => parseInt(x, 10)),
updated: Object.keys(deepDiff.updated).map((x) => parseInt(x, 10)),
deleted: [...context.getters.DELETED_LINES(pageNo)],
inserted: [...context.getters.INSERTED_LINES(pageNo)],
};
const pageStatus = computePageStatus(
context.getters.GET_SCRIPT_PAGE(pageNo),
tmpScriptPage,
context.getters.DELETED_LINES(pageNo),
context.getters.INSERTED_LINES(pageNo)
);
const searchParams = new URLSearchParams({
page: pageNo,
});
Expand Down
103 changes: 103 additions & 0 deletions client/src/store/modules/scriptConfig.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { computePageStatus } from './scriptConfig';

function makeLine(id, numParts) {
return {
id,
act_id: 1,
scene_id: 1,
page: 1,
line_type: 1,
stage_direction_style_id: null,
line_parts: Array.from({ length: numParts }, (_, i) => ({
id: id != null ? 100 + i : null,

Check warning on line 13 in client/src/store/modules/scriptConfig.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AZ4JRDh3Hzl-VevYzhnL&open=AZ4JRDh3Hzl-VevYzhnL&pullRequest=1002
line_id: id,
part_index: i,
character_id: 1,
character_group_id: null,
line_text: `Part ${i}`,
})),
};
}

describe('computePageStatus', () => {
it('classifies an existing line whose line_parts grew as updated, not added', () => {
// deep-object-diff marks index 0 as "added" when a new line_part appears on an existing line.
// The fix: if the line has an id it must go to updated instead.
const saved = [makeLine(42, 1)];
const edited = [makeLine(42, 2)];

const status = computePageStatus(saved, edited, [], []);

expect(status.added).not.toContain(0);
expect(status.updated).toContain(0);
expect(status.deleted).toHaveLength(0);
expect(status.inserted).toHaveLength(0);
});

it('classifies a genuinely new line (id == null) as added', () => {
const saved = [];
const edited = [makeLine(null, 1)];

const status = computePageStatus(saved, edited, [], []);

expect(status.added).toContain(0);
expect(status.updated).not.toContain(0);
});

it('classifies an existing line with a changed top-level field as updated', () => {
const saved = [makeLine(42, 1)];
const edited = [{ ...makeLine(42, 1), line_type: 2 }];

const status = computePageStatus(saved, edited, [], []);

expect(status.updated).toContain(0);
expect(status.added).not.toContain(0);
});

it('passes deleted line indices through to the deleted array', () => {
const saved = [makeLine(10, 1), makeLine(11, 1)];
const edited = [makeLine(10, 1), makeLine(11, 1)];

const status = computePageStatus(saved, edited, [1], []);

expect(status.deleted).toContain(1);
expect(status.added).toHaveLength(0);
expect(status.updated).toHaveLength(0);
});

it('passes inserted line indices through to the inserted array', () => {
const saved = [makeLine(10, 1)];
const newLine = makeLine(null, 1);
// Inserted at index 1: augmented actual includes newLine at index 1 so diff sees no change
const edited = [makeLine(10, 1), newLine];

const status = computePageStatus(saved, edited, [], [1]);

expect(status.inserted).toContain(1);
});

it('handles a mixed page: one truly-new line and one existing line whose parts grew', () => {
const saved = [makeLine(42, 1)];
// Index 0: existing line with extra part; index 1: brand-new line
const edited = [makeLine(42, 2), makeLine(null, 1)];

const status = computePageStatus(saved, edited, [], []);

expect(status.updated).toContain(0);
expect(status.added).not.toContain(0);
expect(status.added).toContain(1);
expect(status.updated).not.toContain(1);
});

it('returns all empty arrays when actual and tmp pages are identical', () => {
const page = [makeLine(42, 2), makeLine(43, 1)];

const status = computePageStatus(page, JSON.parse(JSON.stringify(page)), [], []);

Check warning on line 96 in client/src/store/modules/scriptConfig.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `structuredClone(…)` over `JSON.parse(JSON.stringify(…))` to create a deep clone.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AZ4JRDh3Hzl-VevYzhnM&open=AZ4JRDh3Hzl-VevYzhnM&pullRequest=1002

expect(status.added).toHaveLength(0);
expect(status.updated).toHaveLength(0);
expect(status.deleted).toHaveLength(0);
expect(status.inserted).toHaveLength(0);
});
});
4 changes: 2 additions & 2 deletions electron/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion electron/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "digiscript-electron",
"version": "0.27.3",
"version": "0.28.0",
"description": "DigiScript Electron Desktop Application",
"author": "DreamTeamProd",
"license": "GPL-3.0",
Expand Down
165 changes: 165 additions & 0 deletions server/alembic_config/repair_linked_list_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Shared SQL helpers for ScriptLineRevisionAssociation repair migrations.

Both c2f8d4a6e0b3 and 897ae2963f6d use these primitives; they differ only in
how they select the bridge (first) line when multiple candidates exist for a page.
"""

from collections import defaultdict
from typing import Callable

import sqlalchemy as sa


def fetch_page_associations(conn, revision_id: int) -> dict:
"""Return ``{page: {line_id: {next, prev, prev_page}}}`` for one revision."""
rows = conn.execute(
sa.text(
"""
SELECT a.line_id, a.next_line_id, a.previous_line_id,
l.page AS line_page,
lp.page AS prev_page
FROM script_line_revision_association a
JOIN script_lines l ON l.id = a.line_id
LEFT JOIN script_lines lp ON lp.id = a.previous_line_id
WHERE a.revision_id = :rev_id
"""
),
{"rev_id": revision_id},
).fetchall()

by_page: dict = defaultdict(dict)
for row in rows:
line_id, next_id, prev_id, page, prev_page = row
by_page[page if page is not None else 0][line_id] = {
"next": next_id,
"prev": prev_id,
"prev_page": prev_page,
}
return by_page


def build_page_ordered(by_page: dict, pick_bridge: Callable) -> dict:
"""Build ``{page: [line_id, ...]}`` using bridge detection.

:param by_page: Output of :func:`fetch_page_associations`.
:param pick_bridge: ``(candidates: list[int]) -> int`` — selects the bridge
(first) line from a non-empty list of candidates. Pass ``max`` to use
the highest line_id (correct for the post-corruption case) or
``lambda c: c[0]`` to replicate the original first-found behaviour.
"""
result = {}
for page in sorted(by_page.keys()):
page_lines = by_page[page]
candidates = [
lid
for lid, d in page_lines.items()
if d["prev"] is None
or (d["prev_page"] is not None and d["prev_page"] == page - 1)
]
if not candidates:
result[page] = []
continue

first_line_id = pick_bridge(candidates)
ordered = []
current = first_line_id
seen: set = set()
while current is not None and current in page_lines:
if current in seen:
break
seen.add(current)
ordered.append(current)
current = page_lines[current]["next"]
result[page] = ordered
return result


def build_global_order(page_ordered: dict) -> list:
"""Flatten ``{page: [line_id, ...]}`` into a single ordered list."""
global_order = []
for page in sorted(page_ordered.keys()):
global_order.extend(page_ordered[page])
return global_order


def compute_expected_pointers(global_order: list) -> dict:
"""Return ``{line_id: (expected_next, expected_prev)}`` for the full chain."""
return {
line_id: (
global_order[i + 1] if i + 1 < len(global_order) else None,
global_order[i - 1] if i > 0 else None,
)
for i, line_id in enumerate(global_order)
}


def fetch_current_pointers(conn, revision_id: int) -> dict:
"""Return ``{line_id: (next_line_id, previous_line_id)}`` from the DB."""
rows = conn.execute(
sa.text(
"SELECT line_id, next_line_id, previous_line_id "
"FROM script_line_revision_association WHERE revision_id = :rev_id"
),
{"rev_id": revision_id},
).fetchall()
return {row[0]: (row[1], row[2]) for row in rows}


def collect_pointer_updates(
expected: dict, current_by_id: dict, revision_id: int
) -> list:
"""Return list of ``(new_next, new_prev, revision_id, line_id)`` tuples that need updating."""
updates = []
for line_id, (exp_next, exp_prev) in expected.items():
curr_next, curr_prev = current_by_id.get(line_id, (None, None))
if curr_next != exp_next or curr_prev != exp_prev:
updates.append((exp_next, exp_prev, revision_id, line_id))
return updates


def delete_orphan_associations(conn, revision_id: int, orphan_ids) -> None:
"""Delete cue and revision associations for orphaned lines (FK order)."""
for orphan_id in sorted(orphan_ids):
conn.execute(
sa.text(
"DELETE FROM script_cue_association "
"WHERE revision_id = :rev_id AND line_id = :line_id"
),
{"rev_id": revision_id, "line_id": orphan_id},
)
for orphan_id in sorted(orphan_ids):
conn.execute(
sa.text(
"DELETE FROM script_line_revision_association "
"WHERE revision_id = :rev_id AND line_id = :line_id"
),
{"rev_id": revision_id, "line_id": orphan_id},
)


def apply_pointer_updates(conn, updates: list) -> None:
"""Execute UPDATE statements for all pointer corrections."""
for new_next, new_prev, rev_id, line_id in updates:
conn.execute(
sa.text(
"UPDATE script_line_revision_association "
"SET next_line_id = :next_id, previous_line_id = :prev_id "
"WHERE revision_id = :rev_id AND line_id = :line_id"
),
{
"next_id": new_next,
"prev_id": new_prev,
"rev_id": rev_id,
"line_id": line_id,
},
)


def fetch_all_revision_ids(conn) -> list:
"""Return all script revision IDs in ascending order."""
return [
row[0]
for row in conn.execute(
sa.text("SELECT id FROM script_revisions ORDER BY id")
).fetchall()
]
Loading
Loading