Compare commits

...

45 Commits

Author SHA1 Message Date
Carlos Valente 5dce1a40bc refactor: prevent ids from being overriden 2025-11-02 13:27:53 +01:00
Carlos Valente b75b69c3b1 chore: cleanup directory structure 2025-11-02 13:23:05 +01:00
Carlos Valente e6070e6efd refactor: add rundown summary to import preview 2025-11-02 13:23:05 +01:00
Carlos Valente 3918664945 fix: ensure IDs on import from excel 2025-11-02 13:23:05 +01:00
Carlos Valente b65b3fa6d2 refactor: get worksheet names from excel upload 2025-11-02 13:23:05 +01:00
Carlos Valente 162693e19e refactor: allow user to review import map 2025-11-02 13:23:05 +01:00
Carlos Valente becd734ea8 refactor: simplify ux for sheet import 2025-11-02 13:23:05 +01:00
Carlos Valente 9dfc8083f0 style: run code formatting 2025-10-31 19:30:34 +01:00
Carlos Valente 7bd98c23a4 bump version to 4.1.0 2025-10-31 19:30:34 +01:00
arc-alex 6f19d78e53 addedTimer always updates the restore point 2025-10-31 19:26:30 +01:00
arc-alex 163baa752f keep restorpoint on container shutdown 2025-10-31 19:26:30 +01:00
mdavies1 6f708df6f6 feat: lock extracted rundown panels 2025-10-31 19:23:08 +01:00
Carlos Valente eb5c62cca1 chore: simplify prettier setup 2025-10-30 17:26:49 +01:00
Carlos Valente 99d2debca8 typo: incorrect title in delete confirmation 2025-10-30 17:26:49 +01:00
Carlos Valente 74e59dccdb feat: allow renaming rundown 2025-10-30 17:26:49 +01:00
Carlos Valente 812b17a221 refactor: create destructive menu type 2025-10-30 17:26:49 +01:00
Carlos Valente 32bf0f53f6 feat: allow user to duplicate a rundown 2025-10-30 17:26:49 +01:00
Carlos Valente 17f80baf48 chore: improve rundown API documentation 2025-10-30 17:26:49 +01:00
Carlos Valente e0377a7244 feat: implement simple healthcheck endpoint 2025-10-30 17:26:18 +01:00
Alex Christoffer Rasmussen b7e5723fb2 Fix multiday (#1850)
* chore: cleanup typeings

* fix: total gap should only accumulate after the curent loaded

* fix: add the current day of the event to start day offset
2025-10-30 14:22:14 +01:00
Alex Christoffer Rasmussen 897210d5e3 add timer type for css override selector (#1845) 2025-10-30 13:15:14 +01:00
Alex Christoffer Rasmussen ee3d38fce8 Fix link to host in local network (#1843)
* descripe testing for cloud

* use base url directly

* lint
2025-10-27 11:19:21 +01:00
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
70 changed files with 874 additions and 226 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"jest": true
},
"parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"plugins": ["@typescript-eslint", "prettier"],
"overrides": [
{
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

+8 -4
View File
@@ -14,6 +14,11 @@ 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
@@ -45,10 +50,9 @@ jobs:
- name: Copy 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}}
# 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
+8 -6
View File
@@ -1,8 +1,6 @@
name: Ontime Resolver build
on:
release:
types: [published]
workflow_dispatch:
jobs:
@@ -14,6 +12,11 @@ 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
@@ -34,10 +37,9 @@ jobs:
- name: Build project packages
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}}
# 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
-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",
"version": "4.1.0",
"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",
"version": "4.1.0",
"private": true,
"type": "module",
"dependencies": {
+4 -11
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown } from 'ontime-types';
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
@@ -11,28 +11,21 @@ const excelPath = `${apiEntryUrl}/excel`;
* upload Excel file to server
* @return string - file ID op the uploaded file
*/
export async function upload(file: File) {
export async function upload(file: File): Promise<string[]> {
const formData = new FormData();
formData.append('excel', file);
await axios.post(`${excelPath}/upload`, formData, {
const response = await axios.post(`${excelPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/**
* Get Worksheet names
* @return string[] - array of available worksheets
*/
export async function getWorksheetNames(): Promise<string[]> {
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
return response.data;
}
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
+30 -15
View File
@@ -3,6 +3,7 @@ import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, Transi
import { apiEntryUrl } from './constants';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns =========================
@@ -26,8 +27,8 @@ export async function fetchCurrentRundown(): Promise<Rundown> {
/**
* HTTP request to switch the currently loaded rundown
*/
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${id}/load`);
export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/load`);
}
/**
@@ -37,11 +38,25 @@ export async function createRundown(title: string): Promise<AxiosResponse<Projec
return axios.post(rundownPath, { title });
}
/**
* HTTP request to duplicate an existing rundown
*/
export async function duplicateRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/duplicate`);
}
/**
* HTTP request to rename an existing rundown
*/
export async function renameRundown(rundownId: RundownId, title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.patch(`${rundownPath}/${rundownId}`, { title });
}
/**
* HTTP request to delete a rundown
*/
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${id}`);
export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${rundownId}`);
}
// #endregion operations on project rundowns ======================
@@ -51,7 +66,7 @@ export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRu
* HTTP request to post new entry
*/
export async function postAddEntry(
rundownId: string,
rundownId: RundownId,
data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
@@ -60,7 +75,7 @@ export async function postAddEntry(
/**
* HTTP request to edit an entry
*/
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
export async function putEditEntry(rundownId: RundownId, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
}
@@ -72,7 +87,7 @@ export type BatchEditEntry = {
/**
* HTTP request to edit multiple events
*/
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
}
@@ -85,56 +100,56 @@ export type ReorderEntry = {
/**
* HTTP request to reorder an entry
*/
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
}
/**
* HTTP request to swap two events
*/
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
export async function requestEventSwap(rundownId: RundownId, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
}
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
}
/**
* HTTP request for cloning an entry
*/
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
export async function postCloneEntry(rundownId: RundownId, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
}
/**
* HTTP request for grouping a list of entries into a group
*/
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
export async function requestGroupEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
}
/**
* HTTP request for dissolving of a group
*/
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
export async function requestUngroup(rundownId: RundownId, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
}
/**
* HTTP request to delete entries of a given rundown
*/
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
export async function deleteEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
}
/**
* HTTP request to delete all entries of a given rundown
*/
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> {
export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/all`);
}
+2 -1
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
@@ -56,6 +56,7 @@ export const previewRundown = async (
): Promise<{
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
}> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
return response.data;
@@ -27,7 +27,6 @@
}
}
.item {
outline: 0;
cursor: default;
@@ -61,6 +60,13 @@
border-radius: 3px;
background-color: $gray-1000;
}
&[data-type='destructive'] {
color: $red-500;
svg {
color: $red-500;
}
}
}
.separator {
@@ -6,7 +6,7 @@ import style from './DropdownMenu.module.scss';
type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = {
type: 'item';
type: 'item' | 'destructive';
label: string;
icon?: IconType;
disabled?: boolean;
@@ -31,7 +31,7 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
return <BaseMenu.Separator key={index} className={style.separator} />;
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled} data-type={item.type}>
{item.icon && <item.icon />}
{item.label}
</BaseMenu.Item>
@@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants';
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList, loadRundown, renameRundown } from '../api/rundown';
/**
* Project rundowns
@@ -30,6 +30,26 @@ export function useMutateProjectRundowns() {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: duplicate } = useMutation({
mutationFn: duplicateRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: rename } = useMutation({
mutationFn: ([rundownId, title]: Parameters<typeof renameRundown>) => renameRundown(rundownId, title),
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: remove } = useMutation({
mutationFn: deleteRundown,
@@ -51,5 +71,5 @@ export function useMutateProjectRundowns() {
},
});
return { create, remove, load };
return { create, duplicate, remove, load, rename };
}
@@ -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);
}
@@ -140,7 +140,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
@@ -156,7 +156,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
@@ -172,7 +172,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 3,
isPast: false,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: true,
groupId: 'group',
@@ -188,7 +188,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 4,
isPast: false,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: false,
groupId: 'group',
@@ -204,7 +204,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 5,
isPast: false,
isNextDay: false,
totalGap: 17,
totalGap: 7,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
@@ -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();
});
});
@@ -151,14 +151,14 @@ function processEntry(
if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast && !processedData.isLoaded) {
/**
* isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event
* b) find a countToEnd event
*/
*/
processedData.totalGap += entry.gap;
processedData.isLinkedToLoaded =
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
}
@@ -1,5 +1,13 @@
import { useState } from 'react';
import { IoAdd, IoDocumentOutline, IoDownloadOutline, IoEllipsisHorizontal, IoTrash } from 'react-icons/io5';
import {
IoAdd,
IoDocumentOutline,
IoDownloadOutline,
IoDuplicateOutline,
IoEllipsisHorizontal,
IoPencilOutline,
IoTrash,
} from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks';
import { downloadAsExcel } from '../../../../common/api/excel';
@@ -13,17 +21,19 @@ import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss';
export default function ManageRundowns() {
const { data } = useProjectRundowns();
const { remove, load } = useMutateProjectRundowns();
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure();
const [isOpenLoad, loadHandlers] = useDisclosure();
const [isNewLoad, newHandlers] = useDisclosure();
const [targetRundown, setTargetRundown] = useState('');
const [renamingRundown, setRenamingRundown] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const openLoad = (id: string) => {
@@ -38,6 +48,11 @@ export default function ManageRundowns() {
deleteHandlers.open();
};
const openRename = (id: string) => {
setActionError(null);
setRenamingRundown(id);
};
const submitRundownLoad = async () => {
try {
await load(targetRundown);
@@ -48,6 +63,27 @@ export default function ManageRundowns() {
}
};
const submitRundownDuplicate = async (id: string) => {
setActionError(null);
setRenamingRundown(null);
setTargetRundown('');
try {
await duplicate(id);
} catch (error) {
setActionError(`Failed to duplicate rundown. ${maybeAxiosError(error)}`);
}
};
const submitRundownRename = async (id: string, newTitle: string) => {
try {
await rename([id, newTitle]);
setRenamingRundown(null);
} catch (error) {
setActionError(`Failed to rename rundown. ${maybeAxiosError(error)}`);
}
};
const submitRundownDelete = async () => {
try {
await remove(targetRundown);
@@ -94,6 +130,22 @@ export default function ManageRundowns() {
<tbody>
{data?.rundowns?.map(({ id, numEntries, title }) => {
const isLoaded = data.loaded === id;
const isRenaming = renamingRundown === id;
if (isRenaming) {
return (
<tr key={id}>
<td colSpan={3}>
<RundownRenameForm
onCancel={() => setRenamingRundown(null)}
onSubmit={(newTitle: string) => submitRundownRename(id, newTitle)}
initialTitle={title}
/>
</td>
</tr>
);
}
return (
<tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td>
@@ -104,6 +156,12 @@ export default function ManageRundowns() {
<DropdownMenu
render={<IconButton variant='ghosted-white' />}
items={[
{
type: 'item',
icon: IoPencilOutline,
label: 'Rename',
onClick: () => openRename(id),
},
{
type: 'item',
icon: IoDownloadOutline,
@@ -119,6 +177,13 @@ export default function ManageRundowns() {
},
{
type: 'item',
icon: IoDuplicateOutline,
label: 'Duplicate',
onClick: () => submitRundownDuplicate(id),
},
{ type: 'divider' },
{
type: 'destructive',
icon: IoTrash,
label: 'Delete',
onClick: () => openDelete(id),
@@ -140,7 +205,7 @@ export default function ManageRundowns() {
<Dialog
isOpen={isOpenDelete}
onClose={deleteHandlers.close}
title='Load rundown'
title='Delete rundown'
showBackdrop
showCloseButton
bodyElements={
@@ -0,0 +1,77 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { checkRegex } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils';
interface RundownRenameFormProps {
onSubmit: (newTitle: string) => Promise<void>;
onCancel: () => void;
initialTitle: string;
}
interface FormData {
title: string;
}
export default function RundownRenameForm({ onSubmit, onCancel, initialTitle }: RundownRenameFormProps) {
const {
handleSubmit,
register,
setFocus,
setError,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<FormData>({
defaultValues: { title: initialTitle },
mode: 'onChange',
});
const setupSubmit = async (values: FormData) => {
try {
await onSubmit(values.title);
} catch (error) {
setError('root', { type: 'custom', message: maybeAxiosError(error) });
}
};
// Give initial focus to the title input
useEffect(() => {
setFocus('title');
}, [setFocus]);
const canSubmit = isDirty && isValid;
return (
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
<label>
<Panel.Description>Rundown title</Panel.Description>
<Input
{...register('title', {
required: { value: true, message: 'Title is required' },
validate: (value) => {
if (value.trim().length === 0) return 'Title cannot be empty';
if (checkRegex.isAlphanumericWithSpace(value) === false)
return 'Title can only contain alphanumeric characters, spaces and underscores';
return true;
},
})}
fluid
/>
{errors.title && <Panel.Error>{errors.title.message}</Panel.Error>}
</label>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { CustomFields, Rundown } from 'ontime-types';
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import Button from '../../../../../common/components/buttons/Button';
import useRundown from '../../../../../common/hooks-query/useRundown';
import { formatDuration } from '../../../../../common/utils/time';
import * as Panel from '../../../panel-utils/PanelUtils';
import PreviewSpreadsheet from './preview/PreviewRundown';
@@ -12,12 +14,20 @@ import { useSheetStore } from './useSheetStore';
interface ImportReviewProps {
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
onFinished: () => void;
onCancel: () => void;
onBack: () => void;
}
export default function ImportReview(props: ImportReviewProps) {
const { rundown, customFields, onFinished, onCancel } = props;
export default function ImportReview({
rundown,
customFields,
summary,
onFinished,
onCancel,
onBack,
}: ImportReviewProps) {
const { data: currentRundown } = useRundown();
const [loading, setLoading] = useState(false);
const { importRundown } = useGoogleSheet();
@@ -51,11 +61,31 @@ export default function ImportReview(props: ImportReviewProps) {
<Button onClick={handleCancel} variant='ghosted' disabled={loading}>
Cancel
</Button>
<Button onClick={onBack} variant='subtle' disabled={loading}>
Back
</Button>
<Button onClick={applyImport} variant='primary' loading={loading}>
Apply
</Button>
</Panel.InlineElements>
</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<b>Title</b> {rundown.title}
</Panel.ListItem>
<Panel.ListItem>
<b>Number of entries</b> {rundown.flatOrder.length}
</Panel.ListItem>
<Panel.ListItem>
<b>Start time</b> {millisToString(summary.start)}
</Panel.ListItem>
<Panel.ListItem>
<b>End time</b> {millisToString(summary.end)}
</Panel.ListItem>
<Panel.ListItem>
<b>Total duration</b> {formatDuration(summary.duration)}
</Panel.ListItem>
</Panel.ListGroup>
<PreviewSpreadsheet rundown={rundown} customFields={customFields} />
</Panel.Section>
);
@@ -3,7 +3,6 @@ import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { getErrorMessage, ImportMap } from 'ontime-utils';
import {
getWorksheetNames as getWorksheetNamesExcel,
importRundownPreview as importRundownPreviewExcel,
upload as uploadExcel,
} from '../../../../../common/api/excel';
@@ -37,8 +36,11 @@ export default function SourcesPanel() {
const setRundown = useSheetStore((state) => state.setRundown);
const customFields = useSheetStore((state) => state.customFields);
const setCustomFields = useSheetStore((state) => state.setCustomFields);
const summary = useSheetStore((state) => state.summary);
const setSummary = useSheetStore((state) => state.setSummary);
const setSheetId = useSheetStore((state) => state.setSheetId);
const sheetId = useSheetStore((state) => state.sheetId);
const resetPreview = useSheetStore((state) => state.resetPreview);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -53,8 +55,7 @@ export default function SourcesPanel() {
try {
setHasFile('loading');
validateExcelImport(fileToUpload);
await uploadExcel(fileToUpload);
const names = await getWorksheetNamesExcel();
const names = await uploadExcel(fileToUpload);
setWorksheets(names);
setImportFlow('excel');
setHasFile('done');
@@ -77,6 +78,7 @@ export default function SourcesPanel() {
setHasFile('none');
setWorksheets(null);
setCustomFields(null);
setSummary(null);
setError('');
setSheetId(null);
};
@@ -110,6 +112,7 @@ export default function SourcesPanel() {
const previewData = await importRundownPreviewExcel(importMap);
setRundown(previewData.rundown);
setCustomFields(previewData.customFields);
setSummary(previewData.summary);
} catch (error) {
setError(maybeAxiosError(error));
}
@@ -152,7 +155,7 @@ export default function SourcesPanel() {
const showCompleted = importFlow === 'finished';
const showAuth = isGSheetFlow && !isAuthenticated;
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
const showReview = rundown !== null && customFields !== null;
const showReview = rundown !== null && customFields !== null && summary !== null;
return (
<Panel.Section>
@@ -219,8 +222,10 @@ export default function SourcesPanel() {
<ImportReview
rundown={rundown}
customFields={customFields}
summary={summary}
onFinished={handleFinished}
onCancel={cancelImportMap}
onBack={resetPreview}
/>
)}
</Panel.Card>
@@ -5,6 +5,7 @@ import { checkRegex, ImportMap } from 'ontime-utils';
import Button from '../../../../../../common/components/buttons/Button';
import IconButton from '../../../../../../common/components/buttons/IconButton';
import Info from '../../../../../../common/components/info/Info';
import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select';
import Tooltip from '../../../../../../common/components/tooltip/Tooltip';
@@ -136,6 +137,10 @@ export default function ImportMapForm({
</Button>
</Panel.InlineElements>
</Panel.Title>
<Info>
Match your spreadsheet columns to Ontime fields. <br />
You can also add Custom Fields by providing a name for Ontime and the spreadsheet column name.
</Info>
<Panel.Table>
<thead>
<tr>
@@ -18,9 +18,7 @@ function booleanToText(value?: boolean) {
return value ? 'Yes' : undefined;
}
export default function PreviewRundown(props: PreviewRundownProps) {
const { rundown, customFields } = props;
export default function PreviewRundown({ rundown, customFields }: PreviewRundownProps) {
// we only count Ontime Events which are 1 based in client
let eventIndex = 0;
@@ -59,9 +57,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
return (
<tr key={entry.id}>
<td className={style.center}>
<Tag>-</Tag>
</td>
<td /> {/** Index */}
<td className={style.center}>
<Tag>{entry.type}</Tag>
</td>
@@ -21,6 +21,7 @@ export default function useGoogleSheet() {
const patchStepData = useSheetStore((state) => state.patchStepData);
const setRundown = useSheetStore((state) => state.setRundown);
const setCustomFields = useSheetStore((state) => state.setCustomFields);
const setSummary = useSheetStore((state) => state.setSummary);
/** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
@@ -58,6 +59,7 @@ export default function useGoogleSheet() {
const data = await previewRundown(sheetId, fileOptions);
setRundown(data.rundown);
setCustomFields(data.customFields);
setSummary(data.summary);
} catch (error) {
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
}
@@ -1,4 +1,4 @@
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils';
import { create } from 'zustand';
@@ -17,10 +17,10 @@ type SheetStore = {
// we get this from a preview response
rundown: Rundown | null;
setRundown: (rundown: Rundown | null) => void;
// we get this from a preview response
customFields: CustomFields | null;
setCustomFields: (customFields: CustomFields | null) => void;
summary: RundownSummary | null;
setSummary: (metadata: RundownSummary | null) => void;
spreadsheetImportMap: ImportMap;
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => void;
@@ -43,6 +43,7 @@ const initialState = {
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
rundown: null,
customFields: null,
summary: null,
spreadsheetImportMap: defaultImportMap,
};
@@ -64,6 +65,8 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
setSummary: (summary: RundownSummary | null) => set({ summary }),
patchSpreadsheetImportMap: <T extends keyof ImportMap>(field: T, value: ImportMap[T]) => {
const currentImportMap = get().spreadsheetImportMap;
if (currentImportMap[field] !== value) {
@@ -72,5 +75,5 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
},
reset: () => set(initialState),
resetPreview: () => set({ rundown: null, customFields: null }),
resetPreview: () => set({ rundown: null, customFields: null, summary: null }),
}));
@@ -6,6 +6,7 @@ import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewN
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../../externals';
import MessageControl from './MessageControl';
@@ -20,7 +21,7 @@ function MessageControlExport() {
<ProtectRoute permission='editor'>
<div className={style.messages} data-testid='panel-messages-control'>
{!isExtracted && <Corner onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={classes}>
<ErrorBoundary>
@@ -5,6 +5,7 @@ import ErrorBoundary from '../../../common/components/error-boundary/ErrorBounda
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils';
import { getIsNavigationLocked } from '../../../externals';
import PlaybackControl from './PlaybackControl';
@@ -18,7 +19,7 @@ function TimerControlExport() {
<ProtectRoute permission='editor'>
<div className={style.playback} data-testid='panel-timer-control'>
{!isExtracted && <Corner onClick={(event) => handleLinks('timercontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.content}>
<ErrorBoundary>
@@ -8,6 +8,7 @@ import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
@@ -55,7 +56,7 @@ function RundownExport() {
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<div className={style.list}>
<ErrorBoundary>
@@ -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;
}
@@ -15,7 +15,6 @@ import Switch from '../../common/components/switch/Switch';
import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets';
import copyToClipboard from '../../common/utils/copyToClipboard';
import { preventEscape } from '../../common/utils/keyEvent';
import { linkToOtherHost } from '../../common/utils/linkUtils';
import { isUrlSafe } from '../../common/utils/regex';
import { isOntimeCloud, serverURL } from '../../externals';
import * as Panel from '../app-settings/panel-utils/PanelUtils';
@@ -136,7 +135,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
}
const url = await generateUrl({
baseUrl: linkToOtherHost(options.baseUrl),
baseUrl: options.baseUrl,
path,
authenticate: options.authenticate,
lockConfig: options.lockConfig,
@@ -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) {
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router';
import { EntryId, PlayableEvent } from 'ontime-types';
import Button from '../../common/components/buttons/Button';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
@@ -12,7 +13,7 @@ import { makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss';
interface CountdownSelectProps {
events: PlayableEvent[];
events: ExtendedEntry<PlayableEvent>[];
subscriptions: EntryId[];
disableEdit: () => void;
}
+8 -4
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,15 +117,19 @@
// 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"] {
&[data-phase='warning'] {
color: var(--timer-colour, var(--timer-warning-color-override));
}
&[data-phase="danger"] {
&[data-phase='danger'] {
color: var(--timer-colour, var(--timer-danger-color-override));
}
&[data-type='none'] {
transition: 1s;
opacity: 0;
}
}
}
+1
View File
@@ -179,6 +179,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-type={viewTimerType}
data-phase={time.phase}
>
{display}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.0.0",
"version": "4.1.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.0.0",
"version": "4.1.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
+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,124 @@
export const simpleTemplate = [
['ⓘ Notes on import', '', '', '', '', '', 'ⓘ How to use'],
[
'Ontime will import this spreasheet as a rundown in your project',
'',
'',
'',
'',
'',
'1. Make your own copy of the spreadsheet by either File -> Make a copy or File -> Download -> Microsoft Excel',
],
[
'- Each spreadsheet can only contain one rundown',
'',
'',
'',
'',
'',
'2. Delete the table header with the Notes on import and How to use instructions',
],
[
'- You can have as many columns as necessary, Ontime only imports the columns you request',
'',
'',
'',
'',
'',
'3. Remove any columns that are not necessary to your workflow',
],
[
'- The names of the worksheet and columns are given in Ontime on import',
'',
'',
'',
'',
'',
'See documentation for spreadsheet import',
'',
'https://docs.getontime.no/features/import-spreadsheet/',
],
[
'- Ontime creates an entry for every row on the table with a known Timer Type (see note on column header)',
'',
'',
'',
'',
'',
'See documentation for Google Sheet import',
'',
'https://docs.getontime.no/features/import-spreadsheet-gsheet/',
],
['- All fields in a row are optional, Ontime will fill in with default data as needed'],
['ⓘ Hover over note tags to see additional help on column data'],
[
'',
'',
'',
'Note ---------------------------------------------',
'',
'',
'',
'Note',
'Note --------------------------------------------------------',
],
['Cue', 'Title', 'Colour', 'Time Start', 'Time End', 'Duration', 'Note', 'Timer Type', 'Video Notes'],
[
'Demo',
'Clear all, or Create New Project to start fresh',
'#9d9d9d',
'',
'',
'',
'Moderator - Emma Thompson\n' +
'\n' +
'Speakers\n' +
'- Liam Carter + Sophia Patel\n' +
'- Ethan Brooks\n' +
'- Lucas Bennett',
'milestone',
],
['', 'Morning Sessions', '#339E4E', '', '', '', '', 'group-start'],
[
'1',
'Pre-show Countdown',
'#77C785',
'10:00:00',
'11:00:00',
'',
'Music plays, holding slide on screens',
'count-down',
'Camera + PowerPoint on stream\nPowerPoint on screens',
],
['Standby', '10:45 - Presenters ready side stage', '#A790F5', '', '', '', '', 'milestone'],
['1.1', 'Welcome', '#FFCC78', '', '', '00:10:00', '', 'count-down', 'Cameras on stream\nPowerPoint on screens'],
[
'1.2',
'Session 1',
'#77C785',
'',
'',
'00:50:00',
'',
'count-down',
'Camera + PowerPoint on stream\nPowerPoint on screens',
],
['House', '11:30 - House staff setup lunch in lobby', '#A790F5', '', '', '', '', 'milestone'],
['', 'Lunch', '#3E75E8', '', '', '', '', 'group-start'],
['2.1', 'Lunch / Countdown to next session', '#779BE7', '12:00:00', '13:00:00', '', '', 'count-down'],
['', 'Afternoon Sessions', '#339E4E', '', '', '', '', 'group-start'],
['Standby', '12:45 - Presenters ready side stage', '#A790F5', '', '', '', '', 'milestone'],
[
'3.2',
'Session 2',
'#77C785',
'13:00:00',
'',
'00:50:00',
'',
'count-down',
'Camera + PPT + Video on stream\n' + 'PowerPoint + Video on screens\n' + '\n' + 'Video file: Session2.mp4',
],
['3.2', 'Wrap up', '#FFCC78', '', '', '00:10:00', '', 'count-down', 'Holding slide on screens'],
['Strike', '14:00 - AV & Room strike', '#A790F5', '', '', '', '', 'milestone'],
];
@@ -19,8 +19,10 @@ exports[`rundownToTabular() > returns an array of arrays describing a rundown 1`
"Warning Time",
"Danger Time",
"Skip",
"Custom Field",
"Images",
"Video Notes",
"Audio Notes",
"PowerPoint Name",
"PowerPoint Slide",
],
[
"e2163f",
@@ -33,16 +35,21 @@ exports[`rundownToTabular() > returns an array of arrays describing a rundown 1`
"",
"",
"",
"Go to Settings cog wheel top left of Editor, Project > Create",
"Moderator - Emma Thompson
Speakers
- Liam Carter + Sophia Patel
- Ethan Brooks
- Lucas Bennett",
"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",
@@ -63,6 +70,8 @@ Go to Settings > Project data > Custom fields",
"",
"",
"",
"",
"",
],
[
"9bf60f",
@@ -81,8 +90,11 @@ Go to Settings > Project data > Custom fields",
"00:10:00",
"00:05:00",
"",
"Put additional info here",
"",
"Camera + PowerPoint on stream
PowerPoint on screens",
"2x Wireless Hand Helds",
"HoldingSlide.pptx",
"https://www.getontime.no/images/aux/demo-slide1.webp",
],
[
"bf71a2",
@@ -103,6 +115,8 @@ Go to Settings > Project data > Custom fields",
"",
"",
"",
"",
"",
],
[
"c2697f",
@@ -121,8 +135,11 @@ Go to Settings > Project data > Custom fields",
"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",
@@ -141,8 +158,11 @@ Go to Settings > Project data > Custom fields",
"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",
@@ -163,6 +183,8 @@ Go to Settings > Project data > Custom fields",
"",
"",
"",
"",
"",
],
[
"",
@@ -201,6 +223,8 @@ Go to Settings > Project data > Custom fields",
"",
"",
"",
"",
"",
],
[
"0aaa7d",
@@ -213,15 +237,16 @@ Go to Settings > Project data > Custom fields",
"01:00:00",
"TRUE",
"",
"Buffet in lobby
Music plays, holding slide on screens",
"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",
],
[
"",
@@ -260,6 +285,8 @@ Music plays, holding slide on screens",
"",
"",
"",
"",
"",
],
[
"02afca",
@@ -280,6 +307,8 @@ Music plays, holding slide on screens",
"",
"",
"",
"",
"",
],
[
"75ce86",
@@ -298,8 +327,14 @@ Music plays, holding slide on screens",
"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",
@@ -318,8 +353,10 @@ Music plays, holding slide on screens",
"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",
@@ -340,6 +377,8 @@ Music plays, holding slide on screens",
"",
"",
"",
"",
"",
],
[
"",
@@ -1,12 +1,40 @@
import { CustomFields, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types';
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
import { ImportMap, MILLIS_PER_MINUTE, millisToString } from 'ontime-utils';
import { parseExcel } from '../excel.parser.js';
import { dataFromExcelTemplate } from './mockData.js';
import { simpleTemplate } from '../__mocks__/simpleTemplate.js';
describe('parseExcel()', () => {
it('parses the example file', () => {
it('parses the simple template file', () => {
const result = parseExcel(simpleTemplate, {});
expect(result.rundown.flatOrder.length).toBe(14);
expect(result.rundown.order.length).toBe(4);
const firstEventId = result.rundown.flatOrder[2];
const firstEvent = result.rundown.entries[firstEventId];
expect(firstEvent).toMatchObject({
id: firstEventId,
type: SupportedEntry.Event,
title: 'Pre-show Countdown',
});
expect(millisToString((firstEvent as OntimeEvent).timeStart)).toBe('10:00:00');
const lastEventId = result.rundown.flatOrder[12];
const lastEvent = result.rundown.entries[lastEventId];
expect(lastEvent).toMatchObject({
id: lastEventId,
type: SupportedEntry.Event,
title: 'Wrap up',
});
// the excel step does not relate elements yet
expect(millisToString((lastEvent as OntimeEvent).timeEnd)).toBe('00:00:00');
});
it('parses an import map with only custom fields', () => {
// partial import map with only custom fields
const importMap = {
custom: {
@@ -185,6 +185,7 @@ export const parseExcel = (
if (isOntimeGroup(entry as OntimeEntry)) {
const group = {
...entry,
id,
targetDuration: entry.duration ? entry.duration : null,
custom: { ...entryCustomFields },
} as OntimeGroup;
@@ -200,7 +201,11 @@ export const parseExcel = (
}
if (isOntimeMilestone(entry as OntimeEntry)) {
const milestone = { ...entry, custom: { ...entryCustomFields } } as OntimeMilestone;
const milestone = {
...entry,
id,
custom: { ...entryCustomFields },
} as OntimeMilestone;
if (currentGroupId) {
groupEntries.push(id);
milestone.parent = currentGroupId;
@@ -212,9 +217,10 @@ export const parseExcel = (
return;
}
//and fall through to treat it as an event
// after group and milestones we only have events remaining
const event = {
...entry,
id,
custom: { ...entryCustomFields },
type: SupportedEntry.Event,
} as OntimeEvent;
@@ -223,6 +229,7 @@ export const parseExcel = (
event.timerType = TimerType.CountDown;
}
// we link all events unless user specifies otherwise
if (entry.linkStart === undefined) {
event.linkStart = true;
}
+24 -24
View File
@@ -1,41 +1,41 @@
import express from 'express';
import type { Request, Response } from 'express';
import { CustomFields, ErrorResponse, Rundown } from 'ontime-types';
import { CustomFields, ErrorResponse, Rundown, RundownSummary } from 'ontime-types';
import { getDataProvider } from '../../classes/data-provider/DataProvider.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 { generateExcelFile, generateRundownPreview, 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();
router.post('/upload', uploadExcel, validateFileExists, async (req: Request, res: Response<never | ErrorResponse>) => {
try {
// file has been validated by middleware
const filePath = (req.file as Express.Multer.File).path;
await readExcelFile(filePath);
res.status(201).send();
} catch (error) {
res.status(500).send({ message: String(error) });
}
});
router.get('/worksheets', (_req: Request, res: Response<string[] | ErrorResponse>) => {
try {
const names = listWorksheets();
res.status(200).send(names);
} catch (error) {
res.status(500).send({ message: String(error) });
}
});
router.post(
'/upload',
uploadExcel,
validateFileExists,
async (req: Request, res: Response<string[] | ErrorResponse>) => {
try {
// file has been validated by middleware
const filePath = (req.file as Express.Multer.File).path;
const worksheetNames = await readExcelFile(filePath);
res.status(200).send(worksheetNames);
} catch (error) {
res.status(500).send({ message: String(error) });
}
},
);
router.post(
'/preview',
validateImportMapOptions,
(req: Request, res: Response<{ rundown: Rundown; customFields: CustomFields } | ErrorResponse>) => {
(
req: Request,
res: Response<{ rundown: Rundown; customFields: CustomFields; summary: RundownSummary } | ErrorResponse>,
) => {
try {
const { options } = req.body;
const data = generateRundownPreview(options);
@@ -52,7 +52,7 @@ router.get('/:rundownId/export', validateRundownExport, (req: Request, res: Resp
const customFields = getProjectCustomFields();
const buffer = generateExcelFile(rundown, customFields);
res.setHeader('Content-Disposition', `attachment;`);
res.setHeader('Content-Disposition', 'attachment;');
res.setHeader('Content-Type', EXCEL_MIME);
res.setHeader('Content-Length', buffer.length.toString());
res.status(200).send(buffer);
+32 -17
View File
@@ -3,7 +3,7 @@
* Google Sheets
*/
import { CustomFields, Rundown } from 'ontime-types';
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { type ImportMap } from 'ontime-utils';
import { extname } from 'path';
@@ -14,38 +14,40 @@ import type { WorkBook } from 'xlsx';
import { deleteFile } from '../../utils/fileManagement.js';
import { parseRundown } from '../rundown/rundown.parser.js';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { getProjectCustomFields, processRundown } 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';
// we keep the excel data in memory to allow the flow upload -> preview
let excelData: WorkBook = xlsx.utils.book_new();
/**
* Receives and parses an excel file
* The file is deleted after being read
*/
export async function readExcelFile(filePath: string) {
export async function readExcelFile(filePath: string): Promise<string[]> {
if (!existsSync(filePath)) {
throw new Error('Upload of excel file failed');
}
if (extname(filePath) != '.xlsx') {
throw new Error('Wrong file format');
}
// clear the data
excelData = xlsx.readFile(filePath, { cellDates: true, cellFormula: false });
await deleteFile(filePath);
}
/**
* List all worksheets in the current spreadsheet file
*/
export function listWorksheets(): string[] {
return excelData.SheetNames;
}
export function generateRundownPreview(options: ImportMap): { rundown: Rundown; customFields: CustomFields } {
export function generateRundownPreview(options: ImportMap): {
rundown: Rundown;
summary: RundownSummary;
customFields: CustomFields;
} {
const data = excelData.Sheets[options.worksheet];
if (!data) {
@@ -55,18 +57,31 @@ export function generateRundownPreview(options: ImportMap): { rundown: Rundown;
const arrayOfData: unknown[][] = xlsx.utils.sheet_to_json(data, { header: 1, blankrows: false, raw: false });
const dataFromExcel = parseExcel(arrayOfData, getProjectCustomFields(), options.worksheet, options);
const parsedCustomFields = parseCustomFields(dataFromExcel);
// we run the parsed data through an extra step to ensure the objects shape
const rundown = parseRundown(dataFromExcel.rundown, parsedCustomFields);
if (rundown.order.length === 0) {
if (dataFromExcel.rundown.flatOrder.length === 0) {
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
}
// clear the data
excelData = xlsx.utils.book_new();
// we run the parsed data through an extra step to ensure the objects shape
const customFields = parseCustomFields(dataFromExcel);
const parsedRundown = parseRundown(dataFromExcel.rundown, customFields);
const processedRundown = processRundown(parsedRundown, customFields);
return { rundown, customFields: parsedCustomFields };
return {
rundown: {
id: parsedRundown.id,
title: parsedRundown.title,
order: processedRundown.order,
flatOrder: processedRundown.flatEntryOrder,
entries: processedRundown.entries,
revision: 0,
},
summary: {
duration: processedRundown.totalDuration,
start: processedRundown.firstStart,
end: processedRundown.lastEnd,
},
customFields,
};
}
/**
@@ -3,11 +3,14 @@ import { MILLIS_PER_HOUR } from 'ontime-utils';
import { assertType } from 'vitest';
import { demoDb } from '../../../models/demoProject.js';
import {
calculateDayOffset,
createEvent,
deleteById,
doesInvalidateMetadata,
duplicateRundown,
getInsertAfterId,
hasChanges,
} from '../rundown.utils.js';
@@ -260,3 +263,22 @@ describe('getInsertAfterId()', () => {
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '32')).toBe('31');
});
});
describe('duplicateRundown', () => {
it("duplicates a given rundown", () => {
const demoRundown = demoDb.rundowns['default'];
const title = 'Duplicated Rundown';
const duplicatedRundown = duplicateRundown(demoRundown, title);
expect(duplicatedRundown).toMatchObject({
title: title,
entries: expect.any(Object),
order: expect.any(Array),
flatOrder: expect.any(Array),
})
expect(demoRundown.id).not.toEqual(duplicatedRundown.id);
expect(duplicatedRundown.order.length).toEqual(demoRundown.order.length);
expect(duplicatedRundown.flatOrder.length).toEqual(demoRundown.flatOrder.length);
expect(Object.keys(duplicatedRundown.entries).length).toEqual(Object.keys(demoRundown.entries).length);
})
})
@@ -32,7 +32,7 @@ import {
import { paramsWithId } from '../validation-utils/validationFunction.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { defaultRundown } from '../../models/dataModel.js';
import { normalisedToRundownArray } from './rundown.utils.js';
import { duplicateRundown, normalisedToRundownArray } from './rundown.utils.js';
export const router = express.Router();
@@ -95,6 +95,60 @@ router.post('/', rundownPostValidator, async (req: Request, res: Response<Projec
}
});
/**
* Duplicates an existing rundown
*/
router.post(
'/:id/duplicate',
paramsWithId,
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(req.params.id);
const duplicatedRundown: Rundown = duplicateRundown(rundown, `Copy of ${rundown.title}`);
await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown);
const projectRundowns = getDataProvider().getProjectRundowns();
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
/**
* Patches the data of an existing rundown
* Currently only the title can be changed
*/
router.patch('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(req.params.id);
if (!rundown) throw new Error(`Rundown with ID ${req.params.id} not found`);
if (!req.body.title) throw new Error('No title provided');
await dataProvider.setRundown(rundown.id, { ...rundown, title: req.body.title });
/**
* If loaded we re-init the rundown
* This is likely over-kill but the simplest way to ensure state consistency
*/
if (req.params.id === getCurrentRundown().id) {
const rundown = dataProvider.getRundown(req.params.id);
const customField = dataProvider.getCustomFields();
await initRundown(rundown, customField);
}
const projectRundowns = getDataProvider().getProjectRundowns();
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
/**
* Deletes a rundown if not loaded
*/
@@ -34,6 +34,7 @@ import {
milestone as milestoneDef,
} from '../../models/eventsDefinition.js';
import { makeString } from '../../utils/parserUtils.js';
import { RundownMetadata } from './rundown.types.js';
type CompleteEntry<T> =
@@ -517,3 +518,17 @@ export function normalisedToRundownArray(rundowns: ProjectRundowns): ProjectRund
return { id, numEntries: flatOrder.length, title, revision };
});
}
/**
* Duplicates an existing rundown ensuring all IDs are unique
*/
export function duplicateRundown(rundown: Rundown, newTitle: string): Rundown {
const newRundownId = generateId();
const newRundown = structuredClone(rundown);
newRundown.id = newRundownId;
newRundown.title = newTitle;
newRundown.revision = 0;
return newRundown;
}
@@ -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,7 +1,7 @@
import { EndAction, OntimeEvent, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { getA1Notation, cellRequestFromEvent } from '../sheetUtils.js';
import { getA1Notation, cellRequestFromEvent } from '../sheets.utils.js';
describe('getA1Notation()', () => {
test('A1', () => {
@@ -3,12 +3,14 @@
* Google Sheets
*/
import type { AuthenticationStatus, CustomFields, ErrorResponse, Rundown } from 'ontime-types';
import type { AuthenticationStatus, CustomFields, ErrorResponse, Rundown, RundownSummary } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { Request, Response } from 'express';
import { readFileSync } from 'fs';
import { deleteFile } from '../../utils/fileManagement.js';
import {
revoke,
handleClientSecret,
@@ -17,8 +19,7 @@ import {
download,
upload,
getWorksheetOptions,
} from '../../services/sheet-service/SheetService.js';
import { deleteFile } from '../../utils/fileManagement.js';
} from './sheets.service.js';
export async function requestConnection(
req: Request,
@@ -86,6 +87,7 @@ export async function readFromSheet(
| {
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
}
| ErrorResponse
>,
@@ -15,6 +15,7 @@ import {
MaybeString,
OntimeGroup,
Rundown,
RundownSummary,
SupportedEntry,
} from 'ontime-types';
import { ImportMap, getErrorMessage } from 'ontime-utils';
@@ -23,13 +24,14 @@ import { sheets, type sheets_v4 } from '@googleapis/sheets';
import { Credentials, OAuth2Client } from 'google-auth-library';
import { logger } from '../../classes/Logger.js';
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
import { getCurrentRundown, getProjectCustomFields } from '../../api-data/rundown/rundown.dao.js';
import { parseExcel } from '../../api-data/excel/excel.parser.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseRundowns } from '../rundown/rundown.parser.js';
import { getCurrentRundown, getProjectCustomFields, processRundown } from '../rundown/rundown.dao.js';
import { parseExcel } from '../excel/excel.parser.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import { consoleSubdued } from '../../utils/console.js';
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheetUtils.js';
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheets.utils.js';
import { catchCommonImportXlsxError } from './googleApi.utils.js';
const sheetScope = 'https://www.googleapis.com/auth/spreadsheets';
@@ -377,7 +379,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>();
@@ -455,6 +457,7 @@ export async function download(
): Promise<{
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
}> {
if (!currentAuthClient) {
throw new Error('Not authenticated');
@@ -488,9 +491,9 @@ export async function download(
};
const customFields = parseCustomFields(dataModel);
const rundowns = parseRundowns(dataModel, customFields);
const parsedRundown = parseRundowns(dataModel, customFields);
const importedRundown = rundowns[rundownId];
const importedRundown = parsedRundown[rundownId];
if (!importedRundown) {
throw new Error(`Sheet: Rundown with ID ${rundownId} not found in the worksheet`);
}
@@ -499,5 +502,22 @@ export async function download(
throw new Error('Sheet: Could not find data to import in the worksheet');
}
return { rundown: rundowns[rundownId], customFields };
const processedRundown = processRundown(importedRundown, customFields);
return {
rundown: {
id: importedRundown.id,
title: importedRundown.title,
order: processedRundown.order,
flatOrder: processedRundown.flatEntryOrder,
entries: processedRundown.entries,
revision: 0,
},
summary: {
duration: processedRundown.totalDuration,
start: processedRundown.firstStart,
end: processedRundown.lastEnd,
},
customFields,
};
}
@@ -9,6 +9,7 @@ import {
import { cssOrHexToColour, isLightColour, millisToString, mixColours } from 'ontime-utils';
import type { sheets_v4 } from '@googleapis/sheets';
import { is } from '../../utils/is.js';
export type ClientSecret = {
+7 -1
View File
@@ -84,6 +84,11 @@ app.use(bodyParser);
app.use(cookieParser());
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware(prefix);
// implement health check route
app.get(`${prefix}/health`, (_req, res) => {
res.status(200).send('OK');
});
// Implement route endpoints
app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
@@ -264,6 +269,7 @@ export const shutdown = async (exitCode = 0) => {
// 0 means it was a SIGNAL
// 1 means crash -> keep the file
// 2 means dev crash -> do nothing
// 3 means container shutdown -> keep the file
// 99 means there was a shutdown request from the UI
if (exitCode === 0 || exitCode === 99) {
await restoreService.clear();
@@ -300,4 +306,4 @@ process.on('uncaughtException', async (error) => {
// register shutdown signals
process.once('SIGHUP', async () => shutdown(0));
process.once('SIGINT', async () => shutdown(0));
process.once('SIGTERM', async () => shutdown(0));
process.once('SIGTERM', async () => shutdown(3));
+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) {
@@ -681,8 +683,11 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// if playback changes most things should update
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
const addedTimeChanged = !justStarted && RuntimeService.previousState?.timer.addedTime !== state.timer.addedTime;
// combine all big changes
const hasImmediateChanges = entryChanged || justStarted || hasChangedPlayback || offsetModeChanged;
const hasImmediateChanges =
entryChanged || justStarted || hasChangedPlayback || offsetModeChanged || addedTimeChanged;
/**
* if any values have changed.
@@ -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;
}
}
+1 -1
View File
@@ -407,7 +407,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
state.timer.elapsed = 0;
if (state.rundown.actualStart === null) {
state._startDayOffset = findDayOffset(state.eventNow.timeStart, state.clock);
state._startDayOffset = findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset;
state.rundown.currentDay = state._startDayOffset;
state._startEpoch = epoch;
state.rundown.actualStart = state.clock;
+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);
});
});
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.0.0",
"version": "4.1.0",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -22,6 +22,7 @@
"type": "module",
"scripts": {
"dev": "turbo run dev",
"dev:prefix": "cross-env ROUTER_PREFIX=test turbo run dev --filter=ontime-server",
"dev:electron": "turbo run dev:electron",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
@@ -46,10 +47,11 @@
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-playwright": "^1.5.2",
"eslint-plugin-prettier": "catalog:",
"prettier": "catalog:",
"rimraf": "catalog:",
"turbo": "2.5.8",
"typescript": "catalog:",
"rimraf": "catalog:"
"typescript": "catalog:"
},
"packageManager": "pnpm@10.18.0+sha512.e804f889f1cecc40d572db084eec3e4881739f8dec69c0ff10d2d1beff9a4e309383ba27b5b750059d7f4c149535b6cd0d2cb1ed3aeb739239a4284a68f40cfa"
}
@@ -1,4 +1,5 @@
import type { EntryId, OntimeEntry } from '../../definitions/core/OntimeEntry.js';
import type { MaybeNumber } from '../../utils/utils.type.js';
export type PatchWithId<T extends OntimeEntry = OntimeEntry> = Partial<T> & { id: EntryId };
@@ -23,3 +24,9 @@ export type ProjectRundownsList = {
loaded: string;
rundowns: ProjectRundown[];
};
export type RundownSummary = {
duration: number;
start: MaybeNumber;
end: MaybeNumber;
};
+1
View File
@@ -83,6 +83,7 @@ export type {
ProjectRundown,
ProjectRundownsList,
TransientEventPayload,
RundownSummary,
} from './api/rundown-controller/BackendResponse.type.js';
export type { LinkOptions } from './api/session-controller/BackendResponse.type.js';
+3
View File
@@ -68,6 +68,9 @@ importers:
eslint-plugin-playwright:
specifier: ^1.5.2
version: 1.8.3(eslint-plugin-jest@28.14.0(@typescript-eslint/eslint-plugin@7.16.1(@typescript-eslint/parser@7.16.1(eslint@8.56.0)(typescript@5.5.3))(eslint@8.56.0)(typescript@5.5.3))(eslint@8.56.0)(typescript@5.5.3))(eslint@8.56.0)
eslint-plugin-prettier:
specifier: 'catalog:'
version: 5.1.3(eslint-config-prettier@9.1.0(eslint@8.56.0))(eslint@8.56.0)(prettier@3.3.1)
prettier:
specifier: 'catalog:'
version: 3.3.1
+7 -4
View File
@@ -1,19 +1,22 @@
packages:
- apps/*
- packages/*
catalog:
typescript: 5.5.3
'@types/node': 22.15.26
'@typescript-eslint/eslint-plugin': 7.16.1
'@typescript-eslint/parser': 7.16.1
'@types/node': 22.15.26
prettier: 3.3.1
eslint: 8.56.0
eslint-config-prettier: 9.1.0
eslint-plugin-prettier: 5.1.3
vitest: 3.2.1
prettier: 3.3.1
rimraf: 6.0.1
ts-essentials: 10.1.1
typescript: 5.5.3
vitest: 3.2.1
onlyBuiltDependencies:
- electron
- esbuild
useNodeVersion: 22.15.1
+2 -1
View File
@@ -13,7 +13,8 @@
"tasks": {
"dev": {
"cache": false,
"persistent": true
"persistent": true,
"env": ["ROUTER_PREFIX"]
},
"dev:electron": {
"cache": false,