diff --git a/client-v3/e2e/tests/03-system-config.spec.ts b/client-v3/e2e/tests/03-system-config.spec.ts index 85dad177f..66b8cf2dd 100644 --- a/client-v3/e2e/tests/03-system-config.spec.ts +++ b/client-v3/e2e/tests/03-system-config.spec.ts @@ -235,6 +235,12 @@ test('switches to the System tab', async () => { }); }); +test('System tab shows server info rows', async () => { + await expect(page.locator('strong:has-text("Hostname")')).toBeVisible(); + await expect(page.locator('strong:has-text("IP Address")')).toBeVisible(); + await expect(page.locator('strong:has-text("Port")')).toBeVisible(); +}); + test('can open the View Clients modal', async () => { await page.click('button:has-text("View Clients")'); await waitForModal(page, 'Connected Clients'); diff --git a/client-v3/e2e/tests/06-show-config-characters.spec.ts b/client-v3/e2e/tests/06-show-config-characters.spec.ts index 94978a157..234271b17 100644 --- a/client-v3/e2e/tests/06-show-config-characters.spec.ts +++ b/client-v3/e2e/tests/06-show-config-characters.spec.ts @@ -8,6 +8,7 @@ import { waitForAppReady, waitForModal, confirmModal, + cancelModal, waitForModalClosed, confirmDialog, } from '../helpers.js'; @@ -82,6 +83,56 @@ test('deletes a character', async () => { }); }); +// ── Character Merge ──────────────────────────────────────────────────────── + +test('Merge button is visible for each character row', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Hamlet")') }); + await expect(row.locator('button:has-text("Merge")')).toBeVisible(); +}); + +test('creates a character for merge testing', async () => { + await page.getByRole('button', { name: 'New Character', exact: true }).click(); + await waitForModal(page, 'New Character'); + await page.fill('.modal.show input[type="text"]', 'Horatio'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Horatio")').first()).toBeVisible(); +}); + +test('merge modal opens with correct title', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Horatio")') }); + await row.locator('button:has-text("Merge")').click(); + await waitForModal(page, /Merge Horatio/); + await cancelModal(page); + await waitForModalClosed(page); +}); + +test('merge OK button is disabled with no destination selected', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Horatio")') }); + await row.locator('button:has-text("Merge")').click(); + await waitForModal(page, /Merge Horatio/); + const okBtn = page.locator('.modal.show .modal-footer button.btn-primary'); + await expect(okBtn).toBeDisabled(); + await cancelModal(page); + await waitForModalClosed(page); +}); + +test('merges a character into another', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Horatio")') }); + await row.locator('button:has-text("Merge")').click(); + await waitForModal(page, /Merge Horatio/); + // Open the dropdown by clicking the multiselect container, then select the option. + await page.locator('.modal.show .multiselect').click(); + await page.locator('.modal.show .multiselect__option', { hasText: 'Hamlet' }).click(); + await confirmModal(page); + await waitForModalClosed(page); + // Scope to the character table to avoid matching cells in the Line Counts tab (always in DOM). + await expect(page.locator('#character-table td:has-text("Horatio")')).not.toBeVisible({ + timeout: 5_000, + }); + await expect(page.locator('#character-table td:has-text("Hamlet")')).toBeVisible(); +}); + // ── Character Groups ────────────────────────────────────────────────────── test('switches to Character Groups sub-tab', async () => { diff --git a/client-v3/package-lock.json b/client-v3/package-lock.json index 6b12e0835..030949b04 100644 --- a/client-v3/package-lock.json +++ b/client-v3/package-lock.json @@ -1,17 +1,17 @@ { "name": "client-v3", - "version": "0.30.5", + "version": "0.30.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client-v3", - "version": "0.30.5", + "version": "0.30.6", "dependencies": { "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", "bootstrap": "^5.3.8", - "bootstrap-vue-next": "^0.45.4", + "bootstrap-vue-next": "^0.45.5", "bootswatch": "^5.3.8", "contrast-color": "1.0.1", "core-js": "^3.49.0", @@ -19,11 +19,11 @@ "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "deep-object-diff": "1.1.9", - "dompurify": "^3.4.7", + "dompurify": "^3.4.8", "fuse.js": "^7.4.1", "lodash": "^4.18.1", "loglevel": "^1.9.2", - "marked": "^18.0.4", + "marked": "^18.0.5", "pinia": "^3.0.4", "pinia-plugin-persistedstate": "^4.7.1", "splitpanes": "^4.1.2", @@ -44,11 +44,11 @@ "@typescript-eslint/parser": "^8.60.1", "@vitejs/plugin-vue": "^6.0.7", "@vitest/ui": "^4.1.8", - "@vue/test-utils": "^2.4.10", + "@vue/test-utils": "^2.4.11", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", - "eslint-plugin-vue": "^10.9.1", + "eslint-plugin-vue": "^10.9.2", "globals": "^17.6.0", "jiti": "^2.7.0", "jsdom": "^29.1.1", @@ -351,9 +351,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", - "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", "dev": true, "funding": [ { @@ -2157,9 +2157,9 @@ "license": "MIT" }, "node_modules/@vue/test-utils": { - "version": "2.4.10", - "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.10.tgz", - "integrity": "sha512-SmoZ5EA1kYiAFs9NkYdiFFQF+cSnUwnvlYEbY+DogWQZUiqOm/Y29eSbc5T6yi75SgSF9863SBeXniIEoPajCA==", + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", "dev": true, "license": "MIT", "dependencies": { @@ -2491,9 +2491,9 @@ } }, "node_modules/bootstrap-vue-next": { - "version": "0.45.4", - "resolved": "https://registry.npmjs.org/bootstrap-vue-next/-/bootstrap-vue-next-0.45.4.tgz", - "integrity": "sha512-l/Wxmiw3PZDjt0QLF6jPh1SguW6H+vxfe/zJb0ANX5vTVggmkgXgGjNzdHr4UB2eghVprGjpjSxUOMcSNaZzfA==", + "version": "0.45.5", + "resolved": "https://registry.npmjs.org/bootstrap-vue-next/-/bootstrap-vue-next-0.45.5.tgz", + "integrity": "sha512-MjRiTdValh0DsORygm8OB3IQhFcbIakgar4dNUMp+kbhCUSkEIIl628fZP4n/q7aXYdNYV5sb0bqpLChYBvITA==", "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.5", @@ -2905,9 +2905,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", - "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -3117,9 +3117,9 @@ } }, "node_modules/eslint-plugin-vue": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.9.1.tgz", - "integrity": "sha512-cHB0Tf4Duvzwecwd/AqWzZvF/QszE13BhjVUpVXWCy9AeMR5GjkAjP3i85vqgLgOuTmkHR1OJ5oMeqLHtuw8zg==", + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.9.2.tgz", + "integrity": "sha512-4g7ZP3pYcuqd7Zp0pzUKcos0W+RkjBz4EGdhJ92FcYk6v03Ti/GK5NwjgsjxHK+98eXDbHeK7VtX1az7/8doZA==", "dev": true, "license": "MIT", "dependencies": { @@ -4178,9 +4178,9 @@ } }, "node_modules/marked": { - "version": "18.0.4", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", - "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -4343,15 +4343,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/ohash": { "version": "2.0.11", @@ -4742,9 +4745,9 @@ } }, "node_modules/reka-ui": { - "version": "2.9.8", - "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.8.tgz", - "integrity": "sha512-7dxaBJ6nQ0zOQZXPV45219tTEgZPstmihBLS9ABPhSiPiJ8SiF0sacfZHFaBptS0v9N4tzsevq+8MNBpE4p5JQ==", + "version": "2.9.9", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.9.tgz", + "integrity": "sha512-/e+hdF9vP8E2kPrKR4RdgMQQsfpCr8l436Zn8GRWM3jKT9EG1lOO/UFMGBVEnrMLOVoJSjjmIFrej4tMOb+6qQ==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13", @@ -4858,9 +4861,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", "dev": true, "license": "ISC", "bin": { @@ -5288,9 +5291,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz", - "integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", + "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", "dev": true, "license": "MIT", "engines": { diff --git a/client-v3/package.json b/client-v3/package.json index 52432f028..deae732ed 100644 --- a/client-v3/package.json +++ b/client-v3/package.json @@ -1,6 +1,6 @@ { "name": "client-v3", - "version": "0.30.5", + "version": "0.30.6", "description": "DigiScript front end (Vue 3)", "author": "DreamTeamProd", "private": true, @@ -33,7 +33,7 @@ "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", "bootstrap": "^5.3.8", - "bootstrap-vue-next": "^0.45.4", + "bootstrap-vue-next": "^0.45.5", "bootswatch": "^5.3.8", "contrast-color": "1.0.1", "core-js": "^3.49.0", @@ -41,11 +41,11 @@ "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "deep-object-diff": "1.1.9", - "dompurify": "^3.4.7", + "dompurify": "^3.4.8", "fuse.js": "^7.4.1", "lodash": "^4.18.1", "loglevel": "^1.9.2", - "marked": "^18.0.4", + "marked": "^18.0.5", "pinia": "^3.0.4", "pinia-plugin-persistedstate": "^4.7.1", "splitpanes": "^4.1.2", @@ -65,11 +65,11 @@ "@typescript-eslint/parser": "^8.60.1", "@vitejs/plugin-vue": "^6.0.7", "@vitest/ui": "^4.1.8", - "@vue/test-utils": "^2.4.10", + "@vue/test-utils": "^2.4.11", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", - "eslint-plugin-vue": "^10.9.1", + "eslint-plugin-vue": "^10.9.2", "globals": "^17.6.0", "jiti": "^2.7.0", "jsdom": "^29.1.1", diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue index 1778128d0..e2009da23 100644 --- a/client-v3/src/App.vue +++ b/client-v3/src/App.vue @@ -162,21 +162,17 @@ :hide-footer="changingPage" :no-close-on-backdrop="changingPage" :no-close-on-esc="changingPage" - @ok="goToLivePage" + @ok.prevent="goToLivePage" > - - This is a required field, and must be greater than 0. - @@ -188,8 +184,6 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { storeToRefs } from 'pinia'; import type { BModal } from 'bootstrap-vue-next'; -import { useVuelidate } from '@vuelidate/core'; -import { required, minValue } from '@vuelidate/validators'; import log from 'loglevel'; import { toast } from '@/js/toast'; import { useConfirm } from '@/composables/useConfirm'; @@ -231,9 +225,7 @@ const goToPageModal = ref>(); const currentShowSession = computed(() => showStore.currentSession); // Jump-to-page form -const pageInputState = ref({ pageNo: 1 }); -const pageRules = { pageNo: { required, minValue: minValue(1) } }; -const v$ = useVuelidate(pageRules, pageInputState); +const pageInputNo = ref(1); let loadTimer: ReturnType | null = null; @@ -404,9 +396,8 @@ async function handleLogout(): Promise { } async function goToLivePage(): Promise { - const valid = await v$.value.$validate(); - if (!valid) return; - sendObj({ OP: 'LIVE_SHOW_JUMP_TO_PAGE', DATA: { page: pageInputState.value.pageNo } }); + if (!pageInputNo.value || pageInputNo.value < 1) return; + sendObj({ OP: 'LIVE_SHOW_JUMP_TO_PAGE', DATA: { page: pageInputNo.value } }); goToPageModal.value?.hide(); } diff --git a/client-v3/src/components/config/ConfigSystem.vue b/client-v3/src/components/config/ConfigSystem.vue index 28427d39f..ae5e74134 100644 --- a/client-v3/src/components/config/ConfigSystem.vue +++ b/client-v3/src/components/config/ConfigSystem.vue @@ -46,6 +46,21 @@ + + Hostname + {{ serverInfo?.hostname ?? 'Unknown' }} + + + + IP Address + {{ serverInfo?.ip_address ?? 'Unknown' }} + + + + Port + {{ serverInfo?.port ?? 'Unknown' }} + + @@ -80,7 +95,7 @@ import { useSystemStore } from '@/stores/system'; import { toast } from '@/js/toast'; const systemStore = useSystemStore(); -const { connectedSessions, versionStatus } = storeToRefs(systemStore); +const { connectedSessions, versionStatus, serverInfo } = storeToRefs(systemStore); const loading = ref(true); const isCheckingVersion = ref(false); @@ -137,7 +152,11 @@ function scheduleSessionPoll(): void { } onMounted(async () => { - await Promise.all([systemStore.getVersionStatus(), systemStore.getConnectedSessions()]); + await Promise.all([ + systemStore.getVersionStatus(), + systemStore.getConnectedSessions(), + systemStore.getServerInfo(), + ]); loading.value = false; scheduleSessionPoll(); }); diff --git a/client-v3/src/composables/useWebSocket.ts b/client-v3/src/composables/useWebSocket.ts index 24489d92e..1cbd66062 100644 --- a/client-v3/src/composables/useWebSocket.ts +++ b/client-v3/src/composables/useWebSocket.ts @@ -5,6 +5,8 @@ import { toast } from '@/js/toast'; import { useWebSocketStore } from '@/stores/websocket'; import { useSystemStore } from '@/stores/system'; import { useUserStore } from '@/stores/user'; +import { useShowStore } from '@/stores/show'; +import router from '@/router'; import { getWebSocketURL } from '@/js/platform'; import type { WsMessage } from '@/types/api/websocket'; @@ -36,8 +38,6 @@ async function handleMessage(msg: WsMessage): Promise { const wsStore = useWebSocketStore(); const systemStore = useSystemStore(); const userStore = useUserStore(); - const { default: router } = await import('@/router'); - switch (msg.OP) { case 'SET_UUID': { const newUUID = msg.DATA as unknown as string; @@ -181,7 +181,6 @@ function connect(): void { } log.info('WebSocket connected'); if (wasErrored) { - const { useShowStore } = await import('@/stores/show'); const showStore = useShowStore(); if (showStore.currentSession != null) { await showStore.getShowSessionData(); diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index 100325c26..7f5930ed6 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -1,5 +1,10 @@ import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'; import { isElectron } from '@/js/platform'; +import { useSystemStore } from '@/stores/system'; +import { useUserStore } from '@/stores/user'; +import { useShowStore } from '@/stores/show'; +import { useWebSocketStore } from '@/stores/websocket'; +import { toast } from '@/js/toast'; import HomeView from '@/views/HomeView.vue'; import NotFoundView from '@/views/NotFoundView.vue'; @@ -234,8 +239,6 @@ async function checkPermissionGuards( } async function checkLiveGuard(toast: ToastFn): Promise { - const { useShowStore } = await import('@/stores/show'); - const { useWebSocketStore } = await import('@/stores/websocket'); const showStore = useShowStore(); const wsStore = useWebSocketStore(); await showStore.getShowSessionData(); @@ -247,9 +250,6 @@ async function checkLiveGuard(toast: ToastFn): Promise { } router.beforeEach(async (to, from) => { - const { useSystemStore } = await import('@/stores/system'); - const { useUserStore } = await import('@/stores/user'); - const { toast } = await import('@/js/toast'); const systemStore = useSystemStore(); const userStore = useUserStore(); diff --git a/client-v3/src/stores/show.ts b/client-v3/src/stores/show.ts index 319e88721..20df16393 100644 --- a/client-v3/src/stores/show.ts +++ b/client-v3/src/stores/show.ts @@ -301,6 +301,22 @@ export const useShowStore = defineStore('show', { } }, + async mergeCharacter(sourceId: number, destinationId: number): Promise { + const response = await fetch(makeURL('/api/v1/show/character/merge'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_id: sourceId, destination_id: destinationId }), + }); + if (response.ok) { + // getCharacterGroupList calls getCharacterList internally + await this.getCharacterGroupList(); + toast.success('Merged character!'); + } else { + log.error('Unable to merge characters'); + toast.error('Unable to merge characters'); + } + }, + // Character Groups async getCharacterGroupList(): Promise { const response = await fetch(makeURL('/api/v1/show/character/group')); diff --git a/client-v3/src/stores/system.ts b/client-v3/src/stores/system.ts index 3c5690a67..b6cd72f62 100644 --- a/client-v3/src/stores/system.ts +++ b/client-v3/src/stores/system.ts @@ -24,6 +24,12 @@ interface VersionStatus { check_error: string | null; } +interface ServerInfo { + hostname: string; + ip_address: string; + port: number; +} + interface RbacRole { key: string; value: number; @@ -49,6 +55,7 @@ export const useSystemStore = defineStore('system', { currentShow: null as Show | null, connectedSessions: [] as ConnectedSession[], versionStatus: null as VersionStatus | null, + serverInfo: null as ServerInfo | null, }), getters: { isAdminUser(): boolean { @@ -246,6 +253,14 @@ export const useSystemStore = defineStore('system', { log.error('Unable to fetch version status'); } }, + async getServerInfo() { + const response = await fetch(makeURL('/api/v1/system/info')); + if (response.ok) { + this.serverInfo = await response.json(); + } else { + log.error('Unable to fetch server info'); + } + }, async checkForUpdates() { const response = await fetch(makeURL('/api/v1/version/check'), { method: 'POST', diff --git a/client-v3/src/stores/user.ts b/client-v3/src/stores/user.ts index b7c1bd5fa..a20447000 100644 --- a/client-v3/src/stores/user.ts +++ b/client-v3/src/stores/user.ts @@ -3,6 +3,9 @@ import log from 'loglevel'; import { isEmpty } from 'lodash'; import { makeURL } from '@/js/utils'; import { toast } from '@/js/toast'; +import { useWebSocketStore } from '@/stores/websocket'; +import { useSystemStore } from '@/stores/system'; +import router from '@/router'; import type { User, UserSettings, CueColourOverride } from '@/types/api/user'; import type { StageDirectionStyle } from '@/types/api/script'; @@ -36,7 +39,6 @@ export const useUserStore = defineStore('user', { this.authToken = null; }, async login(username: string, password: string): Promise { - const { useWebSocketStore } = await import('@/stores/websocket'); const wsStore = useWebSocketStore(); const response = await fetch(makeURL('/api/v1/auth/login'), { @@ -54,7 +56,6 @@ export const useUserStore = defineStore('user', { if (data.access_token) this._setToken(data.access_token); try { - const { useSystemStore } = await import('@/stores/system'); await useSystemStore().getRbacRoles(); await this.getCurrentUser(); await this.getCurrentRbac(); @@ -94,19 +95,17 @@ export const useUserStore = defineStore('user', { this.stageDirectionStyleOverrides = []; this.cueColourOverrides = []; - const { useWebSocketStore } = await import('@/stores/websocket'); useWebSocketStore().$patch({ authenticated: false, authSucceeded: false }); if (token) { try { - const { useWebSocketStore: getWsStore } = await import('@/stores/websocket'); const response = await fetch(makeURL('/api/v1/auth/logout'), { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, // use captured value since store is already cleared }, - body: JSON.stringify({ session_id: getWsStore().internalUUID }), + body: JSON.stringify({ session_id: useWebSocketStore().internalUUID }), }); if (!response.ok) { log.error('Logout response was not OK, but local state was cleared'); @@ -118,7 +117,6 @@ export const useUserStore = defineStore('user', { toast.success('Successfully logged out!'); - const { default: router } = await import('@/router'); if (router.currentRoute.value.path !== '/') { router.push('/'); } @@ -135,7 +133,6 @@ export const useUserStore = defineStore('user', { if (response.ok) { const data = await response.json(); this._setToken(data.access_token); - const { useWebSocketStore } = await import('@/stores/websocket'); useWebSocketStore().refreshWsToken(); log.debug('Token refreshed successfully'); return true; @@ -152,7 +149,6 @@ export const useUserStore = defineStore('user', { log.info('Received token refresh from server'); if (newToken) { this._setToken(newToken); - const { useWebSocketStore } = await import('@/stores/websocket'); useWebSocketStore().refreshWsToken(); log.info('Auth token updated from server'); } diff --git a/client-v3/src/views/show/config/ConfigCharacters.vue b/client-v3/src/views/show/config/ConfigCharacters.vue index 4a37dfd86..be36dbd0e 100644 --- a/client-v3/src/views/show/config/ConfigCharacters.vue +++ b/client-v3/src/views/show/config/ConfigCharacters.vue @@ -33,14 +33,21 @@ Edit + + Merge + Delete @@ -99,6 +106,33 @@ + +

