mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dce1a40bc | |||
| b75b69c3b1 | |||
| e6070e6efd | |||
| 3918664945 | |||
| b65b3fa6d2 | |||
| 162693e19e | |||
| becd734ea8 | |||
| 9dfc8083f0 | |||
| 7bd98c23a4 | |||
| 6f19d78e53 | |||
| 163baa752f | |||
| 6f708df6f6 | |||
| eb5c62cca1 | |||
| 99d2debca8 | |||
| 74e59dccdb | |||
| 812b17a221 | |||
| 32bf0f53f6 | |||
| 17f80baf48 | |||
| e0377a7244 | |||
| b7e5723fb2 | |||
| 897210d5e3 | |||
| ee3d38fce8 | |||
| 37abd3fe47 | |||
| b509ee5b1f | |||
| dd2b59efe5 | |||
| 5788785b3e | |||
| e488ad19b3 | |||
| cbc03c898c | |||
| 155bfbdc42 | |||
| 5d3714a4fc | |||
| a8a1d210d8 | |||
| 49de8b63a7 | |||
| 03c9f51b47 | |||
| 8d00fd3966 | |||
| 80fd21354e | |||
| a87b15825a | |||
| 6a8f98c469 | |||
| 8685298430 | |||
| d43af2db57 | |||
| ef718c48e4 | |||
| 45e7373279 | |||
| 6af2d6e021 | |||
| 9b36f36f73 | |||
| 3a0dbf057e | |||
| dae84ab48a |
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
+77
@@ -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>
|
||||
);
|
||||
}
|
||||
+33
-3
@@ -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>
|
||||
);
|
||||
|
||||
+9
-4
@@ -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
@@ -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>
|
||||
|
||||
+2
-6
@@ -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>
|
||||
|
||||
+2
@@ -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) } });
|
||||
}
|
||||
|
||||
+7
-4
@@ -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>
|
||||
|
||||
+12
-7
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
-1
@@ -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
|
||||
>,
|
||||
|
||||
+29
-9
@@ -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,
|
||||
};
|
||||
}
|
||||
+1
@@ -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 = {
|
||||
@@ -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));
|
||||
|
||||
Vendored
+2
-2
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Generated
+3
@@ -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
@@ -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
@@ -13,7 +13,8 @@
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
"persistent": true,
|
||||
"env": ["ROUTER_PREFIX"]
|
||||
},
|
||||
"dev:electron": {
|
||||
"cache": false,
|
||||
|
||||
Reference in New Issue
Block a user