Compare commits

..

40 Commits

Author SHA1 Message Date
Carlos Valente 37abd3fe47 chore(ci): prevent publishing resolver on release 2025-10-22 06:26:35 +02:00
Carlos Valente b509ee5b1f bump to version 4.0.2 2025-10-22 06:26:35 +02:00
Shobhit-Nagpal dd2b59efe5 fix: move blue line to pseudo element 2025-10-21 16:29:59 +02:00
Shobhit-Nagpal 5788785b3e fix: minor ui shift on quick add inline 2025-10-21 16:29:59 +02:00
Carlos Valente e488ad19b3 fix: ensure well formed URLs for IP address destinations 2025-10-18 15:29:16 +02:00
Carlos Valente cbc03c898c fix: correct alignment to end message 2025-10-18 15:28:47 +02:00
jwetzell 155bfbdc42 Merge pull request #1833 from cpvalente/fix/npm-action-access-error 2025-10-18 07:13:05 -05:00
jwetzell 5d3714a4fc Merge pull request #1836 from cpvalente/fix/use-flat-rundown 2025-10-18 07:12:39 -05:00
jwetzell a8a1d210d8 use flat order when building flat rundown 2025-10-17 21:46:13 -05:00
jwetzell 49de8b63a7 add setup-node step back to resolve npm access issues 2025-10-17 08:13:11 -05:00
Carlos Valente 03c9f51b47 chore: update demo project 2025-10-17 12:20:18 +02:00
Carlos Valente 8d00fd3966 bump version to 4.0.1 2025-10-17 12:20:18 +02:00
Carlos Valente 80fd21354e fix: apply styling to overtime timer 2025-10-16 21:13:26 +02:00
Carlos Valente a87b15825a fix: end message scaling 2025-10-16 21:13:26 +02:00
Carlos Valente 6a8f98c469 fix: end message only shows if timer is in overtime 2025-10-16 21:13:26 +02:00
jwetzell 8685298430 Merge pull request #1830 from cpvalente/fix/linux-project-download 2025-10-15 07:25:57 -05:00
jwetzell d43af2db57 pass options to project download to allow folders beginning with a dot 2025-10-14 19:51:15 -05:00
jwetzell ef718c48e4 Merge pull request #1828 from cpvalente/npm-trusted-publisher
update cli and resolver workflows for new NPM auth
2025-10-14 16:18:53 -05:00
Alex Christoffer Rasmussen 45e7373279 Small clean-up (#1827)
* chore: avoid shadow variable name

* chore: map could just be foreach

* clean uneeded template literal

* lint
2025-10-13 20:24:25 +02:00
jwetzell 6af2d6e021 update cli and resolver workflows for new NPM auth 2025-10-12 14:11:52 -05:00
Alex Christoffer Rasmussen 9b36f36f73 fix: next cue (#1821)
* fix next cue

* Update apps/server/src/services/runtime-service/runtime.service.ts

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-10-12 14:42:55 +02:00
Carlos Valente 3a0dbf057e chore: update docs 2025-10-12 07:48:25 +02:00
Carlos Valente dae84ab48a chore: update screenshots 2025-10-12 07:48:25 +02:00
Carlos Valente aa927597f3 fix: product name ontime 2025-10-10 19:03:53 +02:00
Carlos Valente ef7f0fb54a refactor: bump modal sizes 2025-10-10 18:32:29 +02:00
Carlos Valente 324320fdc8 fix: custom field is multiline 2025-10-10 18:32:29 +02:00
Carlos Valente 4b27d080b6 fix: background colour in not found page 2025-10-10 18:13:58 +02:00
Carlos Valente 7918063753 bump version to 4.0.0 2025-10-10 18:13:58 +02:00
arc-alex e2202c2417 fix: dynamic full hight for mobile view param editor 2025-10-10 18:07:39 +02:00
Carlos Valente 9817c8f8c0 fix: selected styling overrides 2025-10-10 17:41:26 +02:00
Carlos Valente 1d8c04d0b4 feat: export excel file
refactor: align id columns between export and import

refactor: match column names with template
2025-10-10 17:41:26 +02:00
Carlos Valente 10762f10bf docs: caveat on proxy not working before project build 2025-10-10 17:41:26 +02:00
Alex Christoffer Rasmussen da7c0d4ff2 Small clean-up (#1815)
* chore: remove old comment

* chore: smal lclean up of a e2e test

* chore: bump to beta.6

* chore: cleanup imports

* fix integration values com in ms

* allow linkStart and timeStrategy from API

* remove local mac dist
2025-10-10 17:37:55 +02:00
Carlos Valente 4a27973de1 refactor: allow locking op settings and navigation 2025-10-10 16:01:41 +02:00
Carlos Valente bdeb087e28 fix: op gets params from preset context 2025-10-10 16:01:41 +02:00
Carlos Valente 3662cd58f5 refactor: extract operator data loading 2025-10-10 16:01:41 +02:00
Carlos Valente 62c52bca07 refactor: larger secondary field in timer 2025-10-09 21:03:22 +02:00
Carlos Valente 1fc4a06133 fix: allow user to reset schedule filter 2025-10-09 21:03:22 +02:00
Alex Christoffer Rasmussen 147862e647 Fix opview with groups (#1814)
* create failing test

* fix: op evet used past state of the group
2025-10-08 21:19:57 +02:00
Alex Christoffer Rasmussen 12bc323e57 Fix build (#1813)
* fix resolver build

* fix docker build

* node version in docker via arg

* add step to setup auth for pnpm

* lint

---------

Co-authored-by: Joel Wetzell <jwetzell@yahoo.com>
2025-10-08 10:44:53 +02:00
77 changed files with 1088 additions and 278 deletions
-1
View File
@@ -17,7 +17,6 @@
# Ignore build folders
node_modules
**/node_modules
**/dist
# Ignore default volumes created by running docker compose up
ontime-db
Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 149 KiB

+13 -5
View File
@@ -13,13 +13,18 @@ jobs:
steps:
- uses: actions/checkout@v4
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
@@ -38,16 +43,19 @@ jobs:
- name: Copy server
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
- name: Copy client
run: cp -R apps/client/build apps/cli/client
- name: Copy external
run: cp -R apps/server/src/external apps/cli/external
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
- name: Install newer version of npm
run: npm install -g npm@11
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/cli
+49 -29
View File
@@ -6,43 +6,63 @@ on:
workflow_dispatch:
jobs:
publish_docker:
runs-on: ubuntu-latest
env:
CI: ''
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+4 -4
View File
@@ -2,7 +2,7 @@ name: Ontime build
on:
push:
tags: [ "*" ]
tags: ['*']
workflow_dispatch:
jobs:
@@ -11,13 +11,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
@@ -35,7 +35,7 @@ jobs:
run: pnpm build
- name: Electron - Build app
env:
env:
APPLE_ID: ${{ secrets.APPLEID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
+11 -5
View File
@@ -1,8 +1,6 @@
name: Ontime Resolver build
on:
release:
types: [published]
workflow_dispatch:
jobs:
@@ -13,13 +11,18 @@ jobs:
steps:
- uses: actions/checkout@v4
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
@@ -34,9 +37,12 @@ jobs:
- name: Build project packages
run: pnpm build:resolver
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
- name: Install newer version of npm
run: npm install -g npm@11
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/resolver
+5 -5
View File
@@ -20,7 +20,7 @@ jobs:
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
@@ -32,10 +32,10 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Run code quality
# Run code quality
- name: Run linter
run: pnpm lint
- name: Run TypeScript checks
run: pnpm typecheck
@@ -56,7 +56,7 @@ jobs:
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
@@ -74,7 +74,7 @@ jobs:
uses: actions/cache@v4
with:
path: |
~/.cache/ms-playwright
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: |
${{ runner.os }}-playwright-
+2 -1
View File
@@ -5,8 +5,9 @@ node_modules
playwright-report
pnpm-lock.yaml
**/*.toml
**/*.yml
**/*.json
!tsconfig.common.json
!turbo.json
+1 -3
View File
@@ -21,6 +21,7 @@ Locally, we would need to run both the React client and the node.js server in de
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Create a local build__ by running `pnpm build`, this will populate local dependencies
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
@@ -82,9 +83,6 @@ From the project root, run the following commands
The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `pnpm dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
## DOCKER
Ontime provides a docker-compose file to aid with building and running docker images.
+9 -15
View File
@@ -1,13 +1,5 @@
FROM node:22-bullseye AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0
COPY . /app
WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils --filter=ontime-types install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm run build:docker
FROM node:22-alpine
ARG NODE_VERSION=22.15.1
FROM node:${NODE_VERSION}-alpine
# Set environment variables
# Environment Variable to signal that we are running production
@@ -15,16 +7,17 @@ ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/data/
RUN mkdir /app
WORKDIR /app/
# Prepare UI
COPY --from=builder /app/apps/client/build ./client/
COPY apps/client/build/ ./client/
# Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/
COPY apps/server/dist/ ./server/
COPY apps/server/src/external/ ./external/
COPY apps/server/src/user/ ./user/
COPY apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
@@ -32,5 +25,6 @@ EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"]
# Build and run commands
# pnpm build:docker
# docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
-2
View File
@@ -26,8 +26,6 @@ We do our best to have most topics covered by the documentation. However, if you
Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
# Ontime
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.0-beta.5",
"version": "4.0.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.0.0-beta.5",
"version": "4.0.2",
"private": true,
"type": "module",
"dependencies": {
+4 -3
View File
@@ -102,6 +102,7 @@ export default function AppRouter() {
path='op'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator />
</ViewLoader>
}
@@ -207,13 +208,13 @@ function PresetView() {
/**
* Locked presets do not allow configuration changes
* Whether the user can navigate is determined by the locked param
*
* We inject the preset to the context value for the view to consume
*/
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return (
<PresetContext value={preset}>
{preset.target !== OntimeView.Cuesheet && (
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
)}
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
{Component ? <Component /> : <NotFound />}
</PresetContext>
);
+16
View File
@@ -3,6 +3,7 @@ import { CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`;
@@ -39,3 +40,18 @@ export async function importRundownPreview(options: ImportMap): Promise<PreviewS
});
return response.data;
}
/**
* Downloads a xlsx representation of the rundown from the server
*/
export async function downloadAsExcel(rundownId: string, fileName?: string) {
try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
responseType: 'blob',
});
downloadBlob(response.data, `${fileName ?? 'Ontime_rundown'}.xlsx`);
} catch (error) {
console.error('Error downloading file:', error);
}
}
@@ -1,6 +1,5 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { IoCheckmark, IoCopy } from 'react-icons/io5';
import copyToClipboard from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils';
@@ -5,9 +5,9 @@
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(680px, 90vw);
min-width: min(880px, 90vw);
min-height: min(200px, 10vh);
max-width: min(900px, 90vw);
max-width: min(1200px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -1,5 +1,4 @@
import { IoApps } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { IoApps, IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
import { cx } from '../../../utils/styleUtils';
@@ -30,6 +30,7 @@
width: 40rem;
max-width: 100vw;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
@@ -55,7 +55,7 @@ export function useFlatRundown() {
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 || data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.entries[id]);
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
setFlatRundown(flatRundown);
setPrevRevision(data.revision);
}
@@ -175,7 +175,7 @@ describe('generateUrlPresetOptions', () => {
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
});
it('throws on on invalid route', () => {
it('throws on invalid route', () => {
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
});
});
@@ -77,7 +77,7 @@ export default function OntimeActionForm({
New time
<Input
{...register(`outputs.${index}.time`, {
required: { value: true, message: 'Required field' }, //TODO:(automation set aux) not sure what way around to have the string and where to have the ms value
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='eg: 10m5s'
@@ -13,5 +13,5 @@
}
.current {
background-color: $blue-1100;
background-color: $blue-1100 !important; // fighting zebra styles
}
@@ -1,10 +1,13 @@
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { IoAdd, IoDocumentOutline, IoDownloadOutline, IoEllipsisHorizontal, IoTrash } from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks';
import { downloadAsExcel } from '../../../../common/api/excel';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils';
@@ -55,6 +58,10 @@ export default function ManageRundowns() {
}
};
const handleDownloadXlsx = async (rundownId: string, title: string) => {
await downloadAsExcel(rundownId, title);
};
return (
<>
<Panel.Section>
@@ -93,19 +100,35 @@ export default function ManageRundowns() {
<td>
{title} {isLoaded && <Tag>Loaded</Tag>}
</td>
<Panel.InlineElements as='td'>
<Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}>
Load
</Button>
<Button
size='small'
variant='subtle-destructive'
onClick={() => openDelete(id)}
disabled={isLoaded}
<td>
<DropdownMenu
render={<IconButton variant='ghosted-white' />}
items={[
{
type: 'item',
icon: IoDownloadOutline,
label: 'Load',
onClick: () => openLoad(id),
disabled: isLoaded,
},
{
type: 'item',
icon: IoDocumentOutline,
label: 'Download .xlsx',
onClick: () => handleDownloadXlsx(id, title),
},
{
type: 'item',
icon: IoTrash,
label: 'Delete',
onClick: () => openDelete(id),
disabled: isLoaded,
},
]}
>
Delete
</Button>
</Panel.InlineElements>
<IoEllipsisHorizontal />
</DropdownMenu>
</td>
</tr>
);
})}
@@ -59,7 +59,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
timeWarning: namedImportMap['Time warning'],
timeDanger: namedImportMap['Time danger'],
custom,
entryId: namedImportMap.ID,
id: namedImportMap.ID,
};
}
+22 -22
View File
@@ -6,13 +6,10 @@ import ViewParamsEditor from '../../common/components/view-params-editor/ViewPar
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useSelectedEventId } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time';
import Loader from '../../views/common/loader/Loader';
import EditModal from './edit-modal/EditModal';
import FollowButton from './follow-button/FollowButton';
@@ -22,21 +19,31 @@ import StatusBar from './status-bar/StatusBar';
import { getOperatorOptions, useOperatorOptions } from './operator.options';
import type { EditEvent } from './operator.types';
import { getEventData } from './operator.utils';
import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss';
const selectedOffset = 50;
export default function Operator() {
const { data, rundownMetadata, status } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: projectData, status: projectDataStatus } = useProjectData();
export default function OperatorLoader() {
const { data, status } = useOperatorData();
const timeoutId = useRef<NodeJS.Timeout | null>(null);
useWindowTitle('Operator');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Operator {...data} />;
}
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const { selectedEventId } = useSelectedEventId();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false);
const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
@@ -52,7 +59,7 @@ export default function Operator() {
followTrigger: selectedEventId,
});
useWindowTitle('Operator');
const timeoutId = useRef<NodeJS.Timeout | null>(null);
// reset scroll if nothing is selected
useEffect(() => {
@@ -101,17 +108,10 @@ export default function Operator() {
setEditEvent({ ...event });
}, []);
const missingData = !data || !customFields || !projectData;
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
if (missingData || isLoading) {
return <EmptyPage text='Loading...' />;
}
const canEdit = shouldEdit && subscribe.length;
return (
@@ -126,8 +126,8 @@ export default function Operator() {
)}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{data.order.map((entryId) => {
const entry = data.entries[entryId];
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
@@ -172,12 +172,12 @@ export default function Operator() {
<Fragment key={entry.id}>
<OperatorGroup key={entry.id} title={entry.title} />
{entry.entries.map((nestedEntryId) => {
const nestedEntry = data.entries[nestedEntryId];
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[entryId];
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
@@ -1,13 +1,10 @@
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { getIsNavigationLocked } from '../../externals';
import Operator from './Operator';
export default function OperatorExport() {
return (
<ProtectRoute permission='operator'>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator />
</ProtectRoute>
);
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { CustomFields, OntimeEvent } from 'ontime-types';
import { CustomFields, EntryId, OntimeEvent } from 'ontime-types';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
@@ -9,6 +9,7 @@ import {
makeCustomFieldSelectOptions,
makeOptionsFromCustomFields,
} from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../viewers/common/viewUtils';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
@@ -93,15 +94,26 @@ type OperatorOptions = {
* Utility extract the view options from URL Params
* the names and fallback are manually matched with timerOptions
*/
function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions {
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): OperatorOptions {
// Helper to get value from either source, prioritizing defaultValues
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
// Helper to get array values from either source
const getArrayValues = (key: string): EntryId[] => {
if (defaultValues?.has(key)) {
return defaultValues.getAll(key) as EntryId[];
}
return searchParams.getAll(key) as EntryId[];
};
// we manually make an object that matches the key above
return {
mainSource: searchParams.get('main') as keyof OntimeEvent | null,
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
subscribe: searchParams.getAll('subscribe'),
shouldEdit: isStringBoolean(searchParams.get('shouldEdit')),
hidePast: isStringBoolean(searchParams.get('hidePast')),
showStart: isStringBoolean(searchParams.get('showStart')),
mainSource: getValue('main') as keyof OntimeEvent | null,
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
subscribe: getArrayValues('subscribe'),
shouldEdit: isStringBoolean(getValue('shouldEdit')),
hidePast: isStringBoolean(getValue('hidePast')),
showStart: isStringBoolean(getValue('showStart')),
};
}
@@ -110,6 +122,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions {
*/
export function useOperatorOptions(): OperatorOptions {
const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
const maybePreset = use(PresetContext);
const options = useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
return options;
}
@@ -0,0 +1,30 @@
import { CustomFields, Rundown, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { aggregateQueryStatus, ViewData } from '../../views/utils/viewLoader.utils';
export interface OperatorData {
rundown: Rundown;
rundownMetadata: RundownMetadataObject;
customFields: CustomFields;
settings: Settings;
}
export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
return {
data: {
rundown,
rundownMetadata,
customFields,
settings,
},
status: aggregateQueryStatus([rundownStatus, customFieldStatus, settingsStatus]),
};
}
@@ -2,16 +2,21 @@
display: flex;
align-items: center;
gap: 1rem;
height: 0;
position: relative;
height: 1px;
background: $blue-500;
z-index: $zindex-floating;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: $blue-500;
}
}
.addButton {
position: absolute;
top: 0;
left: 0;
transform: translateY(-50%);
position: relative;
}
@@ -14,12 +14,18 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
const { data: infoData } = useInfo();
const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined });
/**
* hostOptions are only used for local networks
* the NIF address is a local IP address: 192.168.x.x or 10.x.x.x
* We need to inject the port and protocol to create a valid URL
* eg: http://192.168.x.x:port
*/
const hostOptions = useMemo(() => {
return infoData.networkInterfaces.map((nif) => ({
value: nif.address,
value: `http://${nif.address}:${infoData.serverPort}`,
label: `${nif.name} - ${nif.address}`,
}));
}, [infoData.networkInterfaces]);
}, [infoData.networkInterfaces, infoData.serverPort]);
const pathOptions = useMemo(() => {
if (lockedPath) {
+1 -2
View File
@@ -1,6 +1,5 @@
import React from 'react';
import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router';
import { Routes } from 'react-router';
import { createRoutesFromChildren, matchRoutes, Routes, useLocation, useNavigationType } from 'react-router';
import * as Sentry from '@sentry/react';
import { ONTIME_VERSION } from './ONTIME_VERSION';
@@ -17,7 +17,7 @@ export const getBackstageOptions = (
customFields: CustomFields,
projectData: ProjectData,
): ViewOption[] => {
const customFieldOptions = makeOptionsFromCustomFields(customFields, []);
const customFieldOptions = makeOptionsFromCustomFields(customFields, [{ value: 'none', label: 'None' }]);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'note', label: 'Note' },
@@ -2,10 +2,16 @@
.notFound {
display: grid;
place-items: center;
height: 100vh;
padding-top: 20vh;
background-color: var(--background-color-override, $viewer-background-color);
margin-top: 10vh;
}
.content {
display: flex;
flex-direction: column;
color: $ui-white;
justify-content: center;
align-items: center;
text-align: center;
}
@@ -5,15 +5,17 @@ import style from './NotFound.module.scss';
export default function NotFound() {
return (
<div className={style.notFound}>
<EmptyImage />
<h1>Not found</h1>
<div>
The page you are after was not found.
<br />
It may have moved or your URL may be incorrect.
<br />
Double check the URL and try again.
</div>
<section className={style.content}>
<EmptyImage />
<h1>Not found</h1>
<div>
The page you are after was not found.
<br />
It may have moved or your URL may be incorrect.
<br />
Double check the URL and try again.
</div>
</section>
</div>
);
}
@@ -16,7 +16,7 @@ export const getScheduleOptions = (customFieldOptions: SelectOption[]): ViewOpti
description: 'Hide events without data in the selected custom field',
type: 'option',
values: customFieldOptions,
defaultValue: 'None',
defaultValue: 'none',
},
{
id: 'stopCycle',
@@ -216,7 +216,7 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) {
return <GhostedText>{initialValue}</GhostedText>;
return <GhostedText multiline>{initialValue}</GhostedText>;
}
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
+2 -2
View File
@@ -92,10 +92,10 @@
.end-message {
text-align: center;
font-size: 11.5vw;
line-height: 0.9em;
font-weight: 600;
color: var(--timer-overtime-color-override, $timer-finished-color);
padding: 0;
max-height: 100%;
}
.timer {
@@ -117,7 +117,7 @@
// use a class instead of a phase, to allow suppressing overtime style
&--finished {
color: var(--timer-colour, var(--timer-overtime-color-override, $timer-finished-color));
color: var(--timer-overtime-color-override, $timer-finished-color);
}
&[data-phase="warning"] {
+2 -2
View File
@@ -58,7 +58,7 @@ export function getEstimatedFontSize(stageTimer: string, secondaryContent?: stri
timerFontSize = fontSizeMap[stageTimerCharacters];
}
let externalFontSize = timerFontSize * 0.2;
let externalFontSize = timerFontSize * 0.325;
if (secondaryContent) {
// we need to shrink the timer if the external is going to be there
// this number has been tweaked to fit in a landscape mobile screen
@@ -105,7 +105,7 @@ export function getShowModifiers(
}
return {
showEndMessage: freezeOvertime && freezeMessage !== '',
showEndMessage: phase === TimerPhase.Overtime && freezeOvertime && freezeMessage !== '',
showFinished: phase === TimerPhase.Overtime,
showWarning: phase === TimerPhase.Warning,
showDanger: phase === TimerPhase.Danger,
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.0.0-beta.5",
"version": "4.0.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -25,11 +25,10 @@
"lint": "eslint . --quiet",
"dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac",
"dist-mac:local": "electron-builder --publish=never --mac -c.mac.identity=null",
"dist-linux": "electron-builder --publish=never --x64 --linux"
},
"build": {
"productName": "ontime-prerelease",
"productName": "ontime",
"appId": "no.lightdev.ontime",
"asar": true,
"dmg": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.0.0-beta.5",
"version": "4.0.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+9 -2
View File
@@ -13,8 +13,15 @@ export { SimplePlayback, SimpleDirection } from 'ontime-types';
// entries
export type { OntimeEvent, OntimeGroup, EntryCustomFields, CustomFields, Rundown } from 'ontime-types';
export { SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeDelay, isOntimeMilestone } from 'ontime-types';
export {
SupportedEntry,
isOntimeEvent,
isOntimeGroup,
isOntimeDelay,
isOntimeMilestone,
TimeStrategy,
} from 'ontime-types';
// functions
export { isWsPacketToClient } from './websocket.js';
export type { SocketSender } from './websocket.js';
export type { SocketSender } from './websocket.js';
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.0.0-beta.5",
"version": "4.0.2",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -199,7 +199,7 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
return {
type: 'ontime',
action: maybeOntimeAction.action,
time: maybeOntimeAction.time, //TODO:(automation set aux) not sure what way around to have the string and where to have the ms value
time: maybeOntimeAction.time,
};
}
+1 -1
View File
@@ -109,7 +109,7 @@ export async function projectDownload(req: Request, res: Response) {
return;
}
res.download(pathToFile, filename, (error) => {
res.download(pathToFile, filename, { dotfiles: 'allow' }, (error) => {
if (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
@@ -355,7 +355,7 @@ export function migrateRundown(
const newCustom: EntryCustomFields = {};
if (translationTable) {
Object.entries(custom).map(([key, value]) => {
Object.entries(custom).forEach(([key, value]) => {
const newKey = translationTable.get(key);
if (newKey) {
newCustom[newKey] = value;
@@ -0,0 +1,402 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`rundownToTabular() > returns an array of arrays describing a rundown 1`] = `
[
[
"ID",
"Flag",
"Cue",
"Title",
"Colour",
"Time Start",
"Time End",
"Duration",
"Link Start",
"Count to end",
"Note",
"Timer Type",
"End Action",
"Warning Time",
"Danger Time",
"Skip",
"Video Notes",
"Audio Notes",
"PowerPoint Name",
"PowerPoint Slide",
],
[
"e2163f",
"",
"Demo",
"Clear all, or Create New Project to start fresh",
"#9d9d9d",
"",
"",
"",
"",
"",
"Moderator - Emma Thompson
Speakers
- Liam Carter + Sophia Patel
- Ethan Brooks
- Lucas Bennett",
"milestone",
"",
"",
"",
"",
"",
"",
"",
"",
],
[
"7eaf99",
"",
"",
"Morning Sessions",
"#339E4E",
"10:00:00",
"12:00:00",
"",
"",
"",
"",
"group",
"",
"",
"",
"",
"",
"",
"",
"",
],
[
"9bf60f",
"",
"1",
"Pre-show Countdown",
"#77C785",
"10:00:00",
"11:00:00",
"01:00:00",
"",
"",
"Music plays, holding slide on screens",
"count-down",
"",
"00:10:00",
"00:05:00",
"",
"Camera + PowerPoint on stream
PowerPoint on screens",
"2x Wireless Hand Helds",
"HoldingSlide.pptx",
"https://www.getontime.no/images/aux/demo-slide1.webp",
],
[
"bf71a2",
"",
"Standby",
"10:45 - Presenters ready side stage",
"#A790F5",
"",
"",
"",
"",
"",
"",
"milestone",
"",
"",
"",
"",
"",
"",
"",
"",
],
[
"c2697f",
"",
"1.1",
"Welcome",
"#FFCC78",
"11:00:00",
"11:10:00",
"00:10:00",
"TRUE",
"",
"Emma Thompson",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"Cameras on stream
PowerPoint on screens",
"1x Wireless Hand Held",
"HoldingSlide.pptx",
"https://www.getontime.no/images/aux/demo-slide1.webp",
],
[
"fa593e",
"TRUE",
"1.2",
"Session 1",
"#77C785",
"11:10:00",
"12:00:00",
"00:50:00",
"TRUE",
"",
"Liam Carter, Sophia Patel + PowerPoint",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"Camera + PowerPoint on stream
PowerPoint on screens",
"2x Wireless Hand Helds",
"Session1.pptx",
"https://www.getontime.no/images/aux/demo-slide2.webp",
],
[
"a8b0b3",
"",
"House",
"11:30 - House staff setup lunch in lobby",
"#A790F5",
"",
"",
"",
"",
"",
"",
"milestone",
"",
"",
"",
"",
"",
"",
"",
"",
],
[
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"group-end",
"",
"",
"",
"",
],
[
"f60403",
"",
"",
"Lunch",
"#3E75E8",
"12:00:00",
"13:00:00",
"",
"",
"",
"",
"group",
"",
"",
"",
"",
"",
"",
"",
"",
],
[
"0aaa7d",
"",
"2.1",
"Lunch / Countdown to next session",
"#779BE7",
"12:00:00",
"13:00:00",
"01:00:00",
"TRUE",
"",
"Buffet in lobby",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"Holding slide on screens",
"House music",
"HoldingSlide.pptx",
"https://www.getontime.no/images/aux/demo-slide1.webp",
],
[
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"group-end",
"",
"",
"",
"",
],
[
"6b0edb",
"",
"",
"Afternoon Sessions",
"#339E4E",
"13:00:00",
"14:00:00",
"",
"",
"",
"",
"group",
"",
"",
"",
"",
"",
"",
"",
"",
],
[
"02afca",
"",
"Standby",
"12:45 - Presenters ready side stage",
"#A790F5",
"",
"",
"",
"",
"",
"",
"milestone",
"",
"",
"",
"",
"",
"",
"",
"",
],
[
"75ce86",
"",
"3.1",
"Session 2",
"#77C785",
"13:00:00",
"13:50:00",
"00:50:00",
"TRUE",
"",
"Ethan Brooks + PowerPoint + Video playback",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"Camera + PPT + Video on stream
PowerPoint + Video on screens
Video file: Session2.mp4",
"1x Wireless Hand Held
1x Video with audio",
"Session2.pptx",
"https://www.getontime.no/images/aux/demo-slide3.webp",
],
[
"e10ed9",
"",
"3.2",
"Wrap up",
"#FFCC78",
"13:50:00",
"14:00:00",
"00:10:00",
"",
"",
"Lucas Bennett",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"Holding slide on screens",
"1x Wireless Hand Held",
"HoldingSlide.pptx",
"https://www.getontime.no/images/aux/demo-slide1.webp",
],
[
"07df89",
"",
"Strike",
"14:00 - AV & Room strike",
"#A790F5",
"",
"",
"",
"",
"",
"",
"milestone",
"",
"",
"",
"",
"",
"",
"",
"",
],
[
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"group-end",
"",
"",
"",
"",
],
]
`;
@@ -1,11 +1,13 @@
import { CustomFields } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils';
import { getCustomFieldData } from '../excel.utils.js';
import { demoDb } from '../../../models/demoProject.js';
import { getCustomFieldData, rundownToTabular } from '../excel.utils.js';
describe('getCustomFieldData()', () => {
it('generates a list of keys from the given import map', () => {
const importMap = {
const importMap: ImportMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
@@ -27,8 +29,8 @@ describe('getCustomFieldData()', () => {
sound: 'sound',
video: 'av',
},
entryId: 'id',
} as ImportMap;
id: 'id',
};
const result = getCustomFieldData(importMap, {});
expect(result.mergedCustomFields).toStrictEqual({
@@ -58,7 +60,7 @@ describe('getCustomFieldData()', () => {
});
it('keeps colour information from existing fields', () => {
const importMap = {
const importMap: ImportMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
@@ -81,8 +83,8 @@ describe('getCustomFieldData()', () => {
video: 'av',
'ontime key': 'excel label',
},
entryId: 'id',
} as ImportMap;
id: 'id',
};
const existingCustomFields: CustomFields = {
lighting: { label: 'lighting', type: 'text', colour: 'red' },
@@ -160,3 +162,13 @@ describe('getCustomFieldData()', () => {
});
});
});
describe('rundownToTabular()', () => {
it('returns an array of arrays describing a rundown', () => {
const testRundown = demoDb.rundowns['default'];
const testCustomFields = demoDb.customFields;
const result = rundownToTabular(testRundown, testCustomFields);
expect(result).toMatchSnapshot();
});
});
@@ -0,0 +1 @@
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
@@ -3,7 +3,7 @@ import multer, { type FileFilterCallback } from 'multer';
import { storage } from '../../utils/upload.js';
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
import { EXCEL_MIME } from './excel.constants.js';
const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(EXCEL_MIME)) {
@@ -132,7 +132,7 @@ export const parseExcel = (
entry.timeDanger = parseExcelDate(column);
} else if (j === indexMap.colour) {
entry.colour = makeString(column, '');
} else if (j === indexMap.entryId) {
} else if (j === indexMap.id) {
entry.id = encodeURIComponent(makeString(column, undefined));
} else if (j in indexMap.custom) {
const importKey = indexMap.custom[j];
+24 -4
View File
@@ -1,9 +1,14 @@
import express from 'express';
import type { Request, Response } from 'express';
import { uploadExcel } from './excel.middleware.js';
import { validateFileExists, validateImportMapOptions } from './excel.validation.js';
import { CustomFields, ErrorResponse, Rundown } from 'ontime-types';
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { uploadExcel } from './excel.middleware.js';
import { validateFileExists, validateImportMapOptions, validateRundownExport } from './excel.validation.js';
import { generateExcelFile, generateRundownPreview, listWorksheets, readExcelFile } from './excel.service.js';
import { EXCEL_MIME } from './excel.constants.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export const router = express.Router();
@@ -11,7 +16,7 @@ router.post('/upload', uploadExcel, validateFileExists, async (req: Request, res
try {
// file has been validated by middleware
const filePath = (req.file as Express.Multer.File).path;
await saveExcelFile(filePath);
await readExcelFile(filePath);
res.status(201).send();
} catch (error) {
res.status(500).send({ message: String(error) });
@@ -40,3 +45,18 @@ router.post(
}
},
);
router.get('/:rundownId/export', validateRundownExport, (req: Request, res: Response) => {
try {
const rundown = getDataProvider().getRundown(req.params.rundownId);
const customFields = getProjectCustomFields();
const buffer = generateExcelFile(rundown, customFields);
res.setHeader('Content-Disposition', 'attachment;');
res.setHeader('Content-Type', EXCEL_MIME);
res.setHeader('Content-Length', buffer.length.toString());
res.status(200).send(buffer);
} catch (error) {
res.status(500).send({ message: 'Failed to generate Excel file' });
}
});
@@ -18,10 +18,15 @@ import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import { parseExcel } from './excel.parser.js';
import { rundownToTabular } from './excel.utils.js';
let excelData: WorkBook = xlsx.utils.book_new();
export async function saveExcelFile(filePath: string) {
/**
* Receives and parses an excel file
* The file is deleted after being read
*/
export async function readExcelFile(filePath: string) {
if (!existsSync(filePath)) {
throw new Error('Upload of excel file failed');
}
@@ -33,6 +38,9 @@ export async function saveExcelFile(filePath: string) {
await deleteFile(filePath);
}
/**
* List all worksheets in the current spreadsheet file
*/
export function listWorksheets(): string[] {
return excelData.SheetNames;
}
@@ -60,3 +68,19 @@ export function generateRundownPreview(options: ImportMap): { rundown: Rundown;
return { rundown, customFields: parsedCustomFields };
}
/**
* Creates an xlsx file from a given rundown and custom fields
* @throws if the rundown is empty
*/
export function generateExcelFile(rundown: Rundown, customFields: CustomFields): Buffer {
if (rundown.order.length === 0) {
throw new Error('Cannot generate an Excel file from an empty rundown');
}
const workbook = xlsx.utils.book_new();
const worksheet = xlsx.utils.aoa_to_sheet(rundownToTabular(rundown, customFields));
xlsx.utils.book_append_sheet(workbook, worksheet, rundown.title || 'Rundown');
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
}
+173 -5
View File
@@ -1,5 +1,5 @@
import { CustomFieldKey, CustomFields, MaybeNumber } from 'ontime-types';
import { checkRegex, customFieldLabelToKey, ImportMap } from 'ontime-utils';
import { CustomFieldKey, CustomFields, MaybeNumber, OntimeEntry, Rundown } from 'ontime-types';
import { checkRegex, customFieldLabelToKey, ImportMap, millisToString } from 'ontime-utils';
/**
* Receives an import map which contains custom field labels and a custom fields object
@@ -87,7 +87,7 @@ export function generateImportHandlers(importMap: ImportMap) {
timeDanger: null,
endAction: null,
timerType: null,
entryId: null,
id: null,
custom: {},
};
@@ -155,8 +155,8 @@ export function generateImportHandlers(importMap: ImportMap) {
indexMap.timeDanger = col;
sheetMetadata.timeDanger = { row, col };
},
[importMap.entryId]: (row: number, col: number) => {
indexMap.entryId = col;
[importMap.id]: (row: number, col: number) => {
indexMap.id = col;
sheetMetadata['id'] = { row, col }; // important this will be used in a normal context where the id is not called entryId
},
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
@@ -167,3 +167,171 @@ export function generateImportHandlers(importMap: ImportMap) {
return { handlers, indexMap, sheetMetadata };
}
const emptyCellValue = '';
/**
* Converts a given rundown to 2D array tabular format
* All fields are converted to their string representation as specified by excel
*/
export function rundownToTabular(rundown: Rundown, customFields: CustomFields): string[][] {
const flatRundown: string[][] = [];
// we reuse the keys from the import map so import <> export works by default
const keys = Object.keys(matchedWithDefaultImportMap) as (keyof typeof matchedWithDefaultImportMap)[];
const customFieldKeys = Object.keys(customFields) as CustomFieldKey[];
// create header row
const headerRow: string[] = [];
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
headerRow.push(matchedWithDefaultImportMap[key]);
}
for (let i = 0; i < customFieldKeys.length; i++) {
const key = customFieldKeys[i];
headerRow.push(customFields[key].label);
}
flatRundown.push(headerRow);
// create table body
let previousEntry: OntimeEntry | undefined;
for (let i = 0; i < rundown.flatOrder.length; i++) {
const entryId = rundown.flatOrder[i];
const entry = rundown.entries[entryId];
if (!entry) continue;
// we opt for not exporting delays as they are only relevant for runtime
if (entry.type === 'delay') continue;
// we need a group-end if:
// - the previous entry was a group and now we have another group (both parents are undefined)
// - the previous entry had a parent and the new entry is a group (parent undefined)
// - the previous entry had a parent and the new one doesnt (parent null)
if (
previousEntry &&
((previousEntry.type === 'group' && entry.type === 'group') ||
// @ts-expect-error -- we safely check if the property is nullish
(previousEntry?.parent !== null && entry?.parent == null))
) {
addGroupEnd();
}
previousEntry = entry;
const entryRow: string[] = [];
// each entry is a row in the table
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
// handle edge cases for group
if (entry.type === 'group') {
if (key === 'duration') {
const groupDuration = entry.targetDuration === null ? '' : parseField('duration', entry.targetDuration);
entryRow.push(groupDuration);
continue;
} else if (key === 'timerType') {
entryRow.push('group');
continue;
}
}
// handle edge cases for milestones
if (entry.type === 'milestone') {
if (key === 'timerType') {
entryRow.push('milestone');
continue;
}
}
// handle edge cases for events
if (entry.type === 'event') {
if (key === 'endAction' && entry.endAction === 'none') {
entryRow.push(emptyCellValue);
continue;
}
}
if (key in entry) {
entryRow.push(parseField(key, entry[key as keyof typeof entry]));
} else {
// we push an empty cell to keep the column positioning
entryRow.push(emptyCellValue);
}
}
// add custom fields
if ('custom' in entry) {
for (let i = 0; i < customFieldKeys.length; i++) {
const customFieldKey = customFieldKeys[i] as CustomFieldKey;
const value = entry.custom?.[customFieldKey] ?? emptyCellValue;
entryRow.push(String(value));
}
}
flatRundown.push(entryRow);
}
// close dangling groups
// - last element as a group: previous entry.parent is undefined
// - last element was in a group: previous entry.parent is string
if (previousEntry && (previousEntry.type === 'group' || previousEntry.parent !== null)) {
addGroupEnd();
}
/**
* Adds a new row with group-end timerType
* Ensures that all columns are filled with the correct type
*/
function addGroupEnd() {
const emptyRow = Array(keys.length).fill(emptyCellValue);
const timerTypeIndex = keys.indexOf('timerType');
emptyRow[timerTypeIndex] = 'group-end';
flatRundown.push(emptyRow);
}
return flatRundown;
}
/**
* Map between the ontime key and the excel column name
* This needs to match match the default import map
* TODO: could we organise these objects to have a single source of truth?
*/
const matchedWithDefaultImportMap = {
id: 'ID',
flag: 'Flag',
cue: 'Cue',
title: 'Title',
colour: 'Colour',
timeStart: 'Time Start',
timeEnd: 'Time End',
duration: 'Duration',
linkStart: 'Link Start',
countToEnd: 'Count to end',
note: 'Note',
timerType: 'Timer Type',
endAction: 'End Action',
timeWarning: 'Warning Time',
timeDanger: 'Danger Time',
skip: 'Skip',
};
/**
* Parses a fields value into a string suitable for excel
*/
function parseField(field: string, data: unknown) {
if (data == null) return emptyCellValue;
if (
field === 'timeStart' ||
field === 'timeEnd' ||
field === 'duration' ||
field === 'timeWarning' ||
field === 'timeDanger'
) {
return millisToString(data as MaybeNumber, { fallback: emptyCellValue });
}
if (typeof data === 'boolean') {
return data ? 'TRUE' : emptyCellValue;
}
return typeof data == 'string' ? data : String(data);
}
@@ -1,6 +1,7 @@
import { isImportMap } from 'ontime-utils';
import { body } from 'express-validator';
import { body, param } from 'express-validator';
import {
requestValidationFunction,
requestValidationFunctionWithFile,
@@ -17,3 +18,5 @@ export const validateImportMapOptions = [
requestValidationFunction,
];
export const validateRundownExport = [param('rundownId').isString().trim().notEmpty(), requestValidationFunction];
@@ -80,6 +80,18 @@ describe('generateAuthenticatedUrl()', () => {
});
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/?token=1234');
});
it('throws if provided an IP address without protocol', () => {
expect(() =>
generateShareUrl('192.168.10.173', '<<companion>>', {
lockConfig: false,
lockNav: false,
authenticate: true,
preset: undefined,
hash: '1234',
}),
).toThrowError('Invalid URL');
});
});
describe('for ontime-cloud URLs', () => {
@@ -62,6 +62,11 @@ export function generateShareUrl(
canonicalPath: string,
{ authenticate, lockConfig, lockNav, preset, prefix = routerPrefix, hash = hashedPassword }: LinkOptions,
): URL {
/**
* URL constructor will throw if given an IP address without protocol
* for the case of IP addresses, we expect that the base URL provides protocol and port
* eg: http://192.168.10.1:4001
*/
const url = new URL(baseUrl);
// companion links point to the root
@@ -1,20 +1,19 @@
import { EndAction, TimerType, isKeyOfType } from 'ontime-types';
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
import { EndAction, TimeStrategy, TimerType, isKeyOfType } from 'ontime-types';
import { maxDuration } from 'ontime-utils';
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
/**
*
* @param {number} value time amount in seconds
* @param {number} value time amount in milliseconds
* @returns {number} time in milliseconds clamped to 0 and max duration
*/
function clampDuration(value: number): number {
const valueInMillis = value * MILLIS_PER_SECOND;
if (valueInMillis > maxDuration || valueInMillis < 0) {
if (value > maxDuration || value < 0) {
throw new Error('Times should be from 0 to 23:59:59');
}
return valueInMillis;
return value;
}
const propertyConversion = {
@@ -34,6 +33,9 @@ const propertyConversion = {
endAction: (value: unknown) => coerceEnum<EndAction>(value, EndAction),
timerType: (value: unknown) => coerceEnum<TimerType>(value, TimerType),
linkStart: coerceBoolean,
timeStrategy: (value: unknown) => coerceEnum<TimeStrategy>(value, TimeStrategy),
duration: (value: unknown) => clampDuration(coerceNumber(value)),
timeStart: (value: unknown) => clampDuration(coerceNumber(value)),
timeEnd: (value: unknown) => clampDuration(coerceNumber(value)),
+2 -2
View File
@@ -123,8 +123,8 @@ function formatTimer(number) {
* @param {number} number - The number to pad
* @returns {string} The padded number string
*/
function leftPad(number) {
return Math.floor(number).toString().padStart(2, '0');
function leftPad(val) {
return Math.floor(val).toString().padStart(2, '0');
}
}
+51 -17
View File
@@ -28,13 +28,9 @@ export const demoDb: DatabaseModel = {
type: SupportedEntry.Milestone,
cue: 'Demo',
title: 'Clear all, or Create New Project to start fresh',
note: 'Go to Settings cog wheel top left of Editor, Project > Create',
note: 'Moderator - Emma Thompson\n\nSpeakers\n- Liam Carter + Sophia Patel\n- Ethan Brooks\n- Lucas Bennett',
colour: '#9d9d9d',
custom: {
Custom_Field:
'Create Custom Fields to store specific text information or images.\n\nGo to Settings > Project data > Custom fields',
Images: 'https://www.getontime.no/images/icons/ontime-logo.png',
},
custom: {},
parent: null,
revision: 0,
},
@@ -78,7 +74,10 @@ export const demoDb: DatabaseModel = {
timeWarning: 600000,
timeDanger: 300000,
custom: {
Custom_Field: 'Put additional info here',
PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide1.webp',
Video_Notes: 'Camera + PowerPoint on stream\nPowerPoint on screens',
Audio_Notes: '2x Wireless Hand Helds',
PowerPoint_Name: 'HoldingSlide.pptx',
},
triggers: [],
},
@@ -117,7 +116,12 @@ export const demoDb: DatabaseModel = {
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
custom: {
PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide1.webp',
Video_Notes: 'Cameras on stream\nPowerPoint on screens',
Audio_Notes: '1x Wireless Hand Held',
PowerPoint_Name: 'HoldingSlide.pptx',
},
triggers: [],
},
fa593e: {
@@ -144,7 +148,12 @@ export const demoDb: DatabaseModel = {
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
custom: {
PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide2.webp',
Video_Notes: 'Camera + PowerPoint on stream\nPowerPoint on screens',
Audio_Notes: '2x Wireless Hand Helds',
PowerPoint_Name: 'Session1.pptx',
},
triggers: [],
},
a8b0b3: {
@@ -187,7 +196,7 @@ export const demoDb: DatabaseModel = {
timerType: TimerType.CountDown,
countToEnd: false,
skip: false,
note: 'Buffet in lobby\nMusic plays, holding slide on screens',
note: 'Buffet in lobby',
colour: '#779BE7',
delay: 0,
dayOffset: 0,
@@ -197,7 +206,12 @@ export const demoDb: DatabaseModel = {
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
custom: {
PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide1.webp',
Video_Notes: 'Holding slide on screens',
Audio_Notes: 'House music',
PowerPoint_Name: 'HoldingSlide.pptx',
},
triggers: [],
},
'6b0edb': {
@@ -250,7 +264,12 @@ export const demoDb: DatabaseModel = {
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
custom: {
PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide3.webp',
Video_Notes: 'Camera + PPT + Video on stream\nPowerPoint + Video on screens\n\nVideo file: Session2.mp4',
Audio_Notes: '1x Wireless Hand Held\n1x Video with audio',
PowerPoint_Name: 'Session2.pptx',
},
triggers: [],
},
e10ed9: {
@@ -277,7 +296,12 @@ export const demoDb: DatabaseModel = {
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
custom: {
PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide1.webp',
Video_Notes: 'Holding slide on screens',
Audio_Notes: '1x Wireless Hand Held',
PowerPoint_Name: 'HoldingSlide.pptx',
},
triggers: [],
},
'07df89': {
@@ -341,15 +365,25 @@ export const demoDb: DatabaseModel = {
},
],
customFields: {
Custom_Field: {
Video_Notes: {
type: 'text',
colour: '#FFAB33',
label: 'Video Notes',
},
Audio_Notes: {
type: 'text',
colour: '#339E4E',
label: 'Audio Notes',
},
PowerPoint_Name: {
type: 'text',
colour: '#3E75E8',
label: 'Custom Field',
label: 'PowerPoint Name',
},
Images: {
PowerPoint_Slide: {
type: 'image',
colour: '#ED3333',
label: 'Images',
label: 'PowerPoint Slide',
},
},
automation: {
@@ -50,7 +50,7 @@ export function isRestorePoint(restorePoint: unknown): restorePoint is RestorePo
return false;
}
if (!is.number(restorePoint.startEpoch) && restorePoint.startEpoch !== null) {
if (!is.number(restorePoint.startEpoch) && restorePoint.startEpoch !== null) {
return false;
}
@@ -273,13 +273,14 @@ class RuntimeService {
public startByCue(cue: string): boolean {
const state = runtimeState.getState();
const rundown = getCurrentRundown();
const { playableEventOrder } = getRundownMetadata();
const { timedEventOrder } = getRundownMetadata();
const event = findNextPlayableWithCue(
rundown,
playableEventOrder,
timedEventOrder,
cue,
state.rundown.selectedEventIndex ?? undefined,
state.timer.playback === Playback.Armed, // If we are armed allow the armed event to be considered for playback
);
if (!event) {
@@ -341,6 +342,7 @@ class RuntimeService {
playableEventOrder,
cue,
state.rundown.selectedEventIndex ?? undefined,
false,
);
if (!event) {
@@ -118,24 +118,25 @@ export function findNextPlayableId(playableEventsOrder: EntryId[], currentEventI
*/
export function findNextPlayableWithCue(
rundown: Rundown,
playableEventsOrder: EntryId[],
timedEventsOrder: EntryId[],
targetCue: string,
currentEventIndex = 0,
allowCurrent = false,
): OntimeEvent | undefined {
const lowerCaseCue = targetCue.toLowerCase();
const startFromIndex = allowCurrent ? currentEventIndex : currentEventIndex + 1;
for (let i = currentEventIndex; i < playableEventsOrder.length; i++) {
const eventId = playableEventsOrder[i];
for (let i = startFromIndex; i < timedEventsOrder.length; i++) {
const eventId = timedEventsOrder[i];
const event = rundown.entries[eventId];
if (isOntimeEvent(event) && isPlayableEvent(event) && event.cue.toLowerCase() === lowerCaseCue) {
if (isOntimeEvent(event) && isPlayableEvent(event) && event.cue === targetCue) {
return event;
}
}
for (let i = 0; i < currentEventIndex; i++) {
const eventId = playableEventsOrder[i];
for (let i = 0; i < startFromIndex; i++) {
const eventId = timedEventsOrder[i];
const event = rundown.entries[eventId];
if (isOntimeEvent(event) && isPlayableEvent(event) && event.cue.toLowerCase() === lowerCaseCue) {
if (isOntimeEvent(event) && isPlayableEvent(event) && event.cue === targetCue) {
return event;
}
}
@@ -8,6 +8,7 @@ import {
AuthenticationStatus,
CustomFields,
DatabaseModel,
EntryId,
isOntimeEvent,
isOntimeMilestone,
LogOrigin,
@@ -360,7 +361,7 @@ export async function upload(sheetId: string, options: ImportMap) {
const rundown = getCurrentRundown();
const sheetOrder: string[] = [];
let prevGroup: string | null = null;
let prevGroup: EntryId | null = null;
for (const id of rundown.flatOrder) {
const entry = rundown.entries[id];
@@ -376,7 +377,7 @@ export async function upload(sheetId: string, options: ImportMap) {
const titleMetadata = Object.values(sheetMetadata)[0];
if (titleMetadata === undefined) {
throw new Error(`Sheet read failed: failed to find title row`);
throw new Error('Sheet read failed: failed to find title row');
}
const titleRow = titleMetadata['row'];
const updateRundown = Array<sheets_v4.Schema$Request>();
@@ -418,12 +419,12 @@ export async function upload(sheetId: string, options: ImportMap) {
const isGroupEnd = entryId.startsWith('group-end-');
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
const entry = isGroupEnd
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
: structuredClone(rundown.entries[id]);
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
: structuredClone(rundown.entries[id]);
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
});
} catch (e) {
throw new Error(`Sheet write failed to correctly parse rundown: ${e}`)
throw new Error(`Sheet write failed to correctly parse rundown: ${e}`);
}
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
+4 -4
View File
@@ -11,14 +11,14 @@ describe('parseExcelDate', () => {
['1899-12-30T07:00:00.000Z', 28800000],
['1899-12-30T08:00:10.000Z', 32410000],
['1899-12-30T08:30:00.000Z', 34200000],
])(`handles %s`, (fromExcel, expected) => {
])('handles %s', (fromExcel, expected) => {
expect(parseExcelDate(fromExcel)).toBe(expected);
});
});
describe('parses a time string that passes validation', () => {
test.each([['10:00:00'], ['10:00'], ['10:00AM'], ['10:00am'], ['10:00PM'], ['10:00pm']])(
`handles %s`,
'handles %s',
(fromExcel) => {
expect(parseExcelDate(fromExcel)).not.toBe(0);
},
@@ -26,13 +26,13 @@ describe('parseExcelDate', () => {
});
describe('uses numeric fields as minutes', () => {
test.each([[1], [10], [100]])(`handles numeric fields %s`, (fromExcel) => {
test.each([[1], [10], [100]])('handles numeric fields %s', (fromExcel) => {
expect(parseExcelDate(fromExcel)).toBe(fromExcel * MILLIS_PER_MINUTE);
});
});
describe('returns 0 on other strings', () => {
test.each([['test'], [''], ['x']])(`handles invalid fields %s`, (fromExcel) => {
test.each([['test'], [''], ['x']])('handles invalid fields %s', (fromExcel) => {
expect(parseExcelDate(fromExcel)).toBe(0);
});
});
+11 -15
View File
@@ -1,11 +1,10 @@
import { expect, test } from '@playwright/test';
// TODO: can we replace the clicks with something else?
test.describe('pages routes are available', () => {
test.use({ viewport: { width: 1920, height: 1080 } });
test.describe('main views', () => {
test('editor', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
await page.goto('/editor');
await expect(page).toHaveTitle(/ontime/);
await expect(page.getByTestId('editor-container')).toBeVisible();
@@ -15,44 +14,41 @@ test.describe('pages routes are available', () => {
});
test('cuesheet', async ({ page }) => {
await page.goto('http://localhost:4001/cuesheet');
await page.goto('/cuesheet');
await expect(page).toHaveTitle(/ontime/);
await page.getByTestId('cuesheet').click();
await expect(page.getByTestId('cuesheet')).toBeVisible();
});
test('operator', async ({ page }) => {
await page.goto('http://localhost:4001/op');
await page.goto('/op');
await expect(page).toHaveTitle(/ontime/);
});
test('timer', async ({ page }) => {
await page.goto('http://localhost:4001/timer');
await page.goto('/timer');
await expect(page).toHaveTitle(/ontime/);
});
test('backstage', async ({ page }) => {
await page.goto('http://localhost:4001/backstage');
await page.goto('/backstage');
await expect(page).toHaveTitle(/ontime/);
});
test('studio', async ({ page }) => {
await page.goto('http://localhost:4001/studio');
await page.goto('/studio');
await expect(page).toHaveTitle(/ontime/);
});
test('countdown', async ({ page }) => {
await page.goto('http://localhost:4001/countdown');
await page.goto('/countdown?sub=32d31');
await expect(page).toHaveTitle(/ontime/);
await page.getByRole('button', { name: 'Add' }).click();
await page.getByText('Albania').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Albania')).toBeVisible();
await expect(page.getByText('Latvia')).toBeHidden();
});
@@ -60,15 +56,15 @@ test.describe('pages routes are available', () => {
test.describe('detached views', () => {
test('rundown', async ({ page }) => {
await page.goto('http://localhost:4001/rundown');
await page.goto('/rundown');
await expect(page.getByTestId('panel-rundown')).toBeVisible();
});
test('timer control', async ({ page }) => {
await page.goto('http://localhost:4001/timercontrol');
await page.goto('/timercontrol');
await expect(page.getByTestId('panel-timer-control')).toBeVisible();
});
test('message control', async ({ page }) => {
await page.goto('http://localhost:4001/messagecontrol');
await page.goto('/messagecontrol');
await expect(page.getByTestId('panel-messages-control')).toBeVisible();
});
});
+11 -10
View File
@@ -2,11 +2,16 @@ import { test, expect } from '@playwright/test';
test('smoke test operator', async ({ page }) => {
// make some boilerplate
await page.goto('http://localhost:4001/editor');
await page.goto('/editor');
await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByRole('button', { name: 'Create Group' }).click();
await page.getByTestId('rundown-group').getByTestId('entry__title').click();
await page.getByTestId('rundown-group').getByTestId('entry__title').fill('group 1');
await page.getByTestId('rundown-group').getByTestId('entry__title').press('Enter');
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await page.getByTestId('time-input-timeStart').fill('1m');
await page.getByTestId('time-input-timeStart').press('Enter');
@@ -24,10 +29,9 @@ test('smoke test operator', async ({ page }) => {
await page.getByTestId('entry-3').getByTestId('time-input-duration').fill('1m');
await page.getByTestId('entry-3').getByTestId('time-input-duration').press('Enter');
await page.getByRole('button', { name: 'Group' }).nth(1).click();
await page.getByRole('button', { name: 'Edit' }).click();
await page.getByTestId('entry-1').click();
await page.getByTestId('editor-container').getByLabel('Cue').fill('--1');
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await page.getByTestId('entry-1').getByTestId('entry__title').click();
@@ -51,14 +55,11 @@ test('smoke test operator', async ({ page }) => {
// start an event
await page.getByTestId('panel-timer-control').getByRole('button', { name: 'Start' }).click();
await page.goto('http://localhost:4001/op');
await page.goto('/op');
await expect(page.getByText('group 1')).toBeInViewport();
await expect(page.getByText('title 1')).toBeInViewport();
await expect(page.getByTestId('--1')).toHaveCSS('opacity', '1'); // BUG: ensure event doesn't inherit the past state of the group
await expect(page.getByText('title 2')).toBeInViewport();
await expect(page.getByText('title 3')).toBeInViewport();
// TODO: this part seems particularly flaky, to revise
// await expect(page.getByText('00:01 - 00:02')).toBeInViewport();
// await expect(page.getByText('00:02 - 00:03')).toBeInViewport();
// await expect(page.getByText('00:03 - 00:04')).toBeInViewport();
});
@@ -1,7 +1,5 @@
import { expect, test } from '@playwright/test';
//TODO:
test('show warning when event crosses midnight', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, Locator, Page, test } from '@playwright/test';
import { expect, Locator, test } from '@playwright/test';
test('time until absolute', async ({ context }) => {
const editor = await context.newPage();
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.0.0-beta.5",
"version": "4.0.2",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -27,6 +27,7 @@
"typecheck": "turbo run typecheck",
"build": "turbo run build",
"build:docker": "cross-env NODE_ENV=docker turbo run build --filter=ontime-server --filter=ontime-ui",
"build:resolver": "cross-env turbo run build --filter=@getontime/resolver",
"dist-win": "turbo run dist-win",
"dist-mac": "turbo run dist-mac",
"dist-linux": "turbo run dist-linux",
@@ -1,4 +1,3 @@
import type { AutomationSettings } from './core/Automation.type.js';
import type { CustomFields } from './core/CustomFields.type.js';
import type { ProjectData } from './core/ProjectData.type.js';
@@ -90,7 +90,7 @@ export type OntimeAction =
| {
type: 'ontime';
action: OntimeAuxSetAction;
time: string; //TODO:(automation set aux) not sure what way around to have the string and where to have the ms value
time: string;
}
| {
type: 'ontime';
+1 -1
View File
@@ -14,7 +14,7 @@ export {
type OntimeEvent,
type PlayableEvent,
type TimeField,
SupportedEntry as SupportedEntry,
SupportedEntry,
} from './definitions/core/OntimeEntry.js';
export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/core/Rundown.type.js';
export { TimeStrategy } from './definitions/TimeStrategy.type.js';
@@ -21,7 +21,7 @@ describe('isImportMap()', () => {
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {},
entryId: 'id',
id: 'id',
};
expect(isImportMap(importMap)).toBe(true);
@@ -72,7 +72,7 @@ describe('isImportMap()', () => {
userDefined: 'userDefined',
anotherOne: 'anotherOne',
},
entryId: 'id',
id: 'id',
};
expect(isImportMap(importMap)).toBe(true);
@@ -21,7 +21,7 @@ export const defaultImportMap = {
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {},
entryId: 'id',
id: 'id',
};
/**
-4
View File
@@ -46,10 +46,6 @@
"dependsOn": ["build"],
"outputs": ["dist/**"]
},
"dist-mac:local": {
"dependsOn": ["build"],
"outputs": ["dist/**"]
},
"dist-linux": {
"dependsOn": ["build"],
"outputs": ["dist/**"]