+ Select a destination character. All script lines and group memberships from + {{ mergeSourceCharacter?.name }} will be transferred to the selected + character, and {{ mergeSourceCharacter?.name }} will be deleted. +

+ + + +
+ >(); const editCharacterModal = ref>(); +const mergeCharacterModal = ref>(); + +const mergingCharacter = ref(false); +const mergeSourceCharacter = ref(null); +const mergeDestinationObject = ref(null); const characterFields = [ 'name', @@ -194,6 +235,12 @@ const editRules = { editFormState: { name: { required } } }; const newV$ = useVuelidate(newRules, { newFormState }); const editV$ = useVuelidate(editRules, { editFormState }); +const mergeDestinationOptions = computed(() => + showStore.characterList.filter( + (c) => mergeSourceCharacter.value === null || c.id !== mergeSourceCharacter.value.id + ) +); + const castOptions = computed(() => [ { value: null, text: 'Please select an option', disabled: true }, ...showStore.castList.map((c) => ({ @@ -274,6 +321,36 @@ async function onSubmitEdit(event: Event): Promise { } } +function openMergeForm(character: Character): void { + mergeSourceCharacter.value = character; + mergeDestinationObject.value = null; + mergeCharacterModal.value?.show(); +} + +function resetMergeForm(): void { + mergeSourceCharacter.value = null; + mergeDestinationObject.value = null; + mergingCharacter.value = false; +} + +async function onSubmitMerge(event: Event): Promise { + if (!mergeDestinationObject.value || mergingCharacter.value) { + event.preventDefault(); + return; + } + mergingCharacter.value = true; + try { + await showStore.mergeCharacter(mergeSourceCharacter.value!.id, mergeDestinationObject.value.id); + mergeCharacterModal.value?.hide(); + resetMergeForm(); + } catch (error) { + log.error('Error merging character:', error); + event.preventDefault(); + } finally { + mergingCharacter.value = false; + } +} + async function deleteCharacter(character: Character): Promise { if (deletingCharacter.value) return; const ok = await confirm(`Are you sure you want to delete ${character.name}?`); diff --git a/client/package-lock.json b/client/package-lock.json index 3bc434575..bb31dcedf 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "0.30.5", + "version": "0.30.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "0.30.5", + "version": "0.30.6", "dependencies": { "bootstrap": "4.6.2", "bootstrap-vue": "2.23.1", @@ -17,7 +17,7 @@ "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "deep-object-diff": "1.1.9", - "dompurify": "3.4.7", + "dompurify": "3.4.8", "fuse.js": "7.4.1", "jquery": "3.7.1", "lodash": "4.18.1", @@ -42,7 +42,7 @@ "@typescript-eslint/parser": "^8.60.1", "@vitejs/plugin-vue2": "2.3.4", "@vitest/ui": "^4.1.8", - "@vue/test-utils": "^2.4.10", + "@vue/test-utils": "^2.4.11", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", @@ -246,9 +246,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", - "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", "dev": true, "funding": [ { @@ -1424,9 +1424,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", - "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", "cpu": [ "arm" ], @@ -1438,9 +1438,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", - "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", "cpu": [ "arm64" ], @@ -1452,9 +1452,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", - "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", "cpu": [ "arm64" ], @@ -1466,9 +1466,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", - "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", "cpu": [ "x64" ], @@ -1480,9 +1480,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", - "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", "cpu": [ "arm64" ], @@ -1494,9 +1494,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", - "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", "cpu": [ "x64" ], @@ -1508,9 +1508,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", - "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", "cpu": [ "arm" ], @@ -1522,9 +1522,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", - "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", "cpu": [ "arm" ], @@ -1536,9 +1536,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", - "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", "cpu": [ "arm64" ], @@ -1550,9 +1550,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", - "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", "cpu": [ "arm64" ], @@ -1564,9 +1564,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", - "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", "cpu": [ "loong64" ], @@ -1578,9 +1578,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", - "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", "cpu": [ "loong64" ], @@ -1592,9 +1592,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", - "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", "cpu": [ "ppc64" ], @@ -1606,9 +1606,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", - "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", "cpu": [ "ppc64" ], @@ -1620,9 +1620,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", - "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", "cpu": [ "riscv64" ], @@ -1634,9 +1634,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", - "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", "cpu": [ "riscv64" ], @@ -1648,9 +1648,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", - "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", "cpu": [ "s390x" ], @@ -1662,9 +1662,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", - "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", "cpu": [ "x64" ], @@ -1676,9 +1676,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", - "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", "cpu": [ "x64" ], @@ -1690,9 +1690,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", - "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", "cpu": [ "x64" ], @@ -1704,9 +1704,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", - "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", "cpu": [ "arm64" ], @@ -1718,9 +1718,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", - "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", "cpu": [ "arm64" ], @@ -1732,9 +1732,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", - "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", "cpu": [ "ia32" ], @@ -1746,9 +1746,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", - "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", "cpu": [ "x64" ], @@ -1760,9 +1760,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", - "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", "cpu": [ "x64" ], @@ -2282,9 +2282,9 @@ } }, "node_modules/@vue/test-utils": { - "version": "2.4.10", - "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.10.tgz", - "integrity": "sha512-SmoZ5EA1kYiAFs9NkYdiFFQF+cSnUwnvlYEbY+DogWQZUiqOm/Y29eSbc5T6yi75SgSF9863SBeXniIEoPajCA==", + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", "dev": true, "license": "MIT", "dependencies": { @@ -2880,9 +2880,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", - "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -4142,15 +4142,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/optionator": { "version": "0.9.4", @@ -4458,9 +4461,9 @@ } }, "node_modules/rollup": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", - "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", "dev": true, "license": "MIT", "dependencies": { @@ -4474,31 +4477,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.61.0", - "@rollup/rollup-android-arm64": "4.61.0", - "@rollup/rollup-darwin-arm64": "4.61.0", - "@rollup/rollup-darwin-x64": "4.61.0", - "@rollup/rollup-freebsd-arm64": "4.61.0", - "@rollup/rollup-freebsd-x64": "4.61.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", - "@rollup/rollup-linux-arm-musleabihf": "4.61.0", - "@rollup/rollup-linux-arm64-gnu": "4.61.0", - "@rollup/rollup-linux-arm64-musl": "4.61.0", - "@rollup/rollup-linux-loong64-gnu": "4.61.0", - "@rollup/rollup-linux-loong64-musl": "4.61.0", - "@rollup/rollup-linux-ppc64-gnu": "4.61.0", - "@rollup/rollup-linux-ppc64-musl": "4.61.0", - "@rollup/rollup-linux-riscv64-gnu": "4.61.0", - "@rollup/rollup-linux-riscv64-musl": "4.61.0", - "@rollup/rollup-linux-s390x-gnu": "4.61.0", - "@rollup/rollup-linux-x64-gnu": "4.61.0", - "@rollup/rollup-linux-x64-musl": "4.61.0", - "@rollup/rollup-openbsd-x64": "4.61.0", - "@rollup/rollup-openharmony-arm64": "4.61.0", - "@rollup/rollup-win32-arm64-msvc": "4.61.0", - "@rollup/rollup-win32-ia32-msvc": "4.61.0", - "@rollup/rollup-win32-x64-gnu": "4.61.0", - "@rollup/rollup-win32-x64-msvc": "4.61.0", + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" } }, @@ -4537,9 +4540,9 @@ } }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", "dev": true, "license": "ISC", "bin": { diff --git a/client/package.json b/client/package.json index cc6411ea2..9e3342f4e 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "client", - "version": "0.30.5", + "version": "0.30.6", "description": "DigiScript front end", "author": "DreamTeamProd", "private": true, @@ -37,7 +37,7 @@ "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "deep-object-diff": "1.1.9", - "dompurify": "3.4.7", + "dompurify": "3.4.8", "fuse.js": "7.4.1", "jquery": "3.7.1", "lodash": "4.18.1", @@ -62,7 +62,7 @@ "@typescript-eslint/parser": "^8.60.1", "@vitejs/plugin-vue2": "2.3.4", "@vitest/ui": "^4.1.8", - "@vue/test-utils": "^2.4.10", + "@vue/test-utils": "^2.4.11", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", diff --git a/client/src/store/modules/show.ts b/client/src/store/modules/show.ts index 462b1a2a2..7c38f310e 100644 --- a/client/src/store/modules/show.ts +++ b/client/src/store/modules/show.ts @@ -212,6 +212,23 @@ const module: Module = { VueToast.$toast.error('Unable to delete character'); } }, + async MERGE_CHARACTER( + context, + { source_id, destination_id }: { source_id: number; destination_id: number } + ) { + const response = await fetch(makeURL('/api/v1/show/character/merge'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_id, destination_id }), + }); + if (response.ok) { + context.dispatch('GET_CHARACTER_GROUP_LIST'); + VueToast.$toast.success('Merged character!'); + } else { + log.error('Unable to merge characters'); + VueToast.$toast.error('Unable to merge characters'); + } + }, async UPDATE_CHARACTER(context, character: Partial) { const response = await fetch(`${makeURL('/api/v1/show/character')}`, { method: 'PATCH', diff --git a/client/src/views/show/config/ConfigCharacters.vue b/client/src/views/show/config/ConfigCharacters.vue index 8d57b93bb..4be8756a0 100644 --- a/client/src/views/show/config/ConfigCharacters.vue +++ b/client/src/views/show/config/ConfigCharacters.vue @@ -29,14 +29,21 @@ Edit + + Merge + Delete @@ -62,6 +69,32 @@ + +

+ Select a destination character. All script lines and group memberships from + {{ mergeSourceCharacter ? mergeSourceCharacter.name : '' }} + will be transferred to the selected character, and it will be deleted. +

+ + + +
+ !(this as any).mergeSourceCharacter || c.id !== (this as any).mergeSourceCharacter.id + ); + }, }, async mounted(): Promise { await Promise.all([(this as any).GET_CHARACTER_LIST(), (this as any).GET_CAST_LIST()]); @@ -308,12 +350,43 @@ export default defineComponent({ } } }, + openMergeForm(character: any): void { + this.mergeSourceCharacter = character.item; + this.mergeDestinationCharacter = null; + (this as any).$bvModal.show('merge-character'); + }, + resetMergeForm(): void { + this.mergeSourceCharacter = null; + this.mergeDestinationCharacter = null; + this.mergingCharacter = false; + }, + async onSubmitMerge(event: Event): Promise { + if (!this.mergeDestinationCharacter || this.mergingCharacter) { + event.preventDefault(); + return; + } + this.mergingCharacter = true; + try { + await (this as any).MERGE_CHARACTER({ + source_id: this.mergeSourceCharacter.id, + destination_id: this.mergeDestinationCharacter.id, + }); + (this as any).$bvModal.hide('merge-character'); + this.resetMergeForm(); + } catch (error) { + log.error('Error merging character:', error); + event.preventDefault(); + } finally { + this.mergingCharacter = false; + } + }, ...mapActions([ 'GET_CHARACTER_LIST', 'GET_CAST_LIST', 'ADD_CHARACTER', 'UPDATE_CHARACTER', 'DELETE_CHARACTER', + 'MERGE_CHARACTER', ]), }, }); diff --git a/client/src/vue_components/config/ConfigSystem.vue b/client/src/vue_components/config/ConfigSystem.vue index 901405eb5..9a139ef21 100644 --- a/client/src/vue_components/config/ConfigSystem.vue +++ b/client/src/vue_components/config/ConfigSystem.vue @@ -54,6 +54,21 @@
+ + Hostname + {{ systemInfo ? systemInfo.hostname : 'Unknown' }} + + + + IP Address + {{ systemInfo ? systemInfo.ip_address : 'Unknown' }} + + + + Port + {{ systemInfo ? systemInfo.port : 'Unknown' }} + + | null, + systemInfo: null as { + hostname: string | null; + ip_address: string | null; + port: number | null; + } | null, }; }, async mounted() { - await Promise.all([this.getConnectedClients(), this.getVersionStatus()]); + await Promise.all([this.getConnectedClients(), this.getVersionStatus(), this.getSystemInfo()]); this.loading = false; this.timeUpdateInterval = setInterval(() => { this.currentTime = Date.now(); @@ -201,6 +221,18 @@ export default defineComponent({ if (this.versionStatus.update_available) return 'Update Available'; return 'Up to date'; }, + async getSystemInfo(): Promise { + try { + const response = await fetch(`${makeURL('/api/v1/system/info')}`); + if (response.ok) { + this.systemInfo = await response.json(); + } else { + log.error('Unable to get system info'); + } + } catch (error) { + log.error('Error fetching system info:', error); + } + }, formatTimeAgo(isoTimestamp: string | null): string { if (!isoTimestamp) return 'Never'; diff --git a/docs/pages/user_config.md b/docs/pages/user_config.md index ee2e82f4b..32b23a5b8 100644 --- a/docs/pages/user_config.md +++ b/docs/pages/user_config.md @@ -8,9 +8,11 @@ The **System Config** section, accessible from the top navigation bar, provides The **System** tab provides an overview of the current system state: -- **Current Show**: Displays the currently loaded show name, with buttons to load an existing show or set up a new one. -- **Connected Clients**: Shows the number of WebSocket clients currently connected to the server. Click "View Clients" to see details about each connected session. - **Version**: Displays the current DigiScript version and checks for available updates. +- **Connected Clients**: Shows the number of WebSocket clients currently connected to the server. Click "View Clients" to see details about each connected session. +- **Hostname**: The fully-qualified domain name (FQDN) of the server machine. +- **IP Address**: The primary outbound IP address the server is reachable on. +- **Port**: The port the server is listening on. #### Version Checker diff --git a/electron/package-lock.json b/electron/package-lock.json index 85d433268..1a264c14a 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "digiscript-electron", - "version": "0.30.5", + "version": "0.30.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "digiscript-electron", - "version": "0.30.5", + "version": "0.30.6", "license": "GPL-3.0", "dependencies": { "bonjour-service": "^1.4.0", @@ -2278,9 +2278,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.33", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", - "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3557,9 +3557,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.366", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", - "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", "dev": true, "license": "ISC" }, @@ -3646,13 +3646,13 @@ } }, "node_modules/electron/node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "version": "24.13.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.0.tgz", + "integrity": "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/electron/node_modules/fs-extra": { @@ -3691,9 +3691,9 @@ } }, "node_modules/electron/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -3751,9 +3751,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.22.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", - "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", "dev": true, "license": "MIT", "dependencies": { @@ -7009,9 +7009,9 @@ } }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/electron/package.json b/electron/package.json index 8b75dbb0a..81bde78fe 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "digiscript-electron", - "version": "0.30.5", + "version": "0.30.6", "description": "DigiScript Electron Desktop Application", "author": "DreamTeamProd", "license": "GPL-3.0", diff --git a/server/controllers/api/constants.py b/server/controllers/api/constants.py index b487758ed..c171cc870 100644 --- a/server/controllers/api/constants.py +++ b/server/controllers/api/constants.py @@ -111,3 +111,6 @@ ERROR_NAME_ALREADY_TAKEN = "Name already taken" ERROR_TAG_NAME_EXISTS = "Tag name already exists (case-insensitive)" +ERROR_CANNOT_MERGE_SAME_CHARACTER = ( + "Source and destination characters must be different" +) diff --git a/server/controllers/api/show/characters.py b/server/controllers/api/show/characters.py index 672582181..d9b781881 100644 --- a/server/controllers/api/show/characters.py +++ b/server/controllers/api/show/characters.py @@ -1,9 +1,10 @@ from collections import defaultdict -from sqlalchemy import select +from sqlalchemy import select, update from tornado import escape from controllers.api.constants import ( + ERROR_CANNOT_MERGE_SAME_CHARACTER, ERROR_CAST_MEMBER_NOT_FOUND, ERROR_CHARACTER_GROUP_NOT_FOUND, ERROR_CHARACTER_NOT_FOUND, @@ -12,7 +13,13 @@ ERROR_NAME_MISSING, ERROR_SHOW_NOT_FOUND, ) -from models.script import Script, ScriptLine, ScriptLineType, ScriptRevision +from models.script import ( + Script, + ScriptLine, + ScriptLinePart, + ScriptLineType, + ScriptRevision, +) from models.show import Cast, Character, CharacterGroup, Show from rbac.role import Role from schemas.schemas import CharacterGroupSchema, CharacterSchema @@ -186,6 +193,94 @@ async def delete(self): await self.finish({"message": ERROR_SHOW_NOT_FOUND}) +@ApiRoute("show/character/merge", ApiVersion.V1) +class CharacterMergeController(BaseAPIController): + @requires_show + @no_live_session + async def post(self): + """Merge a source character into a destination character. + + Reassigns all ScriptLinePart rows referencing the source character to + the destination character across all revisions, then adds the destination + to every CharacterGroup that contained the source (if not already a + member), and finally deletes the source character (which cascades away + mic allocations and secondary-table group memberships automatically). + + :raises HTTPError 400: If source_id or destination_id are missing, or + if the source and destination are the same character. + :raises HTTPError 404: If either character cannot be found, or if + either character does not belong to the current show. + """ + current_show = self.get_current_show() + show_id = current_show["id"] + + with self.make_session() as session: + show: Show = session.get(Show, show_id) + if not show: + self.set_status(404) + await self.finish({"message": ERROR_SHOW_NOT_FOUND}) + return + + self.requires_role(show, Role.WRITE) + data = escape.json_decode(self.request.body) + + source_id = data.get("source_id", None) + destination_id = data.get("destination_id", None) + + if not source_id or not destination_id: + self.set_status(400) + await self.finish({"message": ERROR_ID_MISSING}) + return + + if source_id == destination_id: + self.set_status(400) + await self.finish({"message": ERROR_CANNOT_MERGE_SAME_CHARACTER}) + return + + source: Character = session.get(Character, source_id) + if not source or source.show_id != show_id: + self.set_status(404) + await self.finish({"message": ERROR_CHARACTER_NOT_FOUND}) + return + + destination: Character = session.get(Character, destination_id) + if not destination or destination.show_id != show_id: + self.set_status(404) + await self.finish({"message": ERROR_CHARACTER_NOT_FOUND}) + return + + # Step 1: Bulk-update all ScriptLinePart rows across all revisions. + # synchronize_session=False is safe: we do not re-read these objects + # after the update within this transaction. + session.execute( + update(ScriptLinePart) + .where(ScriptLinePart.character_id == source_id) + .values(character_id=destination_id) + .execution_options(synchronize_session=False) + ) + + # Step 2: Add destination to every group the source belongs to, + # skipping groups where destination is already a member to avoid + # a composite-PK collision on the association table. + for group in source.character_groups: + if destination not in group.characters: + group.characters.append(destination) + + # Step 3: Delete source. SQLAlchemy cascades: + # - mic_allocations: via cascade="all, delete-orphan" + # - character_group_association rows: via secondary= M2M management + session.delete(source) + session.commit() + + self.set_status(200) + await self.finish({"message": "Successfully merged character"}) + + await self.application.ws_send_to_all("NOOP", "GET_CHARACTER_LIST", {}) + await self.application.ws_send_to_all( + "NOOP", "GET_CHARACTER_GROUP_LIST", {} + ) + + @ApiRoute("show/character/stats", ApiVersion.V1) class CharacterStatsController(BaseAPIController): async def get(self): diff --git a/server/controllers/api/system_info.py b/server/controllers/api/system_info.py new file mode 100644 index 000000000..787148e03 --- /dev/null +++ b/server/controllers/api/system_info.py @@ -0,0 +1,53 @@ +import socket + +from utils.web.base_controller import BaseAPIController +from utils.web.route import ApiRoute, ApiVersion +from utils.web.web_decorators import api_authenticated, require_admin + + +# Well-known public DNS address used only to determine the local outbound interface. +# No data is ever sent — the UDP socket is closed immediately after getsockname(). +_PROBE_HOST = "8.8.8.8" +_PROBE_PORT = 80 + + +@ApiRoute("system/info", ApiVersion.V1) +class SystemInfoController(BaseAPIController): + @api_authenticated + @require_admin + async def get(self): + """ + Get server network information. + + Returns the server's fully-qualified hostname, primary outbound IP address, + and the port it is listening on. + + :returns: JSON response with hostname, ip_address, and port. + """ + await self.finish( + { + "hostname": socket.getfqdn(), + "ip_address": _get_primary_ip(), + "port": self.application._port, + } + ) + + +def _get_primary_ip() -> str: + """ + Return the primary outbound IP address. + + Uses the connect-to-8.8.8.8 trick: opening a UDP socket and calling connect() + causes the OS to select the correct source address without sending any data. + + :returns: IP address string, or ``"Unknown"`` on failure. + """ + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect((_PROBE_HOST, _PROBE_PORT)) + return s.getsockname()[0] + finally: + s.close() + except Exception: + return "Unknown" diff --git a/server/pyproject.toml b/server/pyproject.toml index cbda89605..df8146fa1 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta" [project] name = "digiscript-server" -version = "0.30.5" +version = "0.30.6" description = "DigiScript server - Digital script management for theatrical shows" readme = "../README.md" requires-python = ">=3.13" diff --git a/server/test/conftest.py b/server/test/conftest.py index 7f0ab85f0..6c5a6e49e 100644 --- a/server/test/conftest.py +++ b/server/test/conftest.py @@ -2,6 +2,7 @@ import os from sqlalchemy import inspect +from tornado import escape from tornado.testing import AsyncHTTPTestCase from digi_server.app_server import DigiScriptServer @@ -43,3 +44,34 @@ def tearDown(self): models.db.engine.dispose() super().tearDown() + + def _create_and_login_admin(self, username="admin", password="adminpass"): + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": username, "password": password, "is_admin": True} + ), + ) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": username, "password": password}), + ) + return escape.json_decode(resp.body)["access_token"] + + def _create_and_login_user(self, admin_token, username="user", password="userpass"): + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": username, "password": password, "is_admin": False} + ), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + resp = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": username, "password": password}), + ) + return escape.json_decode(resp.body)["access_token"] diff --git a/server/test/controllers/api/show/test_characters.py b/server/test/controllers/api/show/test_characters.py index 0b2d213cb..d4674d256 100644 --- a/server/test/controllers/api/show/test_characters.py +++ b/server/test/controllers/api/show/test_characters.py @@ -1,7 +1,15 @@ import tornado.escape +from sqlalchemy import select -from models.script import Script, ScriptRevision -from models.show import Show, ShowScriptType +from models.mics import Microphone, MicrophoneAllocation +from models.script import ( + Script, + ScriptLine, + ScriptLinePart, + ScriptLineType, + ScriptRevision, +) +from models.show import Act, Character, CharacterGroup, Scene, Show, ShowScriptType from test.conftest import DigiScriptTestCase @@ -59,3 +67,212 @@ def test_get_character_stats_no_script(self): response = self.fetch("/api/v1/show/character/stats") # Should get an error because there's no script self.assertNotEqual(200, response.code) + + +class TestCharacterMergeController(DigiScriptTestCase): + """Test suite for POST /api/v1/show/character/merge endpoint.""" + + def setUp(self): + super().setUp() + self.token = self._create_and_login_admin() + + 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 + + source = Character(show_id=show.id, name="Source") + destination = Character(show_id=show.id, name="Destination") + session.add(source) + session.add(destination) + session.flush() + self.source_id = source.id + self.destination_id = destination.id + session.commit() + + self._app.digi_settings.settings["current_show"].set_value(self.show_id) + + def _merge(self, body: dict) -> object: + return self.fetch( + "/api/v1/show/character/merge", + method="POST", + body=tornado.escape.json_encode(body), + headers={"Authorization": f"Bearer {self.token}"}, + ) + + def test_merge_missing_source_id(self): + """POST with no source_id returns 400.""" + response = self._merge({"destination_id": self.destination_id}) + self.assertEqual(400, response.code) + + def test_merge_missing_destination_id(self): + """POST with no destination_id returns 400.""" + response = self._merge({"source_id": self.source_id}) + self.assertEqual(400, response.code) + + def test_merge_same_character(self): + """POST with identical source and destination returns 400.""" + response = self._merge( + {"source_id": self.source_id, "destination_id": self.source_id} + ) + self.assertEqual(400, response.code) + + def test_merge_source_not_found(self): + """POST with a nonexistent source_id returns 404.""" + response = self._merge( + {"source_id": 99999, "destination_id": self.destination_id} + ) + self.assertEqual(404, response.code) + + def test_merge_destination_not_found(self): + """POST with a nonexistent destination_id returns 404.""" + response = self._merge({"source_id": self.source_id, "destination_id": 99999}) + self.assertEqual(404, response.code) + + def test_merge_cross_show_source(self): + """POST with a source character from a different show returns 404.""" + with self._app.get_db().sessionmaker() as session: + other_show = Show(name="Other Show", script_mode=ShowScriptType.FULL) + session.add(other_show) + session.flush() + foreign_char = Character(show_id=other_show.id, name="Foreign") + session.add(foreign_char) + session.flush() + foreign_id = foreign_char.id + session.commit() + + response = self._merge( + {"source_id": foreign_id, "destination_id": self.destination_id} + ) + self.assertEqual(404, response.code) + + def test_merge_deletes_source(self): + """Successful merge removes the source character.""" + response = self._merge( + {"source_id": self.source_id, "destination_id": self.destination_id} + ) + self.assertEqual(200, response.code) + + with self._app.get_db().sessionmaker() as session: + remaining = session.scalars( + select(Character).where(Character.show_id == self.show_id) + ).all() + names = [c.name for c in remaining] + self.assertNotIn("Source", names) + self.assertIn("Destination", names) + + def test_merge_transfers_script_line_parts(self): + """Merge updates all ScriptLinePart rows for the source to the destination.""" + with self._app.get_db().sessionmaker() as session: + act = Act(show_id=self.show_id, name="Act 1") + session.add(act) + session.flush() + scene = Scene(show_id=self.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() + part = ScriptLinePart( + line_id=line.id, + part_index=0, + character_id=self.source_id, + ) + session.add(part) + session.flush() + part_id = part.id + session.commit() + + response = self._merge( + {"source_id": self.source_id, "destination_id": self.destination_id} + ) + self.assertEqual(200, response.code) + + with self._app.get_db().sessionmaker() as session: + updated_part = session.get(ScriptLinePart, part_id) + self.assertEqual(self.destination_id, updated_part.character_id) + + def test_merge_updates_character_groups(self): + """Merge adds destination to groups that contained the source.""" + with self._app.get_db().sessionmaker() as session: + source = session.get(Character, self.source_id) + group = CharacterGroup(show_id=self.show_id, name="Ensemble") + group.characters.append(source) + session.add(group) + session.flush() + group_id = group.id + session.commit() + + response = self._merge( + {"source_id": self.source_id, "destination_id": self.destination_id} + ) + self.assertEqual(200, response.code) + + with self._app.get_db().sessionmaker() as session: + group = session.get(CharacterGroup, group_id) + member_ids = [c.id for c in group.characters] + self.assertIn(self.destination_id, member_ids) + self.assertNotIn(self.source_id, member_ids) + + def test_merge_group_deduplication(self): + """Merge does not create a duplicate when destination is already in the group.""" + with self._app.get_db().sessionmaker() as session: + source = session.get(Character, self.source_id) + destination = session.get(Character, self.destination_id) + group = CharacterGroup(show_id=self.show_id, name="Royalty") + group.characters.append(source) + group.characters.append(destination) + session.add(group) + session.flush() + group_id = group.id + session.commit() + + response = self._merge( + {"source_id": self.source_id, "destination_id": self.destination_id} + ) + self.assertEqual(200, response.code) + + with self._app.get_db().sessionmaker() as session: + group = session.get(CharacterGroup, group_id) + member_ids = [c.id for c in group.characters] + self.assertEqual(1, len(member_ids)) + self.assertIn(self.destination_id, member_ids) + + def test_merge_deletes_mic_allocations(self): + """Merge deletes all mic allocations belonging to the source character.""" + with self._app.get_db().sessionmaker() as session: + mic = Microphone(show_id=self.show_id, name="Radio Mic 1") + session.add(mic) + session.flush() + act = Act(show_id=self.show_id, name="Act 1") + session.add(act) + session.flush() + scene = Scene(show_id=self.show_id, act_id=act.id, name="Scene 1") + session.add(scene) + session.flush() + alloc = MicrophoneAllocation( + mic_id=mic.id, + scene_id=scene.id, + character_id=self.source_id, + ) + session.add(alloc) + session.commit() + + response = self._merge( + {"source_id": self.source_id, "destination_id": self.destination_id} + ) + self.assertEqual(200, response.code) + + with self._app.get_db().sessionmaker() as session: + remaining = session.scalars( + select(MicrophoneAllocation).where( + MicrophoneAllocation.character_id == self.source_id + ) + ).all() + self.assertEqual(0, len(remaining)) diff --git a/server/test/controllers/api/test_db_backups.py b/server/test/controllers/api/test_db_backups.py index 8f7424be2..4aa53fec4 100644 --- a/server/test/controllers/api/test_db_backups.py +++ b/server/test/controllers/api/test_db_backups.py @@ -34,37 +34,6 @@ def _create_backup(self, timestamp: int) -> str: f.write(b"x" * 1024) return backup_path - def _create_and_login_admin(self, username="admin", password="adminpass"): - self.fetch( - "/api/v1/auth/create", - method="POST", - body=escape.json_encode( - {"username": username, "password": password, "is_admin": True} - ), - ) - resp = self.fetch( - "/api/v1/auth/login", - method="POST", - body=escape.json_encode({"username": username, "password": password}), - ) - return escape.json_decode(resp.body)["access_token"] - - def _create_and_login_user(self, admin_token, username="user", password="userpass"): - self.fetch( - "/api/v1/auth/create", - method="POST", - body=escape.json_encode( - {"username": username, "password": password, "is_admin": False} - ), - headers={"Authorization": f"Bearer {admin_token}"}, - ) - resp = self.fetch( - "/api/v1/auth/login", - method="POST", - body=escape.json_encode({"username": username, "password": password}), - ) - return escape.json_decode(resp.body)["access_token"] - def _fetch_backups(self, token=None): headers = {} if token: diff --git a/server/test/controllers/api/test_system_info.py b/server/test/controllers/api/test_system_info.py new file mode 100644 index 000000000..3c89428db --- /dev/null +++ b/server/test/controllers/api/test_system_info.py @@ -0,0 +1,52 @@ +"""Integration tests for GET /api/v1/system/info.""" + +from tornado import escape + +from test.conftest import DigiScriptTestCase + + +class TestSystemInfoController(DigiScriptTestCase): + def _fetch_system_info(self, token=None): + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + return self.fetch("/api/v1/system/info", headers=headers, raise_error=False) + + def test_unauthenticated_returns_401(self): + resp = self._fetch_system_info() + self.assertEqual(401, resp.code) + + def test_non_admin_returns_401(self): + admin_token = self._create_and_login_admin() + user_token = self._create_and_login_user(admin_token) + resp = self._fetch_system_info(token=user_token) + self.assertEqual(401, resp.code) + + def test_authenticated_returns_200_with_expected_keys(self): + token = self._create_and_login_admin() + resp = self._fetch_system_info(token=token) + self.assertEqual(200, resp.code) + body = escape.json_decode(resp.body) + self.assertIn("hostname", body) + self.assertIn("ip_address", body) + self.assertIn("port", body) + + def test_hostname_is_non_empty_string(self): + token = self._create_and_login_admin() + resp = self._fetch_system_info(token=token) + body = escape.json_decode(resp.body) + self.assertIsInstance(body["hostname"], str) + self.assertGreater(len(body["hostname"]), 0) + + def test_ip_address_is_non_empty_string(self): + token = self._create_and_login_admin() + resp = self._fetch_system_info(token=token) + body = escape.json_decode(resp.body) + self.assertIsInstance(body["ip_address"], str) + self.assertGreater(len(body["ip_address"]), 0) + + def test_port_is_integer(self): + token = self._create_and_login_admin() + resp = self._fetch_system_info(token=token) + body = escape.json_decode(resp.body) + self.assertIsInstance(body["port"], int) diff --git a/server/test_requirements.txt b/server/test_requirements.txt index 682f6e4df..ad5d3e042 100644 --- a/server/test_requirements.txt +++ b/server/test_requirements.txt @@ -1,3 +1,3 @@ pytest<9.1 pytest-asyncio>=1.4.0 -ruff==0.15.15 \ No newline at end of file +ruff==0.15.16 \ No newline at end of file