mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa927597f3 | |||
| ef7f0fb54a | |||
| 324320fdc8 | |||
| 4b27d080b6 | |||
| 7918063753 | |||
| e2202c2417 | |||
| 9817c8f8c0 | |||
| 1d8c04d0b4 | |||
| 10762f10bf | |||
| da7c0d4ff2 | |||
| 4a27973de1 | |||
| bdeb087e28 | |||
| 3662cd58f5 | |||
| 62c52bca07 | |||
| 1fc4a06133 | |||
| 147862e647 | |||
| 12bc323e57 |
@@ -17,7 +17,6 @@
|
|||||||
# Ignore build folders
|
# Ignore build folders
|
||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
|
||||||
|
|
||||||
# Ignore default volumes created by running docker compose up
|
# Ignore default volumes created by running docker compose up
|
||||||
ontime-db
|
ontime-db
|
||||||
|
|||||||
@@ -45,9 +45,13 @@ jobs:
|
|||||||
- name: Copy external
|
- name: Copy external
|
||||||
run: cp -R apps/server/src/external apps/cli/external
|
run: cp -R apps/server/src/external apps/cli/external
|
||||||
|
|
||||||
|
- name: Setup pnpm auth config
|
||||||
|
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||||
|
|
||||||
- name: Publish to NPM
|
- name: Publish to NPM
|
||||||
run: pnpm publish --access public --no-git-checks
|
run: pnpm publish --access public --no-git-checks
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
NPM_CONFIG_PROVENANCE: true
|
NPM_CONFIG_PROVENANCE: true
|
||||||
working-directory: ./apps/cli
|
working-directory: ./apps/cli
|
||||||
|
|||||||
@@ -6,43 +6,63 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
publish_docker:
|
publish_docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
CI: ''
|
CI: ''
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Docker Login
|
- name: Setup pnpm
|
||||||
uses: docker/login-action@v2.1.0
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Docker Setup Buildx
|
- name: Get pnpm store directory
|
||||||
uses: docker/setup-buildx-action@v2.5.0
|
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build and push stable release
|
- name: Setup pnpm cache
|
||||||
if: github.event.release.prerelease == false
|
uses: actions/cache@v4
|
||||||
uses: docker/build-push-action@v4.0.0
|
with:
|
||||||
with:
|
path: ${{ env.PNPM_STORE_PATH }}
|
||||||
context: .
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
file: ./Dockerfile
|
restore-keys: |
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
${{ runner.os }}-pnpm-store-
|
||||||
# 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
|
- name: Install dependencies
|
||||||
if: github.event.release.prerelease == true
|
run: pnpm install --frozen-lockfile
|
||||||
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: 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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Ontime build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: [ "*" ]
|
tags: ['*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -34,9 +34,13 @@ jobs:
|
|||||||
- name: Build project packages
|
- name: Build project packages
|
||||||
run: pnpm build:resolver
|
run: pnpm build:resolver
|
||||||
|
|
||||||
|
- name: Setup pnpm auth config
|
||||||
|
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||||
|
|
||||||
- name: Publish to NPM
|
- name: Publish to NPM
|
||||||
run: pnpm publish --access public --no-git-checks
|
run: pnpm publish --access public --no-git-checks
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
NPM_CONFIG_PROVENANCE: true
|
NPM_CONFIG_PROVENANCE: true
|
||||||
working-directory: ./apps/resolver
|
working-directory: ./apps/resolver
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cache/ms-playwright
|
~/.cache/ms-playwright
|
||||||
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-playwright-
|
${{ runner.os }}-playwright-
|
||||||
|
|||||||
+2
-1
@@ -5,8 +5,9 @@ node_modules
|
|||||||
|
|
||||||
playwright-report
|
playwright-report
|
||||||
|
|
||||||
|
|
||||||
|
pnpm-lock.yaml
|
||||||
**/*.toml
|
**/*.toml
|
||||||
**/*.yml
|
|
||||||
**/*.json
|
**/*.json
|
||||||
!tsconfig.common.json
|
!tsconfig.common.json
|
||||||
!turbo.json
|
!turbo.json
|
||||||
|
|||||||
+1
-3
@@ -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
|
From the project root, run the following commands
|
||||||
- __Install the project dependencies__ by running `pnpm i`
|
- __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
|
- __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`
|
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
|
## DOCKER
|
||||||
|
|
||||||
Ontime provides a docker-compose file to aid with building and running docker images.
|
Ontime provides a docker-compose file to aid with building and running docker images.
|
||||||
|
|||||||
+9
-15
@@ -1,13 +1,5 @@
|
|||||||
FROM node:22-bullseye AS builder
|
ARG NODE_VERSION=22.15.1
|
||||||
ENV PNPM_HOME="/pnpm"
|
FROM node:${NODE_VERSION}-alpine
|
||||||
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
|
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
# Environment Variable to signal that we are running production
|
# Environment Variable to signal that we are running production
|
||||||
@@ -15,16 +7,17 @@ ENV NODE_ENV=docker
|
|||||||
# Ontime Data path
|
# Ontime Data path
|
||||||
ENV ONTIME_DATA=/data/
|
ENV ONTIME_DATA=/data/
|
||||||
|
|
||||||
|
RUN mkdir /app
|
||||||
WORKDIR /app/
|
WORKDIR /app/
|
||||||
|
|
||||||
# Prepare UI
|
# Prepare UI
|
||||||
COPY --from=builder /app/apps/client/build ./client/
|
COPY apps/client/build/ ./client/
|
||||||
|
|
||||||
# Prepare Backend
|
# Prepare Backend
|
||||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
COPY apps/server/dist/ ./server/
|
||||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
COPY apps/server/src/external/ ./external/
|
||||||
COPY --from=builder /app/apps/server/src/user/ ./user/
|
COPY apps/server/src/user/ ./user/
|
||||||
COPY --from=builder /app/apps/server/src/html/ ./html/
|
COPY apps/server/src/html/ ./html/
|
||||||
|
|
||||||
# Export default ports
|
# Export default ports
|
||||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||||
@@ -32,5 +25,6 @@ EXPOSE 4001/tcp 8888/udp 9999/udp
|
|||||||
CMD ["node", "server/docker.cjs"]
|
CMD ["node", "server/docker.cjs"]
|
||||||
|
|
||||||
# Build and run commands
|
# Build and run commands
|
||||||
|
# pnpm build:docker
|
||||||
# docker buildx build . -t getontime/ontime
|
# 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
|
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.0.0-beta.5",
|
"version": "4.0.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.0.0-beta.5",
|
"version": "4.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export default function AppRouter() {
|
|||||||
path='op'
|
path='op'
|
||||||
element={
|
element={
|
||||||
<ViewLoader>
|
<ViewLoader>
|
||||||
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
||||||
<Operator />
|
<Operator />
|
||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
@@ -207,13 +208,13 @@ function PresetView() {
|
|||||||
/**
|
/**
|
||||||
* Locked presets do not allow configuration changes
|
* Locked presets do not allow configuration changes
|
||||||
* Whether the user can navigate is determined by the locked param
|
* 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];
|
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
|
||||||
return (
|
return (
|
||||||
<PresetContext value={preset}>
|
<PresetContext value={preset}>
|
||||||
{preset.target !== OntimeView.Cuesheet && (
|
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
||||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
|
||||||
)}
|
|
||||||
{Component ? <Component /> : <NotFound />}
|
{Component ? <Component /> : <NotFound />}
|
||||||
</PresetContext>
|
</PresetContext>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CustomFields, Rundown } from 'ontime-types';
|
|||||||
import { ImportMap } from 'ontime-utils';
|
import { ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import { downloadBlob } from './utils';
|
||||||
|
|
||||||
const excelPath = `${apiEntryUrl}/excel`;
|
const excelPath = `${apiEntryUrl}/excel`;
|
||||||
|
|
||||||
@@ -39,3 +40,18 @@ export async function importRundownPreview(options: ImportMap): Promise<PreviewS
|
|||||||
});
|
});
|
||||||
return response.data;
|
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 { PropsWithChildren, useRef, useState } from 'react';
|
||||||
import { IoCheckmark } from 'react-icons/io5';
|
import { IoCheckmark, IoCopy } from 'react-icons/io5';
|
||||||
import { IoCopy } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import copyToClipboard from '../../utils/copyToClipboard';
|
import copyToClipboard from '../../utils/copyToClipboard';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
min-width: min(680px, 90vw);
|
min-width: min(880px, 90vw);
|
||||||
min-height: min(200px, 10vh);
|
min-height: min(200px, 10vh);
|
||||||
max-width: min(900px, 90vw);
|
max-width: min(1200px, 90vw);
|
||||||
|
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
|
|||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
import { IoApps } from 'react-icons/io5';
|
import { IoApps, IoSettingsOutline } from 'react-icons/io5';
|
||||||
import { IoSettingsOutline } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
|
||||||
import { cx } from '../../../utils/styleUtils';
|
import { cx } from '../../../utils/styleUtils';
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
width: 40rem;
|
width: 40rem;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function OntimeActionForm({
|
|||||||
New time
|
New time
|
||||||
<Input
|
<Input
|
||||||
{...register(`outputs.${index}.time`, {
|
{...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
|
fluid
|
||||||
placeholder='eg: 10m5s'
|
placeholder='eg: 10m5s'
|
||||||
|
|||||||
@@ -13,5 +13,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.current {
|
.current {
|
||||||
background-color: $blue-1100;
|
background-color: $blue-1100 !important; // fighting zebra styles
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState } from 'react';
|
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 { useDisclosure } from '@mantine/hooks';
|
||||||
|
|
||||||
|
import { downloadAsExcel } from '../../../../common/api/excel';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||||
|
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||||
import Tag from '../../../../common/components/tag/Tag';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
@@ -93,19 +100,35 @@ export default function ManageRundowns() {
|
|||||||
<td>
|
<td>
|
||||||
{title} {isLoaded && <Tag>Loaded</Tag>}
|
{title} {isLoaded && <Tag>Loaded</Tag>}
|
||||||
</td>
|
</td>
|
||||||
<Panel.InlineElements as='td'>
|
<td>
|
||||||
<Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}>
|
<DropdownMenu
|
||||||
Load
|
render={<IconButton variant='ghosted-white' />}
|
||||||
</Button>
|
items={[
|
||||||
<Button
|
{
|
||||||
size='small'
|
type: 'item',
|
||||||
variant='subtle-destructive'
|
icon: IoDownloadOutline,
|
||||||
onClick={() => openDelete(id)}
|
label: 'Load',
|
||||||
disabled={isLoaded}
|
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
|
<IoEllipsisHorizontal />
|
||||||
</Button>
|
</DropdownMenu>
|
||||||
</Panel.InlineElements>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
|||||||
timeWarning: namedImportMap['Time warning'],
|
timeWarning: namedImportMap['Time warning'],
|
||||||
timeDanger: namedImportMap['Time danger'],
|
timeDanger: namedImportMap['Time danger'],
|
||||||
custom,
|
custom,
|
||||||
entryId: namedImportMap.ID,
|
id: namedImportMap.ID,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ import ViewParamsEditor from '../../common/components/view-params-editor/ViewPar
|
|||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
import { useSelectedEventId } from '../../common/hooks/useSocket';
|
import { useSelectedEventId } from '../../common/hooks/useSocket';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
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 { cx } from '../../common/utils/styleUtils';
|
||||||
import { throttle } from '../../common/utils/throttle';
|
import { throttle } from '../../common/utils/throttle';
|
||||||
import { getDefaultFormat } from '../../common/utils/time';
|
import { getDefaultFormat } from '../../common/utils/time';
|
||||||
|
import Loader from '../../views/common/loader/Loader';
|
||||||
|
|
||||||
import EditModal from './edit-modal/EditModal';
|
import EditModal from './edit-modal/EditModal';
|
||||||
import FollowButton from './follow-button/FollowButton';
|
import FollowButton from './follow-button/FollowButton';
|
||||||
@@ -22,21 +19,31 @@ import StatusBar from './status-bar/StatusBar';
|
|||||||
import { getOperatorOptions, useOperatorOptions } from './operator.options';
|
import { getOperatorOptions, useOperatorOptions } from './operator.options';
|
||||||
import type { EditEvent } from './operator.types';
|
import type { EditEvent } from './operator.types';
|
||||||
import { getEventData } from './operator.utils';
|
import { getEventData } from './operator.utils';
|
||||||
|
import { OperatorData, useOperatorData } from './useOperatorData';
|
||||||
|
|
||||||
import style from './Operator.module.scss';
|
import style from './Operator.module.scss';
|
||||||
|
|
||||||
const selectedOffset = 50;
|
const selectedOffset = 50;
|
||||||
|
|
||||||
export default function Operator() {
|
export default function OperatorLoader() {
|
||||||
const { data, rundownMetadata, status } = useRundownWithMetadata();
|
const { data, status } = useOperatorData();
|
||||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
|
||||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
|
||||||
|
|
||||||
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 { selectedEventId } = useSelectedEventId();
|
||||||
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
|
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
|
||||||
const { data: settings } = useSettings();
|
|
||||||
|
|
||||||
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
||||||
const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
|
||||||
@@ -52,7 +59,7 @@ export default function Operator() {
|
|||||||
followTrigger: selectedEventId,
|
followTrigger: selectedEventId,
|
||||||
});
|
});
|
||||||
|
|
||||||
useWindowTitle('Operator');
|
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// reset scroll if nothing is selected
|
// reset scroll if nothing is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -101,17 +108,10 @@ export default function Operator() {
|
|||||||
setEditEvent({ ...event });
|
setEditEvent({ ...event });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const missingData = !data || !customFields || !projectData;
|
|
||||||
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
|
|
||||||
|
|
||||||
// gather option data
|
// gather option data
|
||||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
|
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
|
||||||
|
|
||||||
if (missingData || isLoading) {
|
|
||||||
return <EmptyPage text='Loading...' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canEdit = shouldEdit && subscribe.length;
|
const canEdit = shouldEdit && subscribe.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -126,8 +126,8 @@ export default function Operator() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||||
{data.order.map((entryId) => {
|
{rundown.order.map((entryId) => {
|
||||||
const entry = data.entries[entryId];
|
const entry = rundown.entries[entryId];
|
||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
||||||
// hide past events (if setting) and skipped events
|
// hide past events (if setting) and skipped events
|
||||||
@@ -172,12 +172,12 @@ export default function Operator() {
|
|||||||
<Fragment key={entry.id}>
|
<Fragment key={entry.id}>
|
||||||
<OperatorGroup key={entry.id} title={entry.title} />
|
<OperatorGroup key={entry.id} title={entry.title} />
|
||||||
{entry.entries.map((nestedEntryId) => {
|
{entry.entries.map((nestedEntryId) => {
|
||||||
const nestedEntry = data.entries[nestedEntryId];
|
const nestedEntry = rundown.entries[nestedEntryId];
|
||||||
if (!isOntimeEvent(nestedEntry)) {
|
if (!isOntimeEvent(nestedEntry)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[entryId];
|
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
|
||||||
|
|
||||||
// hide past events (if setting) and skipped events
|
// hide past events (if setting) and skipped events
|
||||||
if ((hidePast && isPast) || nestedEntry.skip) {
|
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 ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||||
import { getIsNavigationLocked } from '../../externals';
|
|
||||||
|
|
||||||
import Operator from './Operator';
|
import Operator from './Operator';
|
||||||
|
|
||||||
export default function OperatorExport() {
|
export default function OperatorExport() {
|
||||||
return (
|
return (
|
||||||
<ProtectRoute permission='operator'>
|
<ProtectRoute permission='operator'>
|
||||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
|
|
||||||
<Operator />
|
<Operator />
|
||||||
</ProtectRoute>
|
</ProtectRoute>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { use, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
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 { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
||||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
makeCustomFieldSelectOptions,
|
makeCustomFieldSelectOptions,
|
||||||
makeOptionsFromCustomFields,
|
makeOptionsFromCustomFields,
|
||||||
} from '../../common/components/view-params-editor/viewParams.utils';
|
} from '../../common/components/view-params-editor/viewParams.utils';
|
||||||
|
import { PresetContext } from '../../common/context/PresetContext';
|
||||||
import { isStringBoolean } from '../viewers/common/viewUtils';
|
import { isStringBoolean } from '../viewers/common/viewUtils';
|
||||||
|
|
||||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
||||||
@@ -93,15 +94,26 @@ type OperatorOptions = {
|
|||||||
* Utility extract the view options from URL Params
|
* Utility extract the view options from URL Params
|
||||||
* the names and fallback are manually matched with timerOptions
|
* 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
|
// we manually make an object that matches the key above
|
||||||
return {
|
return {
|
||||||
mainSource: searchParams.get('main') as keyof OntimeEvent | null,
|
mainSource: getValue('main') as keyof OntimeEvent | null,
|
||||||
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
|
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
|
||||||
subscribe: searchParams.getAll('subscribe'),
|
subscribe: getArrayValues('subscribe'),
|
||||||
shouldEdit: isStringBoolean(searchParams.get('shouldEdit')),
|
shouldEdit: isStringBoolean(getValue('shouldEdit')),
|
||||||
hidePast: isStringBoolean(searchParams.get('hidePast')),
|
hidePast: isStringBoolean(getValue('hidePast')),
|
||||||
showStart: isStringBoolean(searchParams.get('showStart')),
|
showStart: isStringBoolean(getValue('showStart')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +122,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions {
|
|||||||
*/
|
*/
|
||||||
export function useOperatorOptions(): OperatorOptions {
|
export function useOperatorOptions(): OperatorOptions {
|
||||||
const [searchParams] = useSearchParams();
|
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;
|
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]),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router';
|
import { createRoutesFromChildren, matchRoutes, Routes, useLocation, useNavigationType } from 'react-router';
|
||||||
import { Routes } from 'react-router';
|
|
||||||
import * as Sentry from '@sentry/react';
|
import * as Sentry from '@sentry/react';
|
||||||
|
|
||||||
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const getBackstageOptions = (
|
|||||||
customFields: CustomFields,
|
customFields: CustomFields,
|
||||||
projectData: ProjectData,
|
projectData: ProjectData,
|
||||||
): ViewOption[] => {
|
): ViewOption[] => {
|
||||||
const customFieldOptions = makeOptionsFromCustomFields(customFields, []);
|
const customFieldOptions = makeOptionsFromCustomFields(customFields, [{ value: 'none', label: 'None' }]);
|
||||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
|
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
|
||||||
{ value: 'none', label: 'None' },
|
{ value: 'none', label: 'None' },
|
||||||
{ value: 'note', label: 'Note' },
|
{ value: 'note', label: 'Note' },
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
.notFound {
|
.notFound {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
height: 100vh;
|
||||||
|
padding-top: 20vh;
|
||||||
background-color: var(--background-color-override, $viewer-background-color);
|
background-color: var(--background-color-override, $viewer-background-color);
|
||||||
margin-top: 10vh;
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ import style from './NotFound.module.scss';
|
|||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<div className={style.notFound}>
|
<div className={style.notFound}>
|
||||||
<EmptyImage />
|
<section className={style.content}>
|
||||||
<h1>Not found</h1>
|
<EmptyImage />
|
||||||
<div>
|
<h1>Not found</h1>
|
||||||
The page you are after was not found.
|
<div>
|
||||||
<br />
|
The page you are after was not found.
|
||||||
It may have moved or your URL may be incorrect.
|
<br />
|
||||||
<br />
|
It may have moved or your URL may be incorrect.
|
||||||
Double check the URL and try again.
|
<br />
|
||||||
</div>
|
Double check the URL and try again.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const getScheduleOptions = (customFieldOptions: SelectOption[]): ViewOpti
|
|||||||
description: 'Hide events without data in the selected custom field',
|
description: 'Hide events without data in the selected custom field',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: customFieldOptions,
|
values: customFieldOptions,
|
||||||
defaultValue: 'None',
|
defaultValue: 'none',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stopCycle',
|
id: 'stopCycle',
|
||||||
|
|||||||
+1
-1
@@ -216,7 +216,7 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
|
|||||||
|
|
||||||
const canWrite = column.columnDef.meta?.canWrite;
|
const canWrite = column.columnDef.meta?.canWrite;
|
||||||
if (!canWrite) {
|
if (!canWrite) {
|
||||||
return <GhostedText>{initialValue}</GhostedText>;
|
return <GhostedText multiline>{initialValue}</GhostedText>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function getEstimatedFontSize(stageTimer: string, secondaryContent?: stri
|
|||||||
timerFontSize = fontSizeMap[stageTimerCharacters];
|
timerFontSize = fontSizeMap[stageTimerCharacters];
|
||||||
}
|
}
|
||||||
|
|
||||||
let externalFontSize = timerFontSize * 0.2;
|
let externalFontSize = timerFontSize * 0.325;
|
||||||
if (secondaryContent) {
|
if (secondaryContent) {
|
||||||
// we need to shrink the timer if the external is going to be there
|
// 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
|
// this number has been tweaked to fit in a landscape mobile screen
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "4.0.0-beta.5",
|
"version": "4.0.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
@@ -25,11 +25,10 @@
|
|||||||
"lint": "eslint . --quiet",
|
"lint": "eslint . --quiet",
|
||||||
"dist-win": "electron-builder --publish=never --x64 --win",
|
"dist-win": "electron-builder --publish=never --x64 --win",
|
||||||
"dist-mac": "electron-builder --publish=never --mac",
|
"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"
|
"dist-linux": "electron-builder --publish=never --x64 --linux"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"productName": "ontime-prerelease",
|
"productName": "ontime",
|
||||||
"appId": "no.lightdev.ontime",
|
"appId": "no.lightdev.ontime",
|
||||||
"asar": true,
|
"asar": true,
|
||||||
"dmg": {
|
"dmg": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/resolver",
|
"name": "@getontime/resolver",
|
||||||
"version": "4.0.0-beta.5",
|
"version": "4.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
"types": "./dist/main.d.ts",
|
"types": "./dist/main.d.ts",
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ export { SimplePlayback, SimpleDirection } from 'ontime-types';
|
|||||||
|
|
||||||
// entries
|
// entries
|
||||||
export type { OntimeEvent, OntimeGroup, EntryCustomFields, CustomFields, Rundown } from 'ontime-types';
|
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
|
// functions
|
||||||
export { isWsPacketToClient } from './websocket.js';
|
export { isWsPacketToClient } from './websocket.js';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "4.0.0-beta.5",
|
"version": "4.0.0",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
|||||||
return {
|
return {
|
||||||
type: 'ontime',
|
type: 'ontime',
|
||||||
action: maybeOntimeAction.action,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
// 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",
|
||||||
|
"Custom Field",
|
||||||
|
"Images",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"e2163f",
|
||||||
|
"",
|
||||||
|
"Demo",
|
||||||
|
"Clear all, or Create New Project to start fresh",
|
||||||
|
"#9d9d9d",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"Go to Settings cog wheel top left of Editor, Project > Create",
|
||||||
|
"milestone",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"Create Custom Fields to store specific text information or images.
|
||||||
|
|
||||||
|
Go to Settings > Project data > Custom fields",
|
||||||
|
"https://www.getontime.no/images/icons/ontime-logo.png",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"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",
|
||||||
|
"",
|
||||||
|
"Put additional info here",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"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",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"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",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"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
|
||||||
|
Music plays, holding slide on screens",
|
||||||
|
"count-down",
|
||||||
|
"",
|
||||||
|
"00:02:00",
|
||||||
|
"00:01:00",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"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",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"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",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"07df89",
|
||||||
|
"",
|
||||||
|
"Strike",
|
||||||
|
"14:00 - AV & Room strike",
|
||||||
|
"#A790F5",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"milestone",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"group-end",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
`;
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { CustomFields } from 'ontime-types';
|
import { CustomFields } from 'ontime-types';
|
||||||
import { defaultImportMap, ImportMap } from 'ontime-utils';
|
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()', () => {
|
describe('getCustomFieldData()', () => {
|
||||||
it('generates a list of keys from the given import map', () => {
|
it('generates a list of keys from the given import map', () => {
|
||||||
const importMap = {
|
const importMap: ImportMap = {
|
||||||
worksheet: 'event schedule',
|
worksheet: 'event schedule',
|
||||||
timeStart: 'time start',
|
timeStart: 'time start',
|
||||||
linkStart: 'link start',
|
linkStart: 'link start',
|
||||||
@@ -27,8 +29,8 @@ describe('getCustomFieldData()', () => {
|
|||||||
sound: 'sound',
|
sound: 'sound',
|
||||||
video: 'av',
|
video: 'av',
|
||||||
},
|
},
|
||||||
entryId: 'id',
|
id: 'id',
|
||||||
} as ImportMap;
|
};
|
||||||
|
|
||||||
const result = getCustomFieldData(importMap, {});
|
const result = getCustomFieldData(importMap, {});
|
||||||
expect(result.mergedCustomFields).toStrictEqual({
|
expect(result.mergedCustomFields).toStrictEqual({
|
||||||
@@ -58,7 +60,7 @@ describe('getCustomFieldData()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps colour information from existing fields', () => {
|
it('keeps colour information from existing fields', () => {
|
||||||
const importMap = {
|
const importMap: ImportMap = {
|
||||||
worksheet: 'event schedule',
|
worksheet: 'event schedule',
|
||||||
timeStart: 'time start',
|
timeStart: 'time start',
|
||||||
linkStart: 'link start',
|
linkStart: 'link start',
|
||||||
@@ -81,8 +83,8 @@ describe('getCustomFieldData()', () => {
|
|||||||
video: 'av',
|
video: 'av',
|
||||||
'ontime key': 'excel label',
|
'ontime key': 'excel label',
|
||||||
},
|
},
|
||||||
entryId: 'id',
|
id: 'id',
|
||||||
} as ImportMap;
|
};
|
||||||
|
|
||||||
const existingCustomFields: CustomFields = {
|
const existingCustomFields: CustomFields = {
|
||||||
lighting: { label: 'lighting', type: 'text', colour: 'red' },
|
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';
|
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) => {
|
const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||||
if (file.mimetype.includes(EXCEL_MIME)) {
|
if (file.mimetype.includes(EXCEL_MIME)) {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export const parseExcel = (
|
|||||||
entry.timeDanger = parseExcelDate(column);
|
entry.timeDanger = parseExcelDate(column);
|
||||||
} else if (j === indexMap.colour) {
|
} else if (j === indexMap.colour) {
|
||||||
entry.colour = makeString(column, '');
|
entry.colour = makeString(column, '');
|
||||||
} else if (j === indexMap.entryId) {
|
} else if (j === indexMap.id) {
|
||||||
entry.id = encodeURIComponent(makeString(column, undefined));
|
entry.id = encodeURIComponent(makeString(column, undefined));
|
||||||
} else if (j in indexMap.custom) {
|
} else if (j in indexMap.custom) {
|
||||||
const importKey = indexMap.custom[j];
|
const importKey = indexMap.custom[j];
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import type { Request, Response } 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 { 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();
|
export const router = express.Router();
|
||||||
|
|
||||||
@@ -11,7 +16,7 @@ router.post('/upload', uploadExcel, validateFileExists, async (req: Request, res
|
|||||||
try {
|
try {
|
||||||
// file has been validated by middleware
|
// file has been validated by middleware
|
||||||
const filePath = (req.file as Express.Multer.File).path;
|
const filePath = (req.file as Express.Multer.File).path;
|
||||||
await saveExcelFile(filePath);
|
await readExcelFile(filePath);
|
||||||
res.status(201).send();
|
res.status(201).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: String(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 { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||||
|
|
||||||
import { parseExcel } from './excel.parser.js';
|
import { parseExcel } from './excel.parser.js';
|
||||||
|
import { rundownToTabular } from './excel.utils.js';
|
||||||
|
|
||||||
let excelData: WorkBook = xlsx.utils.book_new();
|
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)) {
|
if (!existsSync(filePath)) {
|
||||||
throw new Error('Upload of excel file failed');
|
throw new Error('Upload of excel file failed');
|
||||||
}
|
}
|
||||||
@@ -33,6 +38,9 @@ export async function saveExcelFile(filePath: string) {
|
|||||||
await deleteFile(filePath);
|
await deleteFile(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all worksheets in the current spreadsheet file
|
||||||
|
*/
|
||||||
export function listWorksheets(): string[] {
|
export function listWorksheets(): string[] {
|
||||||
return excelData.SheetNames;
|
return excelData.SheetNames;
|
||||||
}
|
}
|
||||||
@@ -60,3 +68,19 @@ export function generateRundownPreview(options: ImportMap): { rundown: Rundown;
|
|||||||
|
|
||||||
return { rundown, customFields: parsedCustomFields };
|
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' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CustomFieldKey, CustomFields, MaybeNumber } from 'ontime-types';
|
import { CustomFieldKey, CustomFields, MaybeNumber, OntimeEntry, Rundown } from 'ontime-types';
|
||||||
import { checkRegex, customFieldLabelToKey, ImportMap } from 'ontime-utils';
|
import { checkRegex, customFieldLabelToKey, ImportMap, millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Receives an import map which contains custom field labels and a custom fields object
|
* 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,
|
timeDanger: null,
|
||||||
endAction: null,
|
endAction: null,
|
||||||
timerType: null,
|
timerType: null,
|
||||||
entryId: null,
|
id: null,
|
||||||
custom: {},
|
custom: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,8 +155,8 @@ export function generateImportHandlers(importMap: ImportMap) {
|
|||||||
indexMap.timeDanger = col;
|
indexMap.timeDanger = col;
|
||||||
sheetMetadata.timeDanger = { row, col };
|
sheetMetadata.timeDanger = { row, col };
|
||||||
},
|
},
|
||||||
[importMap.entryId]: (row: number, col: number) => {
|
[importMap.id]: (row: number, col: number) => {
|
||||||
indexMap.entryId = col;
|
indexMap.id = col;
|
||||||
sheetMetadata['id'] = { row, col }; // important this will be used in a normal context where the id is not called entryId
|
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) => {
|
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
|
||||||
@@ -167,3 +167,171 @@ export function generateImportHandlers(importMap: ImportMap) {
|
|||||||
|
|
||||||
return { handlers, indexMap, sheetMetadata };
|
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 { isImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { body } from 'express-validator';
|
import { body, param } from 'express-validator';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
requestValidationFunctionWithFile,
|
requestValidationFunctionWithFile,
|
||||||
@@ -17,3 +18,5 @@ export const validateImportMapOptions = [
|
|||||||
|
|
||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const validateRundownExport = [param('rundownId').isString().trim().notEmpty(), requestValidationFunction];
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import { EndAction, TimerType, isKeyOfType } from 'ontime-types';
|
import { EndAction, TimeStrategy, TimerType, isKeyOfType } from 'ontime-types';
|
||||||
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
|
import { maxDuration } from 'ontime-utils';
|
||||||
|
|
||||||
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
|
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
|
||||||
import { getDataProvider } from '../classes/data-provider/DataProvider.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
|
* @returns {number} time in milliseconds clamped to 0 and max duration
|
||||||
*/
|
*/
|
||||||
function clampDuration(value: number): number {
|
function clampDuration(value: number): number {
|
||||||
const valueInMillis = value * MILLIS_PER_SECOND;
|
if (value > maxDuration || value < 0) {
|
||||||
if (valueInMillis > maxDuration || valueInMillis < 0) {
|
|
||||||
throw new Error('Times should be from 0 to 23:59:59');
|
throw new Error('Times should be from 0 to 23:59:59');
|
||||||
}
|
}
|
||||||
return valueInMillis;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const propertyConversion = {
|
const propertyConversion = {
|
||||||
@@ -34,6 +33,9 @@ const propertyConversion = {
|
|||||||
endAction: (value: unknown) => coerceEnum<EndAction>(value, EndAction),
|
endAction: (value: unknown) => coerceEnum<EndAction>(value, EndAction),
|
||||||
timerType: (value: unknown) => coerceEnum<TimerType>(value, TimerType),
|
timerType: (value: unknown) => coerceEnum<TimerType>(value, TimerType),
|
||||||
|
|
||||||
|
linkStart: coerceBoolean,
|
||||||
|
timeStrategy: (value: unknown) => coerceEnum<TimeStrategy>(value, TimeStrategy),
|
||||||
|
|
||||||
duration: (value: unknown) => clampDuration(coerceNumber(value)),
|
duration: (value: unknown) => clampDuration(coerceNumber(value)),
|
||||||
timeStart: (value: unknown) => clampDuration(coerceNumber(value)),
|
timeStart: (value: unknown) => clampDuration(coerceNumber(value)),
|
||||||
timeEnd: (value: unknown) => clampDuration(coerceNumber(value)),
|
timeEnd: (value: unknown) => clampDuration(coerceNumber(value)),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
AuthenticationStatus,
|
AuthenticationStatus,
|
||||||
CustomFields,
|
CustomFields,
|
||||||
DatabaseModel,
|
DatabaseModel,
|
||||||
|
EntryId,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isOntimeMilestone,
|
isOntimeMilestone,
|
||||||
LogOrigin,
|
LogOrigin,
|
||||||
@@ -360,7 +361,7 @@ export async function upload(sheetId: string, options: ImportMap) {
|
|||||||
const rundown = getCurrentRundown();
|
const rundown = getCurrentRundown();
|
||||||
|
|
||||||
const sheetOrder: string[] = [];
|
const sheetOrder: string[] = [];
|
||||||
let prevGroup: string | null = null;
|
let prevGroup: EntryId | null = null;
|
||||||
for (const id of rundown.flatOrder) {
|
for (const id of rundown.flatOrder) {
|
||||||
const entry = rundown.entries[id];
|
const entry = rundown.entries[id];
|
||||||
|
|
||||||
@@ -418,12 +419,12 @@ export async function upload(sheetId: string, options: ImportMap) {
|
|||||||
const isGroupEnd = entryId.startsWith('group-end-');
|
const isGroupEnd = entryId.startsWith('group-end-');
|
||||||
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
|
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
|
||||||
const entry = isGroupEnd
|
const entry = isGroupEnd
|
||||||
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
|
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
|
||||||
: structuredClone(rundown.entries[id]);
|
: structuredClone(rundown.entries[id]);
|
||||||
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
|
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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({
|
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
// TODO: can we replace the clicks with something else?
|
|
||||||
test.describe('pages routes are available', () => {
|
test.describe('pages routes are available', () => {
|
||||||
test.use({ viewport: { width: 1920, height: 1080 } });
|
test.use({ viewport: { width: 1920, height: 1080 } });
|
||||||
test.describe('main views', () => {
|
test.describe('main views', () => {
|
||||||
test('editor', async ({ page }) => {
|
test('editor', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/editor');
|
await page.goto('/editor');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await expect(page.getByTestId('editor-container')).toBeVisible();
|
await expect(page.getByTestId('editor-container')).toBeVisible();
|
||||||
@@ -15,44 +14,41 @@ test.describe('pages routes are available', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('cuesheet', async ({ page }) => {
|
test('cuesheet', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/cuesheet');
|
await page.goto('/cuesheet');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await page.getByTestId('cuesheet').click();
|
await expect(page.getByTestId('cuesheet')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('operator', async ({ page }) => {
|
test('operator', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/op');
|
await page.goto('/op');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('timer', async ({ page }) => {
|
test('timer', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/timer');
|
await page.goto('/timer');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('backstage', async ({ page }) => {
|
test('backstage', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/backstage');
|
await page.goto('/backstage');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('studio', async ({ page }) => {
|
test('studio', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/studio');
|
await page.goto('/studio');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('countdown', async ({ page }) => {
|
test('countdown', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/countdown');
|
await page.goto('/countdown?sub=32d31');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/);
|
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('Albania')).toBeVisible();
|
||||||
await expect(page.getByText('Latvia')).toBeHidden();
|
await expect(page.getByText('Latvia')).toBeHidden();
|
||||||
});
|
});
|
||||||
@@ -60,15 +56,15 @@ test.describe('pages routes are available', () => {
|
|||||||
|
|
||||||
test.describe('detached views', () => {
|
test.describe('detached views', () => {
|
||||||
test('rundown', async ({ page }) => {
|
test('rundown', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/rundown');
|
await page.goto('/rundown');
|
||||||
await expect(page.getByTestId('panel-rundown')).toBeVisible();
|
await expect(page.getByTestId('panel-rundown')).toBeVisible();
|
||||||
});
|
});
|
||||||
test('timer control', async ({ page }) => {
|
test('timer control', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/timercontrol');
|
await page.goto('/timercontrol');
|
||||||
await expect(page.getByTestId('panel-timer-control')).toBeVisible();
|
await expect(page.getByTestId('panel-timer-control')).toBeVisible();
|
||||||
});
|
});
|
||||||
test('message control', async ({ page }) => {
|
test('message control', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/messagecontrol');
|
await page.goto('/messagecontrol');
|
||||||
await expect(page.getByTestId('panel-messages-control')).toBeVisible();
|
await expect(page.getByTestId('panel-messages-control')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ import { test, expect } from '@playwright/test';
|
|||||||
|
|
||||||
test('smoke test operator', async ({ page }) => {
|
test('smoke test operator', async ({ page }) => {
|
||||||
// make some boilerplate
|
// 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: 'Edit' }).click();
|
||||||
await page.getByRole('button', { name: 'Clear all' }).click();
|
await page.getByRole('button', { name: 'Clear all' }).click();
|
||||||
await page.getByRole('button', { name: 'Delete 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').fill('1m');
|
||||||
await page.getByTestId('time-input-timeStart').press('Enter');
|
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').fill('1m');
|
||||||
await page.getByTestId('entry-3').getByTestId('time-input-duration').press('Enter');
|
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.getByRole('button', { name: 'Edit' }).click();
|
||||||
await page.getByTestId('entry-1').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.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
|
||||||
await page.getByTestId('entry-1').getByTestId('entry__title').click();
|
await page.getByTestId('entry-1').getByTestId('entry__title').click();
|
||||||
@@ -51,14 +55,11 @@ test('smoke test operator', async ({ page }) => {
|
|||||||
// start an event
|
// start an event
|
||||||
await page.getByTestId('panel-timer-control').getByRole('button', { name: 'Start' }).click();
|
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.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 2')).toBeInViewport();
|
||||||
await expect(page.getByText('title 3')).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';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
//TODO:
|
|
||||||
|
|
||||||
test('show warning when event crosses midnight', async ({ page }) => {
|
test('show warning when event crosses midnight', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/editor');
|
await page.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
test('time until absolute', async ({ context }) => {
|
||||||
const editor = await context.newPage();
|
const editor = await context.newPage();
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "4.0.0-beta.5",
|
"version": "4.0.0",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"build:docker": "cross-env NODE_ENV=docker turbo run build --filter=ontime-server --filter=ontime-ui",
|
"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-win": "turbo run dist-win",
|
||||||
"dist-mac": "turbo run dist-mac",
|
"dist-mac": "turbo run dist-mac",
|
||||||
"dist-linux": "turbo run dist-linux",
|
"dist-linux": "turbo run dist-linux",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import type { AutomationSettings } from './core/Automation.type.js';
|
import type { AutomationSettings } from './core/Automation.type.js';
|
||||||
import type { CustomFields } from './core/CustomFields.type.js';
|
import type { CustomFields } from './core/CustomFields.type.js';
|
||||||
import type { ProjectData } from './core/ProjectData.type.js';
|
import type { ProjectData } from './core/ProjectData.type.js';
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export type OntimeAction =
|
|||||||
| {
|
| {
|
||||||
type: 'ontime';
|
type: 'ontime';
|
||||||
action: OntimeAuxSetAction;
|
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';
|
type: 'ontime';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export {
|
|||||||
type OntimeEvent,
|
type OntimeEvent,
|
||||||
type PlayableEvent,
|
type PlayableEvent,
|
||||||
type TimeField,
|
type TimeField,
|
||||||
SupportedEntry as SupportedEntry,
|
SupportedEntry,
|
||||||
} from './definitions/core/OntimeEntry.js';
|
} from './definitions/core/OntimeEntry.js';
|
||||||
export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/core/Rundown.type.js';
|
export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/core/Rundown.type.js';
|
||||||
export { TimeStrategy } from './definitions/TimeStrategy.type.js';
|
export { TimeStrategy } from './definitions/TimeStrategy.type.js';
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe('isImportMap()', () => {
|
|||||||
timeWarning: 'warning time',
|
timeWarning: 'warning time',
|
||||||
timeDanger: 'danger time',
|
timeDanger: 'danger time',
|
||||||
custom: {},
|
custom: {},
|
||||||
entryId: 'id',
|
id: 'id',
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(isImportMap(importMap)).toBe(true);
|
expect(isImportMap(importMap)).toBe(true);
|
||||||
@@ -72,7 +72,7 @@ describe('isImportMap()', () => {
|
|||||||
userDefined: 'userDefined',
|
userDefined: 'userDefined',
|
||||||
anotherOne: 'anotherOne',
|
anotherOne: 'anotherOne',
|
||||||
},
|
},
|
||||||
entryId: 'id',
|
id: 'id',
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(isImportMap(importMap)).toBe(true);
|
expect(isImportMap(importMap)).toBe(true);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const defaultImportMap = {
|
|||||||
timeWarning: 'warning time',
|
timeWarning: 'warning time',
|
||||||
timeDanger: 'danger time',
|
timeDanger: 'danger time',
|
||||||
custom: {},
|
custom: {},
|
||||||
entryId: 'id',
|
id: 'id',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -46,10 +46,6 @@
|
|||||||
"dependsOn": ["build"],
|
"dependsOn": ["build"],
|
||||||
"outputs": ["dist/**"]
|
"outputs": ["dist/**"]
|
||||||
},
|
},
|
||||||
"dist-mac:local": {
|
|
||||||
"dependsOn": ["build"],
|
|
||||||
"outputs": ["dist/**"]
|
|
||||||
},
|
|
||||||
"dist-linux": {
|
"dist-linux": {
|
||||||
"dependsOn": ["build"],
|
"dependsOn": ["build"],
|
||||||
"outputs": ["dist/**"]
|
"outputs": ["dist/**"]
|
||||||
|
|||||||
Reference in New Issue
Block a user