Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
77e20e6
Add ScriptDraft model and dependencies for collaborative editing
Tim020 Feb 11, 2026
ce06988
Add Alembic migration for script_drafts table
Tim020 Feb 11, 2026
67302a7
Add ScriptLine-to-YDoc conversion and room manager
Tim020 Feb 11, 2026
029ab2a
Add WebSocket handlers for Yjs sync and integrate RoomManager
Tim020 Feb 11, 2026
c211ae7
Add backend tests for collaborative editing (Phase 1)
Tim020 Feb 11, 2026
202949b
Add frontend Yjs infrastructure for collaborative editing
Tim020 Feb 11, 2026
fa51291
Integrate Y.Doc into ScriptEditor for collaborative editing (Phase 2.…
Tim020 Feb 11, 2026
56ff9bb
Fix CURRENT_REVISION null when navigating directly to Script page
Tim020 Feb 11, 2026
35d20e7
Add direct Y.Doc binding to ScriptLinePart (Rework R1/R4)
Tim020 Feb 11, 2026
413b37c
Add direct Y.Doc binding to ScriptLineEditor (Rework R2)
Tim020 Feb 11, 2026
910dab2
Rework ScriptEditor for Y.Doc as source of truth (Rework R3)
Tim020 Feb 11, 2026
7f75131
Add room membership broadcast and line-level awareness (Phase 3.1-3.2)
Tim020 Feb 11, 2026
f51af27
Add CollaboratorPanel and line-level editing indicators (Phase 3.3-3.4)
Tim020 Feb 11, 2026
5ef047e
Fix infinite render loop when editing line after deletion
Tim020 Feb 12, 2026
81ebf72
Merge remote-tracking branch 'origin/dev' into feature/collaborative-…
Tim020 Feb 13, 2026
86480cf
Merge remote-tracking branch 'origin/dev' into feature/collaborative-…
Tim020 Feb 15, 2026
0189538
Merge remote-tracking branch 'origin/dev' into feature/collaborative-…
Tim020 Feb 15, 2026
b79f799
[Collab] Phase 3.5: Multi-user edit mode, RBAC enforcement & room lif…
Tim020 Feb 16, 2026
eb574be
[Collab] Add disabled-reason tooltips to Edit/Cuts buttons
Tim020 Feb 16, 2026
27611f6
Lint and format python code
Tim020 Feb 17, 2026
7357d69
[Collab] Phase 4: Save flow — Y.Doc to ScriptLine persistence
Tim020 Feb 18, 2026
1551f38
[Collab] Phase 4 follow-ups: save progress UI, await fix, HTTP 409
Tim020 Feb 19, 2026
f4bdb50
[Collab] Bugfix: inserted line disappears after save
Tim020 Feb 19, 2026
0cb2fa1
[Collab] Bugfix: false hasDraft after save + stop editing
Tim020 Feb 19, 2026
ea167bd
[Collab] Fix sync-timeout false-positive + add debug logging
Tim020 Feb 19, 2026
c606501
Merge remote-tracking branch 'origin/dev' into feature/collaborative-…
Tim020 Feb 19, 2026
28f0d96
[Collab] Fix test_save_script_draft_success for meta.last_saved_at up…
Tim020 Feb 19, 2026
34b5813
Merge remote-tracking branch 'origin/dev' into feature/collaborative-…
Tim020 Feb 21, 2026
71ad54b
Merge alembic heads
Tim020 Feb 21, 2026
857779b
Merge remote-tracking branch 'origin/dev' into feature/collaborative-…
Tim020 Feb 23, 2026
e87b435
Merge branch 'dev' into feature/collaborative-editing
Tim020 Feb 23, 2026
fcc066e
Fix double log import in main.js
Tim020 Feb 23, 2026
d5164f0
Fix silent YJS_UPDATE failure after page navigation in collab edit mode
Tim020 Feb 25, 2026
2785f50
Fix ruff formatting
Tim020 Feb 25, 2026
04ecbdd
Move helper script into scripts directory and make executable
Tim020 Feb 25, 2026
d24f70f
Fix ruff formatting
Tim020 Feb 25, 2026
79606ae
Fix requirements file
Tim020 Feb 25, 2026
2d4917b
Refactor collab WebSocket handling to standard OP:NOOP + ACTION dispatch
Tim020 Feb 25, 2026
52805af
Add Phase 5 revision lifecycle guards for collaborative editing
Tim020 Feb 25, 2026
0ea45e9
Add Phase 6 guard removal and edge cases for collaborative editing
Tim020 Feb 25, 2026
0942978
Add Phase 6.5 live session guards for collaborative editing
Tim020 Feb 27, 2026
789fb06
Merge remote-tracking branch 'origin/dev' into feature/collaborative-…
Tim020 Feb 27, 2026
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
1 change: 1 addition & 0 deletions .idea/DigiScript-2.iml

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

52 changes: 51 additions & 1 deletion client/package-lock.json

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

4 changes: 3 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"dompurify": "3.3.1",
"fuse.js": "7.1.0",
"jquery": "3.7.1",
"lib0": "^0.2.117",
"lodash": "4.17.23",
"loglevel": "1.9.2",
"marked": "11.2.0",
Expand All @@ -49,7 +50,8 @@
"vue-toast-notification": "0.6.3",
"vuelidate": "0.7.7",
"vuex": "3.6.2",
"vuex-persistedstate": "3.2.1"
"vuex-persistedstate": "3.2.1",
"yjs": "^13.6.29"
},
"devDependencies": {
"@babel/core": "7.29.0",
Expand Down
3 changes: 3 additions & 0 deletions client/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import { getWebSocketURL, isElectron } from '@/js/platform';
import { initRemoteLogging } from '@/js/logger';
import log from 'loglevel';

// Expose loglevel globally so `log.setLevel('debug')` works in DevTools
window.log = log;

import './assets/styles/dark.scss';
import 'vue-toast-notification/dist/theme-sugar.css';
import 'vue-multiselect/dist/vue-multiselect.min.css';
Expand Down
9 changes: 6 additions & 3 deletions client/src/store/modules/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ export default {
context.dispatch('GET_SCRIPT_REVISIONS');
Vue.$toast.success('Added new script revision!');
} else {
const data = await response.json().catch(() => ({}));
log.error('Unable to add new script revision');
Vue.$toast.error('Unable to add new script revision');
Vue.$toast.error(data.message || 'Unable to add new script revision');
}
},
async DELETE_SCRIPT_REVISION(context, revisionID) {
Expand All @@ -91,8 +92,9 @@ export default {
context.dispatch('GET_SCRIPT_REVISIONS');
Vue.$toast.success('Deleted script revision!');
} else {
const data = await response.json().catch(() => ({}));
log.error('Unable to delete script revision');
Vue.$toast.error('Unable to delete script revision');
Vue.$toast.error(data.message || 'Unable to delete script revision');
}
},
async LOAD_SCRIPT_REVISION(context, revisionID) {
Expand All @@ -109,8 +111,9 @@ export default {
context.dispatch('GET_SCRIPT_REVISIONS');
Vue.$toast.success('Loaded script revision!');
} else {
const data = await response.json().catch(() => ({}));
log.error('Unable to load script revision');
Vue.$toast.error('Unable to load script revision');
Vue.$toast.error(data.message || 'Unable to load script revision');
}
},
async SCRIPT_REVISION_CHANGED(context) {
Expand Down
41 changes: 33 additions & 8 deletions client/src/store/modules/scriptConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ export default {
tmpScript: {},
deletedLines: {},
editStatus: {
canRequestEdit: false,
currentEditor: null,
editors: [],
cutters: [],
hasDraft: false,
},
cutMode: false,
insertedLines: {},
Expand Down Expand Up @@ -93,8 +94,9 @@ export default {
},
},
actions: {
REQUEST_EDIT_FAILURE(context) {
Vue.$toast.error('Unable to edit script');
REQUEST_EDIT_FAILURE(context, message) {
const reason = message?.DATA?.reason || 'Unable to edit script';
Vue.$toast.error(reason);
context.dispatch('GET_SCRIPT_CONFIG_STATUS');
context.commit('SET_CUT_MODE', false);
},
Expand Down Expand Up @@ -195,11 +197,34 @@ export default {
ALL_DELETED_LINES(state) {
return state.deletedLines;
},
CAN_REQUEST_EDIT(state) {
return state.editStatus.canRequestEdit;
EDITORS(state) {
return state.editStatus.editors;
},
CURRENT_EDITOR(state) {
return state.editStatus.currentEditor;
CUTTERS(state) {
return state.editStatus.cutters;
},
HAS_DRAFT(state) {
return state.editStatus.hasDraft;
},
CAN_REQUEST_EDIT(state, getters, rootState, rootGetters) {
if (rootGetters.CURRENT_SHOW_SESSION) return false;
return state.editStatus.cutters.length === 0;
},
CAN_REQUEST_CUTS(state, getters, rootState, rootGetters) {
if (rootGetters.CURRENT_SHOW_SESSION) return false;
return (
state.editStatus.editors.length === 0 &&
state.editStatus.cutters.length === 0 &&
!state.editStatus.hasDraft
);
},
IS_CURRENT_EDITOR: (state, getters, rootState, rootGetters) => {
const uuid = rootGetters.INTERNAL_UUID;
return state.editStatus.editors.some((e) => e.internal_id === uuid);
},
IS_CURRENT_CUTTER: (state, getters, rootState, rootGetters) => {
const uuid = rootGetters.INTERNAL_UUID;
return state.editStatus.cutters.some((c) => c.internal_id === uuid);
},
IS_CUT_MODE(state) {
return state.cutMode;
Expand Down
98 changes: 98 additions & 0 deletions client/src/store/modules/scriptConfig.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { vi } from 'vitest';

// Stub external dependencies before importing scriptConfig
vi.mock('vue', () => ({
default: { prototype: {}, set: vi.fn(), delete: vi.fn() },
set: vi.fn(),
delete: vi.fn(),
}));
vi.mock('loglevel', () => ({
default: { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn() },
}));
vi.mock('deep-object-diff', () => ({
detailedDiff: vi.fn(() => ({ added: {}, updated: {}, deleted: {} })),
}));
vi.mock('@/js/utils', () => ({
makeURL: vi.fn((path) => `http://localhost${path}`),
}));

import scriptConfigModule from './scriptConfig';

const { getters } = scriptConfigModule;

function makeState(overrides = {}) {
return {
editStatus: { editors: [], cutters: [], hasDraft: false },
...overrides,
};
}

describe('scriptConfig getters', () => {
describe('CAN_REQUEST_EDIT', () => {
it('returns false when a live session is active (no cutters)', () => {
const state = makeState();
const result = getters.CAN_REQUEST_EDIT(state, {}, {}, { CURRENT_SHOW_SESSION: { id: 1 } });
expect(result).toBe(false);
});

it('returns false when live session is active even if cutters also present', () => {
const state = makeState({
editStatus: { editors: [], cutters: [{ internal_id: 'x' }], hasDraft: false },
});
const result = getters.CAN_REQUEST_EDIT(state, {}, {}, { CURRENT_SHOW_SESSION: { id: 1 } });
expect(result).toBe(false);
});

it('returns true when no live session and no cutters', () => {
const state = makeState();
const result = getters.CAN_REQUEST_EDIT(state, {}, {}, { CURRENT_SHOW_SESSION: null });
expect(result).toBe(true);
});

it('returns false when no live session but cutters exist', () => {
const state = makeState({
editStatus: { editors: [], cutters: [{ internal_id: 'x' }], hasDraft: false },
});
const result = getters.CAN_REQUEST_EDIT(state, {}, {}, { CURRENT_SHOW_SESSION: null });
expect(result).toBe(false);
});
});

describe('CAN_REQUEST_CUTS', () => {
it('returns false when a live session is active (all else clear)', () => {
const state = makeState();
const result = getters.CAN_REQUEST_CUTS(state, {}, {}, { CURRENT_SHOW_SESSION: { id: 1 } });
expect(result).toBe(false);
});

it('returns true when no live session and all else clear', () => {
const state = makeState();
const result = getters.CAN_REQUEST_CUTS(state, {}, {}, { CURRENT_SHOW_SESSION: null });
expect(result).toBe(true);
});

it('returns false when no live session but an editor exists', () => {
const state = makeState({
editStatus: { editors: [{ internal_id: 'y' }], cutters: [], hasDraft: false },
});
const result = getters.CAN_REQUEST_CUTS(state, {}, {}, { CURRENT_SHOW_SESSION: null });
expect(result).toBe(false);
});

it('returns false when no live session but a cutter exists', () => {
const state = makeState({
editStatus: { editors: [], cutters: [{ internal_id: 'z' }], hasDraft: false },
});
const result = getters.CAN_REQUEST_CUTS(state, {}, {}, { CURRENT_SHOW_SESSION: null });
expect(result).toBe(false);
});

it('returns false when no live session but a draft exists', () => {
const state = makeState({
editStatus: { editors: [], cutters: [], hasDraft: true },
});
const result = getters.CAN_REQUEST_CUTS(state, {}, {}, { CURRENT_SHOW_SESSION: null });
expect(result).toBe(false);
});
});
});
Loading
Loading