mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 06:28:01 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0057b4a3f1 | |||
| ac8c1e333e | |||
| 321297b685 | |||
| 67e67d6a5d | |||
| 4385e0b9c9 | |||
| 02243242f1 | |||
| 1af42b766d | |||
| 9d6ec3758b | |||
| 9e78ff4d1c | |||
| 2037aef080 | |||
| 48b9cf042f | |||
| 1866e5e78d | |||
| bc1b3800c8 | |||
| a2505c838b | |||
| bffa863fb4 | |||
| d00d7677bc | |||
| 3ae73be562 | |||
| 763080cd4d | |||
| 6ff06b21a8 | |||
| adebde1b43 | |||
| 89922fe394 | |||
| f219ebb8f8 | |||
| eace97ec7a | |||
| ec1df8b958 |
@@ -34,6 +34,10 @@ e2e/tests/fixtures/tmp/*
|
||||
build/
|
||||
dist/
|
||||
|
||||
# bundled assets
|
||||
translations.json
|
||||
override.css
|
||||
|
||||
# working stuff
|
||||
**/TODO.md
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.0.0-alpha.5",
|
||||
"version": "4.0.0-beta.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
+11
-11
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.0.0-alpha.5",
|
||||
"version": "4.0.0-beta.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui-components/react": "1.0.0-beta.2",
|
||||
"@base-ui-components/react": "1.0.0-beta.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^8.2.4",
|
||||
"@fontsource/open-sans": "^5.2.6",
|
||||
"@mantine/hooks": "^8.2.8",
|
||||
"@sentry/react": "^10.2.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.84.1",
|
||||
"@tanstack/react-query-devtools": "^5.84.1",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
"@tanstack/react-query-devtools": "^5.85.9",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.11.0",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"csv-stringify": "^6.6.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.1.1",
|
||||
@@ -27,11 +27,11 @@
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-router": "^7.8.0",
|
||||
"react-router": "^7.8.2",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"react-virtuoso": "^4.14.0",
|
||||
"web-vitals": "^5.1.0",
|
||||
"zustand": "^5.0.7"
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
@@ -64,8 +64,8 @@
|
||||
"@sentry/vite-plugin": "^2.16.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitejs/plugin-react": "4.5.1",
|
||||
|
||||
@@ -33,7 +33,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
if (newPath === '/' || newPath === currentPath) {
|
||||
return;
|
||||
}
|
||||
setRedirect({ target: id, redirect: newPath });
|
||||
|
||||
if (newPath.startsWith('preset-')) {
|
||||
setRedirect({ target: id, redirect: newPath.slice(7) });
|
||||
} else {
|
||||
setRedirect({ target: id, redirect: newPath });
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -45,7 +51,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
label: view.label,
|
||||
})),
|
||||
...enabledPresets.map((preset) => ({
|
||||
value: preset.search,
|
||||
value: `preset-${preset.alias}`,
|
||||
label: `URL Preset: ${preset.alias}`,
|
||||
})),
|
||||
];
|
||||
@@ -61,32 +67,11 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
<>
|
||||
<Info>
|
||||
Remotely redirect the client to a different URL. <br />
|
||||
Either by selecting a URL Preset or entering a custom path.
|
||||
Either by entering a custom path or selecting a URL Preset.
|
||||
<br />
|
||||
<br />
|
||||
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
|
||||
</Info>
|
||||
<div>
|
||||
<span className={style.label}>Select View or URL Preset</span>
|
||||
<div className={style.textEntry}>
|
||||
<Select
|
||||
fluid
|
||||
options={viewOptions}
|
||||
defaultValue={viewOptions[0].value}
|
||||
onValueChange={(value) => setSelected(value)}
|
||||
disabled={enabledPresets.length === 0}
|
||||
/>
|
||||
<Button
|
||||
variant='primary'
|
||||
aria-label='Redirect to preset'
|
||||
className={style.redirect}
|
||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
||||
onClick={() => handleRedirect(selected)}
|
||||
>
|
||||
Redirect <IoArrowForward />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.inlineEntry}>
|
||||
<span className={style.label}>Enter custom path</span>
|
||||
<label className={style.textEntry}>
|
||||
@@ -100,9 +85,34 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
className={style.redirect}
|
||||
onClick={() => handleRedirect(path)}
|
||||
>
|
||||
Redirect <IoArrowForward />
|
||||
Redirect
|
||||
<IoArrowForward />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className={style.label}>Select View or URL Preset</span>
|
||||
<div className={style.inlineEntry}>
|
||||
<label className={style.textEntry}>
|
||||
{origin}
|
||||
<Select
|
||||
fluid
|
||||
options={viewOptions}
|
||||
defaultValue={viewOptions[0].value}
|
||||
onValueChange={(value) => setSelected(value)}
|
||||
disabled={enabledPresets.length === 0}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant='primary'
|
||||
aria-label='Redirect to preset'
|
||||
className={style.redirect}
|
||||
disabled={enabledPresets.length === 0 || selected === '/'}
|
||||
onClick={() => handleRedirect(selected)}
|
||||
>
|
||||
Redirect <IoArrowForward />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -39,7 +39,14 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
}
|
||||
|
||||
if (type === 'multi-option') {
|
||||
return <MultiOption paramField={paramField} />;
|
||||
const optionFromParams = searchParams.getAll(id);
|
||||
|
||||
return (
|
||||
<MultiOption
|
||||
paramField={paramField}
|
||||
options={optionFromParams.length ? optionFromParams : paramField.defaultValue ?? ['']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
@@ -74,19 +81,17 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
|
||||
interface EditFormMultiOptionProps {
|
||||
paramField: ParamField & { type: 'multi-option' };
|
||||
options: string[];
|
||||
}
|
||||
|
||||
function MultiOption({ paramField }: EditFormMultiOptionProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { id, values, defaultValue = [''] } = paramField;
|
||||
|
||||
const optionFromParams = searchParams.getAll(id);
|
||||
const [paramState, setParamState] = useState<string[]>(optionFromParams.length ? optionFromParams : defaultValue);
|
||||
function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
|
||||
const { id, values } = paramField;
|
||||
const [paramState, setParamState] = useState<string[]>(options);
|
||||
|
||||
// synchronise options
|
||||
useEffect(() => {
|
||||
const params = searchParams.getAll(id);
|
||||
setParamState(params.length ? params : defaultValue);
|
||||
}, [searchParams, id, defaultValue]);
|
||||
setParamState(options);
|
||||
}, [options]);
|
||||
|
||||
const toggleValue = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
|
||||
@@ -56,13 +56,18 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
|
||||
/**
|
||||
* Creates data for a select element that displays project custom data
|
||||
*/
|
||||
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] {
|
||||
return projectData.custom.map((entry, index) => {
|
||||
export function makeProjectDataOptions(
|
||||
projectData: ProjectData,
|
||||
additionalOptions: SelectOption[] = [],
|
||||
): SelectOption[] {
|
||||
const generatedOptions = projectData.custom.map((entry, index) => {
|
||||
return {
|
||||
value: `${index}-${entry.title}`,
|
||||
label: entry.title,
|
||||
};
|
||||
});
|
||||
|
||||
return [...additionalOptions, ...generatedOptions];
|
||||
}
|
||||
|
||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { unobfuscate } from 'ontime-utils';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { APP_SETTINGS } from '../api/constants';
|
||||
import { getSettings } from '../api/settings';
|
||||
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
|
||||
@@ -11,10 +10,6 @@ export default function useSettings() {
|
||||
queryKey: APP_SETTINGS,
|
||||
queryFn: getSettings,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
select: (data) => {
|
||||
const unobfuscated = { ...data };
|
||||
if (data.editorKey) {
|
||||
|
||||
@@ -23,6 +23,9 @@ export const useFadeOutOnInactivity = (initialState = false) => {
|
||||
|
||||
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
|
||||
|
||||
// we call the function on mount, to make sure the menu is hidden
|
||||
throttledShowMenu();
|
||||
|
||||
document.addEventListener('mousemove', throttledShowMenu);
|
||||
document.addEventListener('keydown', throttledShowMenu);
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ const createSelector =
|
||||
|
||||
export const setClientRemote = {
|
||||
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload),
|
||||
setRedirect: (payload: { target: string; redirect: string }) => sendSocket('client', payload),
|
||||
setRedirect: (payload: { target: string; redirect: string }) => {
|
||||
console.log('--- got', payload);
|
||||
sendSocket('client', payload);
|
||||
},
|
||||
setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
|
||||
};
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('initRundownMetadata()', () => {
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLinkedToLoaded: true,
|
||||
isLoaded: true,
|
||||
groupId: 'group',
|
||||
groupColour: 'red',
|
||||
|
||||
@@ -125,6 +125,7 @@ function processEntry(
|
||||
if (entry.id === selectedEventId) {
|
||||
processedData.isLoaded = true;
|
||||
processedData.isPast = false;
|
||||
processedData.isLinkedToLoaded = true;
|
||||
}
|
||||
|
||||
if (isOntimeGroup(entry)) {
|
||||
@@ -158,7 +159,8 @@ function processEntry(
|
||||
* a) find an unlinked event
|
||||
* b) find a countToEnd event
|
||||
*/
|
||||
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
|
||||
processedData.isLinkedToLoaded =
|
||||
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
|
||||
}
|
||||
|
||||
if (isNewLatest(entry, processedData.latestEvent)) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
import { isProduction, websocketUrl } from '../../externals';
|
||||
import {
|
||||
APP_SETTINGS,
|
||||
CLIENT_LIST,
|
||||
CUSTOM_FIELDS,
|
||||
PROJECT_DATA,
|
||||
@@ -177,6 +178,9 @@ export const connectSocket = () => {
|
||||
case RefetchKey.Translation:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
|
||||
break;
|
||||
case RefetchKey.Settings:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
|
||||
break;
|
||||
default: {
|
||||
target satisfies never;
|
||||
break;
|
||||
|
||||
+5
-1
@@ -14,4 +14,8 @@
|
||||
.copiable {
|
||||
cursor: text ;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.self {
|
||||
background-color: $blue-1100;
|
||||
}
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ import { RenameClientModal } from '../../../../../common/components/client-modal
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import { setClientRemote } from '../../../../../common/hooks/useSocket';
|
||||
import { useClientStore } from '../../../../../common/stores/clientStore';
|
||||
import { cx } from '../../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ClientControlPanel.module.scss';
|
||||
@@ -71,13 +72,13 @@ export default function ClientList() {
|
||||
const { identify, name, path } = client;
|
||||
const isCurrent = id === key;
|
||||
return (
|
||||
<tr key={key}>
|
||||
<tr key={key} className={cx([isCurrent && style.self])}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
{isCurrent && <Tag>SELF</Tag>}
|
||||
{name}
|
||||
</Panel.InlineElements>
|
||||
<td className={style.copiable}>{path}</td>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Button
|
||||
size='small'
|
||||
className={`${identify ? style.blink : ''}`}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isStringBoolean } from '../viewers/common/viewUtils';
|
||||
|
||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
||||
const fieldOptions = makeOptionsFromCustomFields(customFields, [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'title', label: 'Title' },
|
||||
{ value: 'note', label: 'Note' },
|
||||
]);
|
||||
@@ -38,7 +39,7 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
description: 'Field to be shown in the second line of text',
|
||||
type: 'option',
|
||||
values: fieldOptions,
|
||||
defaultValue: '',
|
||||
defaultValue: 'none',
|
||||
},
|
||||
{
|
||||
id: 'subscribe',
|
||||
|
||||
@@ -179,8 +179,9 @@ export function OffsetOverview() {
|
||||
|
||||
export function ClockOverview({ className }: { className?: string }) {
|
||||
const { clock } = useClock();
|
||||
const formattedClock = formatTime(clock);
|
||||
|
||||
return <TimeColumn label='Time now' value={formattedTime(clock)} className={className} />;
|
||||
return <TimeColumn label='Time now' value={formattedClock} className={className} />;
|
||||
}
|
||||
|
||||
export function TimerOverview({ className }: { className?: string }) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
.rundownEditor {
|
||||
// width is locked to swatch picker elements
|
||||
width: calc(15 * 2rem + 13 * 0.5rem);
|
||||
min-width: calc(15 * 2rem + 13 * 0.5rem);
|
||||
|
||||
// we dont want a scrollbar when in the modal
|
||||
.content {
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
|
||||
{ value: OntimeView.Timer, label: 'Timer' },
|
||||
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
||||
{ value: OntimeView.Operator, label: 'Operator' },
|
||||
{ value: '', label: 'Companion' },
|
||||
{ value: '<<companion>>', label: 'Companion' },
|
||||
...urlPresetData.map((preset) => ({
|
||||
value: `preset-${preset.alias}`,
|
||||
label: `URL Preset: ${preset.alias}`,
|
||||
|
||||
@@ -64,7 +64,7 @@ export const initializeSentry = () => {
|
||||
/NetworkError/i,
|
||||
/The operation couldn't be completed/i,
|
||||
],
|
||||
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
|
||||
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i, /external\//i],
|
||||
beforeSend(event) {
|
||||
// Drop errors that happen during known data-unavailable states
|
||||
const error = event.exception?.values?.[0]?.value;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
||||
export const langDe: TranslationObject = {
|
||||
'common.expected_finish': 'Erwartetes Ende',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sek',
|
||||
'common.now': 'Jetzt',
|
||||
'common.next': 'Nächste',
|
||||
'common.scheduled_start': 'Geplanter beginn',
|
||||
@@ -15,6 +16,7 @@ export const langDe: TranslationObject = {
|
||||
'common.no_data': 'Keine Daten',
|
||||
'countdown.ended': 'Veranstaltung endete um',
|
||||
'countdown.running': 'Veranstaltung läuft',
|
||||
'countdown.loaded': 'Veranstaltung geladen',
|
||||
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
|
||||
'countdown.to_start': 'Zeit bis zum Start',
|
||||
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
||||
export const langEs: TranslationObject = {
|
||||
'common.expected_finish': 'Finalización esperada',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Ahora',
|
||||
'common.next': 'Siguiente',
|
||||
'common.scheduled_start': 'Inicio programado',
|
||||
@@ -15,6 +16,7 @@ export const langEs: TranslationObject = {
|
||||
'common.no_data': 'Sin datos',
|
||||
'countdown.ended': 'Evento finalizado a las',
|
||||
'countdown.running': 'Evento en curso',
|
||||
'countdown.loaded': 'Evento está cargado', //TODO: check translation
|
||||
'countdown.select_event': 'Seleccionar un evento para seguir',
|
||||
'countdown.to_start': 'Tiempo para comenzar',
|
||||
'countdown.waiting': 'Esperando el inicio del evento',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
||||
export const langFr: TranslationObject = {
|
||||
'common.expected_finish': 'Fin estimée à',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Maintenant',
|
||||
'common.next': 'A suivre',
|
||||
'common.scheduled_start': 'Début prévu',
|
||||
@@ -15,6 +16,7 @@ export const langFr: TranslationObject = {
|
||||
'common.no_data': 'Aucune donnée',
|
||||
'countdown.ended': 'Évènement terminé à',
|
||||
'countdown.running': 'Évènement en cours',
|
||||
'countdown.loaded': 'Évènement chargé',
|
||||
'countdown.select_event': 'Sélectionnez un évènement à suivre',
|
||||
'countdown.to_start': 'Évènement commence dans',
|
||||
'countdown.waiting': 'En attente du début de l’évènement',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
||||
export const langIt: TranslationObject = {
|
||||
'common.expected_finish': 'Fine Prevista',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Adesso',
|
||||
'common.next': 'Prossimo',
|
||||
'common.scheduled_start': 'Inizio programmato',
|
||||
@@ -15,6 +16,7 @@ export const langIt: TranslationObject = {
|
||||
'common.no_data': 'Nessun dato disponibile',
|
||||
'countdown.ended': 'Evento finito alle',
|
||||
'countdown.running': 'Evento in corso',
|
||||
'countdown.loaded': 'Evento caricato', //TODO: check translation
|
||||
'countdown.select_event': 'Seleziona un evento da seguire',
|
||||
'countdown.to_start': 'Tempo alla partenza',
|
||||
'countdown.waiting': "In attesa dell'inizio dell'evento",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TranslationObject } from 'ontime-types';
|
||||
export const langPt: TranslationObject = {
|
||||
'common.expected_finish': 'Término esperado',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Agora',
|
||||
'common.next': 'Próximo',
|
||||
'common.scheduled_start': 'Início programado',
|
||||
@@ -15,6 +16,7 @@ export const langPt: TranslationObject = {
|
||||
'common.no_data': 'Sem dados',
|
||||
'countdown.ended': 'Evento encerrado às',
|
||||
'countdown.running': 'Evento em andamento',
|
||||
'countdown.loaded': 'Evento carregado', //TODO: check translation
|
||||
'countdown.select_event': 'Selecione um evento para acompanhar',
|
||||
'countdown.to_start': 'Tempo para iniciar',
|
||||
'countdown.waiting': 'Aguardando o início do evento',
|
||||
|
||||
@@ -17,8 +17,11 @@ export const getBackstageOptions = (
|
||||
customFields: CustomFields,
|
||||
projectData: ProjectData,
|
||||
): ViewOption[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
|
||||
const projectDataOptions = makeProjectDataOptions(projectData);
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'note', label: 'Note' },
|
||||
]);
|
||||
const projectDataOptions = makeProjectDataOptions(projectData, [{ value: 'none', label: 'None' }]);
|
||||
|
||||
return [
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
@@ -32,7 +35,7 @@ export const getBackstageOptions = (
|
||||
description: 'Select the data source for auxiliary text shown in now and next cards',
|
||||
type: 'option',
|
||||
values: secondaryOptions,
|
||||
defaultValue: '',
|
||||
defaultValue: 'none',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -47,7 +50,7 @@ export const getBackstageOptions = (
|
||||
description: 'Select a project data source to show in the view',
|
||||
type: 'option',
|
||||
values: projectDataOptions,
|
||||
defaultValue: '',
|
||||
defaultValue: 'none',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -118,7 +118,11 @@ $item-height: 3.5rem;
|
||||
}
|
||||
|
||||
.sub--live {
|
||||
background-color: $green-700;
|
||||
background-color: $active-green;
|
||||
}
|
||||
|
||||
.sub--armed {
|
||||
background-color: $gray-1325;
|
||||
}
|
||||
|
||||
.sub__binder {
|
||||
@@ -140,12 +144,16 @@ $item-height: 3.5rem;
|
||||
color: $delay-color;
|
||||
}
|
||||
|
||||
.sub__schedule--strike {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.sub__schedule--over {
|
||||
color: $playback-over
|
||||
color: $playback-over;
|
||||
}
|
||||
|
||||
.sub__schedule--under {
|
||||
color: $playback-under
|
||||
color: $playback-under;
|
||||
}
|
||||
|
||||
.sub__title {
|
||||
|
||||
@@ -9,6 +9,7 @@ import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useClock } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
@@ -39,14 +40,14 @@ export default function CountdownLoader() {
|
||||
return <Countdown {...data} />;
|
||||
}
|
||||
|
||||
function Countdown({ customFields, events, projectData, isMirrored, settings }: CountdownData) {
|
||||
function Countdown({ customFields, rundownData, projectData, isMirrored, settings }: CountdownData) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { subscriptions } = useCountdownOptions();
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
// gather rundown data
|
||||
const playableEvents = events.filter((event) => isOntimeEvent(event) && isPlayableEvent(event));
|
||||
const playableEvents = rundownData.filter((entry) => isOntimeEvent(entry) && isPlayableEvent(entry));
|
||||
|
||||
// gather presentation data
|
||||
const hasEvents = playableEvents.length > 0;
|
||||
@@ -85,7 +86,7 @@ function Countdown({ customFields, events, projectData, isMirrored, settings }:
|
||||
}
|
||||
|
||||
interface CountdownContentsProps {
|
||||
playableEvents: OntimeEvent[];
|
||||
playableEvents: ExtendedEntry<OntimeEvent>[];
|
||||
subscriptions: EntryId[];
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,37 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { IoPencil } from 'react-icons/io5';
|
||||
import { EntryId, OntimeEvent } from 'ontime-types';
|
||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import {
|
||||
useCountdownSocket,
|
||||
useCurrentDay,
|
||||
usePlayback,
|
||||
useRuntimeOffset,
|
||||
useSelectedEventId,
|
||||
} from '../../common/hooks/useSocket';
|
||||
import { useExpectedStartData, usePlayback, useSelectedEventId } from '../../common/hooks/useSocket';
|
||||
import useReport from '../../common/hooks-query/useReport';
|
||||
import { getOffsetState } from '../../common/utils/offset';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { throttle } from '../../common/utils/throttle';
|
||||
import FollowButton from '../../features/operator/follow-button/FollowButton';
|
||||
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
import { useCountdownOptions } from './countdown.options';
|
||||
import { getIsLive, getSubscriptionDisplayData, sanitiseTitle, timerProgress } from './countdown.utils';
|
||||
import {
|
||||
CountdownEvent,
|
||||
extendEventData,
|
||||
getIsLive,
|
||||
isOutsideRange,
|
||||
preferredFormat12,
|
||||
preferredFormat24,
|
||||
useSubscriptionDisplayData,
|
||||
} from './countdown.utils';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
interface CountdownSubscriptionsProps {
|
||||
subscribedEvents: OntimeEvent[];
|
||||
subscribedEvents: ExtendedEntry<OntimeEvent>[];
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
@@ -36,6 +41,9 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const showFab = useFadeOutOnInactivity(true);
|
||||
|
||||
const { data: reportData } = useReport();
|
||||
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
|
||||
|
||||
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -92,23 +100,19 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
{subscribedEvents.map((event) => {
|
||||
const secondaryData = getPropertyValue(event, secondarySource);
|
||||
const isLive = getIsLive(event.id, selectedEventId, playback);
|
||||
|
||||
const isArmed = !isLive && event.id === selectedEventId;
|
||||
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
|
||||
const title = event.title.length ? event.title : ' '; // insert utf-8 empty space to avoid the line collapsing
|
||||
return (
|
||||
<div key={event.id} ref={isLive ? selectedRef : undefined} className={cx(['sub', isLive && 'sub--live'])}>
|
||||
<div
|
||||
key={event.id}
|
||||
ref={isLive ? selectedRef : undefined}
|
||||
className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])}
|
||||
>
|
||||
<div className='sub__binder' style={{ '--user-color': event.colour }} />
|
||||
<div className={cx(['sub__schedule', event.delay > 0 && 'sub__schedule--delayed'])}>
|
||||
{showExpected ? (
|
||||
<ExpectedSchedule timeStart={event.timeStart} timeEnd={event.timeEnd} delay={event.delay} />
|
||||
) : (
|
||||
<>
|
||||
<ClockTime value={event.timeStart + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
→
|
||||
<ClockTime value={event.timeEnd + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SubscriptionStatus key={event.id} event={event} selectedEventId={selectedEventId} />
|
||||
<div className={cx(['sub__title', !event.title && 'subdued'])}>{sanitiseTitle(event.title)}</div>
|
||||
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||
<SubscriptionStatus event={countdownEvent} />
|
||||
<div className={cx(['sub__title', !event.title && 'subdued'])}>{title}</div>
|
||||
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -123,63 +127,82 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
);
|
||||
}
|
||||
|
||||
interface ExpectedScheduleProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
delay: number;
|
||||
}
|
||||
function ExpectedSchedule(props: ExpectedScheduleProps) {
|
||||
const { timeStart, timeEnd, delay } = props;
|
||||
type ScheduleTimeProps = {
|
||||
event: CountdownEvent;
|
||||
showExpected: boolean;
|
||||
};
|
||||
//TODO: consider relative mode
|
||||
export function ScheduleTime(props: ScheduleTimeProps) {
|
||||
const { event, showExpected } = props;
|
||||
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
|
||||
|
||||
const { offset } = useRuntimeOffset();
|
||||
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
||||
|
||||
// offset is negative if we are ahead
|
||||
const expectedOffset = offset - delay;
|
||||
const expectedState = getOffsetState(expectedOffset);
|
||||
// only show new exacted value if outside range of the planned value
|
||||
const isExpectedValueShow = showExpected && isOutsideRange(plannedStart, expectedStart);
|
||||
|
||||
const plannedStateClass = isExpectedValueShow ? 'sub__schedule--strike' : delay !== 0 ? 'sub__schedule--delayed' : '';
|
||||
|
||||
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
||||
const plannedEnd = plannedStart + duration + delay;
|
||||
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
|
||||
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='sub__schedule'>
|
||||
<ClockTime
|
||||
value={timeStart - expectedOffset}
|
||||
className={`sub__schedule--${expectedState}`}
|
||||
preferredFormat12='h:mm'
|
||||
preferredFormat24='HH:mm'
|
||||
value={plannedStart}
|
||||
preferredFormat12={preferredFormat12}
|
||||
preferredFormat24={preferredFormat24}
|
||||
className={plannedStateClass}
|
||||
/>
|
||||
→
|
||||
<ClockTime value={timeEnd - expectedOffset} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
</>
|
||||
{!isExpectedValueShow && (
|
||||
<>
|
||||
→
|
||||
<ClockTime
|
||||
value={plannedEnd}
|
||||
preferredFormat12={preferredFormat12}
|
||||
preferredFormat24={preferredFormat24}
|
||||
className={plannedStateClass}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isExpectedValueShow && (
|
||||
<>
|
||||
<ClockTime
|
||||
value={expectedStart}
|
||||
className={expectedStateClass}
|
||||
preferredFormat12={preferredFormat12}
|
||||
preferredFormat24={preferredFormat24}
|
||||
/>
|
||||
→
|
||||
<ClockTime
|
||||
value={expectedEnd}
|
||||
className={expectedEndClass}
|
||||
preferredFormat12={preferredFormat12}
|
||||
preferredFormat24={preferredFormat24}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubscriptionStatusProps {
|
||||
event: OntimeEvent;
|
||||
selectedEventId: EntryId | null;
|
||||
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
||||
}
|
||||
|
||||
function SubscriptionStatus({ event, selectedEventId }: SubscriptionStatusProps) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { currentDay } = useCurrentDay();
|
||||
const { offset } = useRuntimeOffset();
|
||||
const { showExpected } = useCountdownOptions();
|
||||
const { playback, current, clock } = useCountdownSocket();
|
||||
|
||||
// TODO: use reporter values as in the event block chip
|
||||
const { status, timer } = getSubscriptionDisplayData(
|
||||
current,
|
||||
playback,
|
||||
clock,
|
||||
event,
|
||||
selectedEventId,
|
||||
offset,
|
||||
currentDay,
|
||||
getLocalizedString('common.minutes'),
|
||||
showExpected,
|
||||
);
|
||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='sub__status'>{getLocalizedString(timerProgress[status])}</div>
|
||||
<div className='sub__timer'>{timer}</div>
|
||||
<div className='sub__status'>{statusDisplay}</div>
|
||||
{status === 'done' ? (
|
||||
<SuperscriptTime className='sub__timer' time={timeDisplay} />
|
||||
) : (
|
||||
<div className='sub__timer'>{timeDisplay}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
@use '@/theme/viewerDefs' as *;
|
||||
|
||||
.single-container {
|
||||
height: 100%;
|
||||
margin-top: 5vh;
|
||||
margin-top: 7.5vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $view-element-gap;
|
||||
gap: 5rem;
|
||||
}
|
||||
|
||||
.event__title {
|
||||
@@ -13,13 +12,25 @@
|
||||
padding: $view-card-padding;
|
||||
border-radius: $element-border-radius;
|
||||
font-size: clamp(40px, 4.5vw, 80px);
|
||||
line-height: 1.1em;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
border-left: 0.25em solid;
|
||||
border-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
|
||||
.secondary {
|
||||
text-align: left;
|
||||
font-size: $title-font-size;
|
||||
}
|
||||
|
||||
.sub__schedule {
|
||||
color: inherit;
|
||||
font-size: $base-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.event__status {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
font-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||
font-size: $header-font-size;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,57 @@
|
||||
import { IoPencil } from 'react-icons/io5';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { getExpectedStart } from 'ontime-utils';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||
import { useCountdownSocket, useCurrentDay, useRuntimeOffset, useSelectedEventId } from '../../common/hooks/useSocket';
|
||||
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
||||
import useReport from '../../common/hooks-query/useReport';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||||
|
||||
import { useCountdownOptions } from './countdown.options';
|
||||
import { getSubscriptionDisplayData, timerProgress } from './countdown.utils';
|
||||
import { useSubscriptionDisplayData } from './countdown.utils';
|
||||
import { ScheduleTime } from './CountdownSubscriptions';
|
||||
|
||||
import './SingleEventCountdown.scss';
|
||||
|
||||
interface SingleEventCountdownProps {
|
||||
subscribedEvent: OntimeEvent;
|
||||
subscribedEvent: ExtendedEntry<OntimeEvent>;
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) {
|
||||
const { secondarySource, showExpected } = useCountdownOptions();
|
||||
const showFab = useFadeOutOnInactivity(true);
|
||||
const { data: reportData } = useReport();
|
||||
|
||||
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
|
||||
const { totalGap, isLinkedToLoaded } = subscribedEvent;
|
||||
const expectedStart = getExpectedStart(subscribedEvent, {
|
||||
currentDay,
|
||||
totalGap,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
isLinkedToLoaded,
|
||||
offset,
|
||||
mode,
|
||||
});
|
||||
|
||||
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
|
||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
|
||||
const title = subscribedEvent.title.length ? subscribedEvent.title : ' '; // insert utf-8 empty space to avoid the line collapsing
|
||||
const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
|
||||
|
||||
return (
|
||||
<div className='single-container' data-testid='countdown-event'>
|
||||
<SubscriptionStatus event={subscribedEvent} />
|
||||
<div className='event__title'>{subscribedEvent.title}</div>
|
||||
<SubscriptionStatus event={countdownEvent} />
|
||||
<div className='event__title' style={{ borderColor: countdownEvent.colour }}>
|
||||
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||
{title}
|
||||
{secondaryData && <div className='secondary'>{secondaryData}</div>}
|
||||
</div>
|
||||
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
|
||||
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
||||
<IoPencil /> Edit
|
||||
@@ -34,34 +62,20 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
||||
}
|
||||
|
||||
interface SubscriptionStatusProps {
|
||||
event: OntimeEvent;
|
||||
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
||||
}
|
||||
|
||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const { currentDay } = useCurrentDay();
|
||||
const { offset } = useRuntimeOffset();
|
||||
const { showExpected } = useCountdownOptions();
|
||||
const { playback, current, clock } = useCountdownSocket();
|
||||
|
||||
// TODO: use reporter values as in the event block chip
|
||||
const { status, timer } = getSubscriptionDisplayData(
|
||||
current,
|
||||
playback,
|
||||
clock,
|
||||
event,
|
||||
selectedEventId,
|
||||
offset,
|
||||
currentDay,
|
||||
getLocalizedString('common.minutes'),
|
||||
showExpected,
|
||||
);
|
||||
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='event__status'>{getLocalizedString(timerProgress[status])}</div>
|
||||
<div className='event__timer'>{timer}</div>
|
||||
<div className='event__status'>{statusDisplay}</div>
|
||||
{status === 'done' ? (
|
||||
<SuperscriptTime className='event__timer' time={timeDisplay} />
|
||||
) : (
|
||||
<div className='event__timer'>{timeDisplay}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ export const getCountdownOptions = (
|
||||
customFields: CustomFields,
|
||||
persistedSubscriptions: EntryId[],
|
||||
): ViewOption[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'note', label: 'Note' },
|
||||
]);
|
||||
|
||||
return [
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
@@ -28,7 +31,7 @@ export const getCountdownOptions = (
|
||||
description: 'Select the data source for auxiliary text shown in the card',
|
||||
type: 'option',
|
||||
values: secondaryOptions,
|
||||
defaultValue: '',
|
||||
defaultValue: 'none',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { EntryId, MaybeNumber, OntimeEvent, Playback, TimerType } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
|
||||
import { getExpectedStart, MILLIS_PER_MINUTE, removeSeconds } from 'ontime-utils';
|
||||
|
||||
import { getFormattedTimer } from '../../features/viewers/common/viewUtils';
|
||||
import type { TranslationKey } from '../../translation/TranslationProvider';
|
||||
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { timerPlaceholderMin } from '../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../common/utils/time';
|
||||
import { type TranslationKey, useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
/**
|
||||
* Parses string as a title
|
||||
@@ -11,6 +14,9 @@ export function sanitiseTitle(title: string | null) {
|
||||
return title ?? '{no title}';
|
||||
}
|
||||
|
||||
export const preferredFormat12 = 'h:mm a';
|
||||
export const preferredFormat24 = 'HH:mm';
|
||||
|
||||
/**
|
||||
* Whether the current event is live
|
||||
*/
|
||||
@@ -18,137 +24,96 @@ export function getIsLive(currentId: EntryId, selectedId: EntryId | null, playba
|
||||
return currentId === selectedId && playback !== Playback.Armed;
|
||||
}
|
||||
|
||||
const subscriptionTimerDisplayOptions = {
|
||||
removeSeconds: true,
|
||||
removeLeadingZero: true,
|
||||
} as const;
|
||||
|
||||
const subscriptionScheduledTimeDisplayOptions = {
|
||||
removeSeconds: true,
|
||||
removeLeadingZero: false,
|
||||
} as const;
|
||||
|
||||
type TimerMessage = Record<string, TranslationKey>;
|
||||
export type ProgressStatus = 'future' | 'due' | 'live' | 'done';
|
||||
export type ProgressStatus = 'future' | 'due' | 'live' | 'done' | 'pending' | 'loaded';
|
||||
type TimerMessage = Record<ProgressStatus, TranslationKey>;
|
||||
|
||||
export const timerProgress: TimerMessage = {
|
||||
future: 'countdown.to_start',
|
||||
due: 'timeline.due',
|
||||
live: 'timeline.live',
|
||||
live: 'countdown.running',
|
||||
pending: 'countdown.waiting',
|
||||
loaded: 'countdown.loaded',
|
||||
done: 'countdown.ended',
|
||||
};
|
||||
|
||||
export function getFormattedTime(
|
||||
value: MaybeNumber,
|
||||
status: ProgressStatus,
|
||||
minText: string,
|
||||
secText: string,
|
||||
dueText: string,
|
||||
) {
|
||||
if (value === null) return timerPlaceholderMin;
|
||||
if (status === 'future' || status === 'live') {
|
||||
if (value <= 0) return dueText.toUpperCase();
|
||||
return formatDuration(value, value > MILLIS_PER_MINUTE * 2)
|
||||
.replace('m', `${minText} `)
|
||||
.replace('s', secText);
|
||||
}
|
||||
return removeSeconds(formatTime(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a parsed timer and relevant status message
|
||||
* Handles events in different days but disregards whether an event has actually played
|
||||
* TODO: get data from reporter and check if the event has played
|
||||
* TODO: get timer data granularly
|
||||
*/
|
||||
export function getSubscriptionDisplayData(
|
||||
current: MaybeNumber,
|
||||
playback: Playback,
|
||||
clock: number,
|
||||
subscribedEvent: OntimeEvent,
|
||||
selectedId: EntryId | null,
|
||||
offset: number,
|
||||
currentDay: number,
|
||||
minutesString: string,
|
||||
showExpected = false,
|
||||
): { status: ProgressStatus; timer: string } {
|
||||
const offsetAndDelay = showExpected ? offset + subscribedEvent.delay : 0;
|
||||
export function useSubscriptionDisplayData(
|
||||
subscribedEvent: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number },
|
||||
): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
|
||||
const { playback, current, clock } = useCountdownSocket();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
if (selectedId === subscribedEvent.id) {
|
||||
// 1. An event that is loaded but not running is {'due': <countdown | overtime>}
|
||||
const bigDuration = (value: number) => {
|
||||
if (value <= 0) return getLocalizedString('countdown.overtime').toUpperCase();
|
||||
return formatDuration(value, value > MILLIS_PER_MINUTE * 2)
|
||||
.replace('m', `${getLocalizedString('common.minutes')} `)
|
||||
.replace('s', getLocalizedString('common.seconds'));
|
||||
};
|
||||
|
||||
if (subscribedEvent.isLoaded) {
|
||||
if (playback === Playback.Armed) {
|
||||
// if we are following the event, but it is not running, we show the scheduled start
|
||||
return {
|
||||
status: 'due',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeStart + offsetAndDelay,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionScheduledTimeDisplayOptions,
|
||||
),
|
||||
status: 'loaded',
|
||||
statusDisplay: getLocalizedString(timerProgress['loaded']),
|
||||
timeDisplay: bigDuration(subscribedEvent.duration),
|
||||
};
|
||||
}
|
||||
|
||||
// 2. An event with a time-to-start lower than 0 is {'due': <countdown | scheduledStart>}, show the running timer
|
||||
return {
|
||||
status: 'live',
|
||||
timer: getFormattedTimer(current, TimerType.CountDown, minutesString, subscriptionTimerDisplayOptions),
|
||||
statusDisplay: getLocalizedString(timerProgress['live']),
|
||||
timeDisplay: bigDuration(current ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If the running timer is not the one we are following
|
||||
* we can be in future, due or have ended
|
||||
*/
|
||||
|
||||
// 3. event is the day after, we show a countdown to start
|
||||
if (subscribedEvent.dayOffset > currentDay) {
|
||||
const dayOffset = (subscribedEvent.dayOffset - currentDay) * dayInMs;
|
||||
if (playback === Playback.Stop || playback === Playback.Armed) {
|
||||
return {
|
||||
status: 'future',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeStart + dayOffset - clock - offsetAndDelay,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionTimerDisplayOptions,
|
||||
),
|
||||
status: 'pending',
|
||||
statusDisplay: getLocalizedString(timerProgress['pending']),
|
||||
timeDisplay: ' ',
|
||||
};
|
||||
}
|
||||
|
||||
// 4. event is the before after, show the scheduled end
|
||||
// TODO: get the time from the reporter
|
||||
if (subscribedEvent.dayOffset < currentDay) {
|
||||
if (subscribedEvent.isPast) {
|
||||
return {
|
||||
status: 'done',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeEnd,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionScheduledTimeDisplayOptions,
|
||||
),
|
||||
statusDisplay: getLocalizedString(timerProgress['done']),
|
||||
timeDisplay: formatTime(subscribedEvent.endedAt, { format12: preferredFormat12, format24: preferredFormat24 }),
|
||||
};
|
||||
}
|
||||
|
||||
// 5. if event is in future, we count to the scheduled start
|
||||
// TODO: get time until
|
||||
if (clock < subscribedEvent.timeStart) {
|
||||
if (subscribedEvent.expectedStart - clock <= 0) {
|
||||
return {
|
||||
status: 'future',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeStart - clock - offsetAndDelay,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionTimerDisplayOptions,
|
||||
),
|
||||
status: 'due',
|
||||
statusDisplay: getLocalizedString(timerProgress['future']), // We use future here on purpose for the look of it
|
||||
timeDisplay: getLocalizedString(timerProgress['due']).toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
// 6. if event has ended, we show the scheduled end
|
||||
// TODO: get the time from the reporter
|
||||
if (clock > subscribedEvent.timeEnd) {
|
||||
return {
|
||||
status: 'done',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeEnd,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionScheduledTimeDisplayOptions,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// the event here has to be due, we show the countdown the expected start time
|
||||
return {
|
||||
status: 'due',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeStart + offsetAndDelay,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionScheduledTimeDisplayOptions,
|
||||
),
|
||||
status: 'future',
|
||||
statusDisplay: getLocalizedString(timerProgress['future']),
|
||||
timeDisplay: bigDuration(subscribedEvent.expectedStart - clock),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,7 +146,7 @@ export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) {
|
||||
* Since the original array is already ordered, we simply filter out the events
|
||||
* which are not in the subscriptions list.
|
||||
*/
|
||||
export function getOrderedSubscriptions(subscriptions: EntryId[], playableEvents: OntimeEvent[]): OntimeEvent[] {
|
||||
export function getOrderedSubscriptions<T extends OntimeEntry>(subscriptions: EntryId[], playableEvents: T[]): T[] {
|
||||
return playableEvents.filter((event) => subscriptions.includes(event.id));
|
||||
}
|
||||
|
||||
@@ -212,3 +177,32 @@ export function isLinkedToLoadedEvent(events: OntimeEvent[], loadedId: EntryId |
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isOutsideRange(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) > MILLIS_PER_MINUTE;
|
||||
}
|
||||
|
||||
export type CountdownEvent = ExtendedEntry<OntimeEvent> & { expectedStart: number; endedAt: MaybeNumber };
|
||||
|
||||
export function extendEventData(
|
||||
event: ExtendedEntry<OntimeEvent>,
|
||||
currentDay: number,
|
||||
actualStart: MaybeNumber,
|
||||
plannedStart: MaybeNumber,
|
||||
offset: number,
|
||||
mode: OffsetMode,
|
||||
reportData: OntimeReport,
|
||||
): CountdownEvent {
|
||||
const { totalGap, isLinkedToLoaded } = event;
|
||||
const expectedStart = getExpectedStart(event, {
|
||||
currentDay,
|
||||
totalGap,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
isLinkedToLoaded,
|
||||
offset,
|
||||
mode,
|
||||
});
|
||||
const { endedAt } = reportData[event.id] ?? { endedAt: null };
|
||||
return { ...event, expectedStart, endedAt };
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@ import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
|
||||
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
||||
import useSettings from '../../common/hooks-query/useSettings';
|
||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils';
|
||||
|
||||
export interface CountdownData {
|
||||
customFields: CustomFields;
|
||||
events: OntimeEntry[];
|
||||
rundownData: ExtendedEntry<OntimeEntry>[];
|
||||
projectData: ProjectData;
|
||||
isMirrored: boolean;
|
||||
settings: Settings;
|
||||
@@ -20,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
// HTTP API data
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundown();
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
|
||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||
@@ -28,7 +29,7 @@ export function useCountdownData(): ViewData<CountdownData> {
|
||||
return {
|
||||
data: {
|
||||
customFields,
|
||||
events: rundownData,
|
||||
rundownData,
|
||||
projectData,
|
||||
isMirrored,
|
||||
settings,
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
||||
/>
|
||||
);
|
||||
},
|
||||
TableRow: ({ item: _item, ...virtuosoProps }) => {
|
||||
TableRow: ({ item: _item, style: injectedStyles, ...virtuosoProps }) => {
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
const rowIndex = virtuosoProps['data-index'];
|
||||
const row = rows[rowIndex];
|
||||
@@ -183,13 +183,16 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
||||
rowId={row.id}
|
||||
rowIndex={row.index}
|
||||
table={table}
|
||||
injectedStyles={injectedStyles}
|
||||
{...virtuosoProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeDelay(entry)) {
|
||||
return <DelayRow key={key} duration={entry.duration} {...virtuosoProps} />;
|
||||
return (
|
||||
<DelayRow key={key} duration={entry.duration} injectedStyles={injectedStyles} {...virtuosoProps} />
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeMilestone(entry)) {
|
||||
@@ -204,6 +207,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
||||
rowId={row.id}
|
||||
rowIndex={rowIndex}
|
||||
table={table}
|
||||
injectedStyles={injectedStyles}
|
||||
{...virtuosoProps}
|
||||
/>
|
||||
);
|
||||
@@ -225,6 +229,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
||||
rowId={row.id}
|
||||
rowIndex={rowIndex}
|
||||
table={table}
|
||||
injectedStyles={injectedStyles}
|
||||
{...virtuosoProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { CSSProperties, memo } from 'react';
|
||||
|
||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
@@ -7,9 +7,10 @@ import style from './DelayRow.module.scss';
|
||||
|
||||
interface DelayRowProps {
|
||||
duration: number;
|
||||
injectedStyles?: CSSProperties;
|
||||
}
|
||||
|
||||
function DelayRow({ duration, ...virtuosoProps }: DelayRowProps) {
|
||||
function DelayRow({ duration, injectedStyles, ...virtuosoProps }: DelayRowProps) {
|
||||
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
|
||||
|
||||
if (hideDelays || duration === 0) {
|
||||
@@ -19,7 +20,7 @@ function DelayRow({ duration, ...virtuosoProps }: DelayRowProps) {
|
||||
const delayTime = millisToDelayString(duration, 'expanded');
|
||||
|
||||
return (
|
||||
<tr className={style.delayRow} data-testid='cuesheet-delay' {...virtuosoProps}>
|
||||
<tr className={style.delayRow} data-testid='cuesheet-delay' style={injectedStyles} {...virtuosoProps}>
|
||||
<td tabIndex={0}>{delayTime}</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { CSSProperties, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
import { flexRender, Table } from '@tanstack/react-table';
|
||||
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
@@ -26,6 +26,7 @@ interface EventRowProps {
|
||||
parent: EntryId | null;
|
||||
rowIndex: number;
|
||||
table: Table<ExtendedEntry<OntimeEntry>>;
|
||||
injectedStyles?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function EventRow({
|
||||
@@ -42,6 +43,7 @@ export default function EventRow({
|
||||
parent,
|
||||
rowIndex,
|
||||
table,
|
||||
injectedStyles,
|
||||
...virtuosoProps
|
||||
}: EventRowProps) {
|
||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||
@@ -81,6 +83,7 @@ export default function EventRow({
|
||||
parent && style.hasParent,
|
||||
])}
|
||||
style={{
|
||||
...injectedStyles,
|
||||
opacity: `${isPast ? '0.2' : '1'}`,
|
||||
'--user-bg': groupColour ?? 'transparent',
|
||||
}}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
import { flexRender, Table } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
@@ -15,9 +16,18 @@ interface GroupRowProps {
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: Table<ExtendedEntry>;
|
||||
injectedStyles?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function GroupRow({ groupId, colour, rowId, rowIndex, table, ...virtuosoProps }: GroupRowProps) {
|
||||
export default function GroupRow({
|
||||
groupId,
|
||||
colour,
|
||||
rowId,
|
||||
rowIndex,
|
||||
table,
|
||||
injectedStyles,
|
||||
...virtuosoProps
|
||||
}: GroupRowProps) {
|
||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||
cuesheetMode: AppMode.Edit,
|
||||
hideIndexColumn: false,
|
||||
@@ -26,7 +36,12 @@ export default function GroupRow({ groupId, colour, rowId, rowIndex, table, ...v
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
return (
|
||||
<tr className={style.groupRow} style={{ '--user-bg': colour }} data-testid='cuesheet-group' {...virtuosoProps}>
|
||||
<tr
|
||||
className={style.groupRow}
|
||||
style={{ ...injectedStyles, '--user-bg': colour }}
|
||||
data-testid='cuesheet-group'
|
||||
{...virtuosoProps}
|
||||
>
|
||||
{cuesheetMode === AppMode.Edit && (
|
||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||
<IconButton
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
import { flexRender, Table } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
@@ -20,6 +21,7 @@ interface MilestoneRowProps {
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: Table<ExtendedEntry>;
|
||||
injectedStyles?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function MilestoneRow({
|
||||
@@ -31,6 +33,7 @@ export default function MilestoneRow({
|
||||
rowId,
|
||||
rowIndex,
|
||||
table,
|
||||
injectedStyles,
|
||||
...virtuosoProps
|
||||
}: MilestoneRowProps) {
|
||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||
@@ -56,6 +59,7 @@ export default function MilestoneRow({
|
||||
<tr
|
||||
className={cx([style.milestoneRow, Boolean(parentBgColour) && style.hasParent])}
|
||||
style={{
|
||||
...injectedStyles,
|
||||
opacity: `${isPast ? '0.2' : '1'}`,
|
||||
'--user-bg': parentBgColour ?? 'transparent',
|
||||
}}
|
||||
|
||||
@@ -42,8 +42,8 @@ const fontSizeMap: { [key: number]: number } = {
|
||||
4: 28, // 9:01
|
||||
5: 28, // -9:01, 10:01, 9 min
|
||||
6: 25, // -10:01, 10 min
|
||||
8: 20, // 23:01:01
|
||||
9: 20, // -23:01:01
|
||||
8: 18, // 23:01:01
|
||||
9: 18, // -23:01:01
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.0.0-alpha.5",
|
||||
"version": "4.0.0-beta.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -121,7 +121,8 @@
|
||||
"from": "../server/src/user/",
|
||||
"to": "extraResources/user/",
|
||||
"filter": [
|
||||
"**/*"
|
||||
"**/*",
|
||||
"*{.ts}"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.0.0-alpha.5",
|
||||
"version": "4.0.0-beta.2",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { defaultCss } from '../src/user/styles/bundledCss';
|
||||
import path from 'path';
|
||||
|
||||
import { defaultCss } from '../src/user/styles/bundledCss';
|
||||
|
||||
/**
|
||||
* Script to write contents of bundledCss to override.css
|
||||
*/
|
||||
@@ -11,10 +11,6 @@ async function bundleCss() {
|
||||
const stylesDir = path.resolve(process.cwd(), 'src', 'user', 'styles');
|
||||
const cssFile = path.resolve(stylesDir, 'override.css');
|
||||
|
||||
if (!existsSync(cssFile)) {
|
||||
throw new Error('File does not exist');
|
||||
}
|
||||
|
||||
await writeFile(cssFile, defaultCss, { encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
console.error('Failed writing to CSS file: ', error);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { defaultTranslation } from '../src/user/translations/bundledTranslation';
|
||||
import path from 'path';
|
||||
|
||||
import { defaultTranslation } from '../src/user/translations/bundledTranslations.js';
|
||||
|
||||
/**
|
||||
* Script to write contents of default translation to translation.json
|
||||
*/
|
||||
@@ -11,10 +11,6 @@ async function bundleTranslation() {
|
||||
const translationDir = path.resolve(process.cwd(), 'src', 'user', 'translations');
|
||||
const translationsFile = path.resolve(translationDir, 'translations.json');
|
||||
|
||||
if (!existsSync(translationsFile)) {
|
||||
throw new Error('File does not exist');
|
||||
}
|
||||
|
||||
await writeFile(translationsFile, defaultTranslation, { encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
console.error('Failed writing to translations file: ', error);
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('generateAuthenticatedUrl()', () => {
|
||||
authenticate: true,
|
||||
hash: '1234',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?token=1234&n=1');
|
||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?n=1&token=1234');
|
||||
});
|
||||
|
||||
it('generates a link to an unlocked preset', () => {
|
||||
@@ -69,6 +69,17 @@ describe('generateAuthenticatedUrl()', () => {
|
||||
});
|
||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/preset/some-cuesheet-preset');
|
||||
});
|
||||
|
||||
it('generates a link for companion', () => {
|
||||
const withAuth = generateShareUrl('http://192.168.10.173:4001', '<<companion>>', {
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
authenticate: true,
|
||||
preset: undefined,
|
||||
hash: '1234',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/?token=1234');
|
||||
});
|
||||
});
|
||||
|
||||
describe('for ontime-cloud URLs', () => {
|
||||
@@ -100,7 +111,7 @@ describe('generateAuthenticatedUrl()', () => {
|
||||
prefix: 'prefix',
|
||||
hash: '1234',
|
||||
});
|
||||
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?token=1234&n=1');
|
||||
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?n=1&token=1234');
|
||||
});
|
||||
|
||||
it('generates a link to an unlocked preset', () => {
|
||||
|
||||
@@ -64,18 +64,21 @@ export function generateShareUrl(
|
||||
): URL {
|
||||
const url = new URL(baseUrl);
|
||||
|
||||
// if the config is locked and we are in a preset, we hide the canonical path
|
||||
const shouldMaskPath = Boolean(preset) && (canonicalPath === OntimeView.Cuesheet || lockConfig);
|
||||
const maybePresetPath = shouldMaskPath ? `preset/${preset}` : preset || canonicalPath;
|
||||
url.pathname = prefix ? `${prefix}/${maybePresetPath}` : maybePresetPath;
|
||||
// companion links point to the root
|
||||
if (canonicalPath !== '<<companion>>') {
|
||||
// if the config is locked and we are in a preset, we hide the canonical path
|
||||
const shouldMaskPath = Boolean(preset) && (canonicalPath === OntimeView.Cuesheet || lockConfig);
|
||||
const maybePresetPath = shouldMaskPath ? `preset/${preset}` : preset || canonicalPath;
|
||||
url.pathname = prefix ? `${prefix}/${maybePresetPath}` : maybePresetPath;
|
||||
|
||||
if (lockNav) {
|
||||
url.searchParams.append('n', '1');
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticate && hash) {
|
||||
url.searchParams.append('token', hash);
|
||||
}
|
||||
|
||||
if (lockNav) {
|
||||
url.searchParams.append('n', '1');
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ export const validateGenerateUrl = [
|
||||
body('lockConfig').isBoolean(),
|
||||
body('lockNav').isBoolean(),
|
||||
body('preset').optional().isString().trim().notEmpty(),
|
||||
body('prefix').optional().isString().trim().notEmpty(),
|
||||
body('hash').optional().isString().trim().notEmpty(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { ErrorResponse, Settings } from 'ontime-types';
|
||||
import { getErrorMessage, obfuscate } from 'ontime-utils';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { isDocker } from '../../setup/environment.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import * as appState from '../../services/app-state-service/AppStateService.js';
|
||||
|
||||
import { extractPin } from './settings.utils.js';
|
||||
|
||||
export async function getSettings(_req: Request, res: Response<Settings>) {
|
||||
const settings = getDataProvider().getSettings();
|
||||
const obfuscatedSettings = { ...settings };
|
||||
if (settings.editorKey) {
|
||||
obfuscatedSettings.editorKey = obfuscate(settings.editorKey);
|
||||
}
|
||||
|
||||
if (settings.operatorKey) {
|
||||
obfuscatedSettings.operatorKey = obfuscate(settings.operatorKey);
|
||||
}
|
||||
|
||||
res.status(200).send(obfuscatedSettings);
|
||||
}
|
||||
|
||||
export async function postSettings(req: Request, res: Response<Settings | ErrorResponse>) {
|
||||
try {
|
||||
const settings = getDataProvider().getSettings();
|
||||
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
|
||||
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
|
||||
const serverPort = Number(req.body?.serverPort);
|
||||
//TODO: should this not be part of the validator?
|
||||
if (isNaN(serverPort)) {
|
||||
res.status(400).send({ message: `Invalid value found for server port: ${req.body?.serverPort}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const hasChangedPort = settings.serverPort !== serverPort;
|
||||
|
||||
if (isDocker && hasChangedPort) {
|
||||
res.status(403).json({ message: 'Can`t change port when running inside docker' });
|
||||
return;
|
||||
}
|
||||
|
||||
let timeFormat = settings.timeFormat;
|
||||
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
|
||||
timeFormat = req.body.timeFormat;
|
||||
}
|
||||
|
||||
const language = req.body?.language || 'en';
|
||||
|
||||
const newData = {
|
||||
...settings,
|
||||
editorKey,
|
||||
operatorKey,
|
||||
timeFormat,
|
||||
language,
|
||||
serverPort,
|
||||
};
|
||||
await getDataProvider().setSettings(newData);
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function postWelcomeDialog(req: Request, res: Response) {
|
||||
const show = await appState.setShowWelcomeDialog(req.body.show);
|
||||
res.status(200).send({ show });
|
||||
}
|
||||
@@ -1,10 +1,58 @@
|
||||
import express from 'express';
|
||||
import { getSettings, postSettings, postWelcomeDialog } from './settings.controller.js';
|
||||
import { matchedData } from 'express-validator';
|
||||
import type { Request, Response } from 'express';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
|
||||
import { ErrorResponse, RefetchKey, Settings } from 'ontime-types';
|
||||
import { getErrorMessage, obfuscate } from 'ontime-utils';
|
||||
|
||||
import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import * as appState from '../../services/app-state-service/AppStateService.js';
|
||||
import { isDocker } from '../../setup/environment.js';
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.post('/welcomedialog', validateWelcomeDialog, postWelcomeDialog);
|
||||
router.post('/welcomedialog', validateWelcomeDialog, async (req: Request, res: Response) => {
|
||||
const show = await appState.setShowWelcomeDialog(req.body.show);
|
||||
res.status(200).json({ show });
|
||||
});
|
||||
|
||||
router.get('/', getSettings);
|
||||
router.post('/', validateSettings, postSettings);
|
||||
router.get('/', (_req: Request, res: Response<Settings>) => {
|
||||
const settings = getDataProvider().getSettings();
|
||||
const obfuscatedSettings = { ...settings };
|
||||
if (settings.editorKey) {
|
||||
obfuscatedSettings.editorKey = obfuscate(settings.editorKey);
|
||||
}
|
||||
|
||||
if (settings.operatorKey) {
|
||||
obfuscatedSettings.operatorKey = obfuscate(settings.operatorKey);
|
||||
}
|
||||
|
||||
res.status(200).json(obfuscatedSettings);
|
||||
});
|
||||
|
||||
router.post('/', validateSettings, async (req: Request, res: Response<Settings | ErrorResponse>) => {
|
||||
try {
|
||||
const data = matchedData<Settings>(req);
|
||||
const settings = getDataProvider().getSettings();
|
||||
|
||||
if (isDocker && settings.serverPort !== data.serverPort) {
|
||||
res.status(403).json({ message: 'Can`t change port when running inside docker' });
|
||||
return;
|
||||
}
|
||||
|
||||
data.version = settings.version;
|
||||
|
||||
if (!deepEqual(data, settings)) {
|
||||
await getDataProvider().setSettings(data);
|
||||
sendRefetch(RefetchKey.Settings);
|
||||
}
|
||||
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Business logic for resolving a string
|
||||
*/
|
||||
export function extractPin(value: string | undefined | null, fallback: string | null): string | null {
|
||||
if (value === null) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -6,16 +6,27 @@ import { requestValidationFunction } from '../validation-utils/validationFunctio
|
||||
*/
|
||||
export const validateWelcomeDialog = [body('show').isBoolean(), requestValidationFunction];
|
||||
|
||||
const pinValidator = (key: string) => {
|
||||
return body(key)
|
||||
.optional()
|
||||
.isLength({ min: 0, max: 4 })
|
||||
.customSanitizer((input) => {
|
||||
if (input === null || input.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return input;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Validates object for POST /ontime/settings
|
||||
*/
|
||||
export const validateSettings = [
|
||||
body().notEmpty().withMessage('No object found in request'),
|
||||
body('editorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
|
||||
body('operatorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
|
||||
body('timeFormat').isString().isIn(['12', '24']),
|
||||
body('language').isString(),
|
||||
body('serverPort').isPort().optional(),
|
||||
pinValidator('editorKey'),
|
||||
pinValidator('operatorKey'),
|
||||
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
|
||||
body('language').isString().trim().notEmpty(),
|
||||
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
@@ -34,7 +34,8 @@ import { populateTranslation } from './setup/loadTranslations.js';
|
||||
import { populateStyles } from './setup/loadStyles.js';
|
||||
import { eventStore } from './stores/EventStore.js';
|
||||
import { runtimeService } from './services/runtime-service/runtime.service.js';
|
||||
import { RestorePoint, restoreService } from './services/RestoreService.js';
|
||||
import { restoreService } from './services/restore-service/restore.service.js';
|
||||
import type { RestorePoint } from './services/restore-service/restore.type.js';
|
||||
import * as messageService from './services/message-service/message.service.js';
|
||||
import { getState } from './stores/runtimeState.js';
|
||||
import { initialiseProject } from './services/project-service/ProjectService.js';
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { MaybeNumber, MaybeString, Playback } from 'ontime-types';
|
||||
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
|
||||
import { publicFiles } from '../setup/index.js';
|
||||
|
||||
export type RestorePoint = {
|
||||
playback: Playback;
|
||||
selectedEventId: MaybeString;
|
||||
startedAt: MaybeNumber;
|
||||
addedTime: number;
|
||||
pausedAt: MaybeNumber;
|
||||
firstStart: MaybeNumber;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility validates a RestorePoint
|
||||
* @param obj
|
||||
* @return boolean
|
||||
*/
|
||||
export function isRestorePoint(obj: unknown): obj is RestorePoint {
|
||||
if (!obj) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const restorePoint = obj as RestorePoint;
|
||||
|
||||
if (typeof restorePoint.playback !== 'string' || !Object.values(Playback).includes(restorePoint.playback)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof restorePoint.selectedEventId !== 'string' && restorePoint.selectedEventId !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof restorePoint.startedAt !== 'number' && restorePoint.startedAt !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof restorePoint.addedTime !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof restorePoint.pausedAt !== 'number' && restorePoint.pausedAt !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof restorePoint.firstStart !== 'number' && restorePoint.firstStart !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility interface to allow dependency injection during test
|
||||
*/
|
||||
|
||||
/**
|
||||
* Service manages saving of application state
|
||||
* that can then be restored when reopening
|
||||
*/
|
||||
export class RestoreService {
|
||||
private readonly filePath: MaybeString;
|
||||
private readonly file: JSONFile<RestorePoint | null>;
|
||||
private failedCreateAttempts: number;
|
||||
private savedState: RestorePoint | null;
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.filePath = filePath;
|
||||
|
||||
this.savedState = null;
|
||||
this.file = new JSONFile(this.filePath);
|
||||
this.failedCreateAttempts = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility, reads from file
|
||||
* @private
|
||||
*/
|
||||
private async read() {
|
||||
return this.file.read();
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility writes payload to file
|
||||
* @throws
|
||||
* @param stringifiedState
|
||||
*/
|
||||
private async write(data: RestorePoint) {
|
||||
await this.file.write(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves runtime data to restore file
|
||||
* @param newState RestorePoint
|
||||
*/
|
||||
async save(newState: RestorePoint) {
|
||||
// after three failed attempts, mark the service as unavailable
|
||||
if (this.failedCreateAttempts > 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deepEqual(newState, this.savedState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.write(newState);
|
||||
this.savedState = { ...newState };
|
||||
this.failedCreateAttempts = 0;
|
||||
} catch (_error) {
|
||||
this.failedCreateAttempts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts reading a restore point from a given file path
|
||||
* Returns null if none found, restore point otherwise
|
||||
*/
|
||||
async load(): Promise<RestorePoint | null> {
|
||||
try {
|
||||
const maybeRestorePoint = await this.read();
|
||||
if (isRestorePoint(maybeRestorePoint)) {
|
||||
return maybeRestorePoint;
|
||||
}
|
||||
} catch (_error) {
|
||||
// no need to notify the user
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the restore file
|
||||
*/
|
||||
async clear() {
|
||||
try {
|
||||
await this.file.write(null);
|
||||
} catch (_error) {
|
||||
// nothing to do
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const restoreService = new RestoreService(publicFiles.restoreFile);
|
||||
@@ -1,139 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { isRestorePoint, RestorePoint, RestoreService } from '../RestoreService.js';
|
||||
|
||||
describe('isRestorePoint()', () => {
|
||||
it('validates a well defined object', () => {
|
||||
let restorePoint: RestorePoint = {
|
||||
playback: Playback.Roll,
|
||||
selectedEventId: '123',
|
||||
startedAt: 1,
|
||||
addedTime: 2,
|
||||
pausedAt: 3,
|
||||
firstStart: 1,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||
|
||||
restorePoint = {
|
||||
playback: Playback.Roll,
|
||||
selectedEventId: '123',
|
||||
startedAt: null,
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
firstStart: 1,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||
});
|
||||
|
||||
describe('rejects a badly formatted file', () => {
|
||||
it('with invalid playback value', () => {
|
||||
const restorePoint = {
|
||||
playback: 'unknown',
|
||||
selectedEventId: '123',
|
||||
startedAt: null,
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
groupStartAt: 10,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||
});
|
||||
it('with missing playback value', () => {
|
||||
const restorePoint = {
|
||||
selectedEventId: '123',
|
||||
startedAt: null,
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
groupStartAt: 10,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||
});
|
||||
it('with incorrect value', () => {
|
||||
const restorePoint = {
|
||||
playback: Playback.Roll,
|
||||
selectedEventId: '123',
|
||||
startedAt: 'testing',
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
groupStartAt: 10,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RestoreService()', () => {
|
||||
describe('load()', () => {
|
||||
it('loads working file with times', async () => {
|
||||
const expected: RestorePoint = {
|
||||
playback: Playback.Play,
|
||||
selectedEventId: 'da5b4',
|
||||
startedAt: 1234,
|
||||
addedTime: 5678,
|
||||
pausedAt: 9087,
|
||||
firstStart: 1234,
|
||||
};
|
||||
|
||||
const restoreService = new RestoreService('/path/to/restore/file');
|
||||
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => expected);
|
||||
|
||||
const testLoad = await restoreService.load();
|
||||
expect(testLoad).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('loads working file without times', async () => {
|
||||
const expected: RestorePoint = {
|
||||
playback: Playback.Stop,
|
||||
selectedEventId: null,
|
||||
startedAt: null,
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
firstStart: 1234,
|
||||
};
|
||||
|
||||
const restoreService = new RestoreService('/path/to/restore/file');
|
||||
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => expected);
|
||||
|
||||
const testLoad = await restoreService.load();
|
||||
expect(testLoad).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('does not load wrong play state', async () => {
|
||||
const expected = {
|
||||
playback: 'does-not-exist',
|
||||
selectedEventId: 'da5b4',
|
||||
startedAt: 1234,
|
||||
addedTime: 1234,
|
||||
pausedAt: 1234,
|
||||
firstStart: 1234,
|
||||
groupStartAt: 10,
|
||||
};
|
||||
|
||||
const restoreService = new RestoreService('/path/to/restore/file');
|
||||
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => expected);
|
||||
|
||||
const testLoad = await restoreService.load();
|
||||
expect(testLoad).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save()', () => {
|
||||
it('saves data to file', async () => {
|
||||
const testData: RestorePoint = {
|
||||
playback: Playback.Play,
|
||||
selectedEventId: '1234',
|
||||
startedAt: 1234,
|
||||
addedTime: 1234,
|
||||
pausedAt: 1234,
|
||||
firstStart: 1234,
|
||||
};
|
||||
|
||||
const restoreService = new RestoreService('/path/to/restore/file');
|
||||
const writeSpy = vi.spyOn<any, any>(restoreService, 'write').mockImplementation(() => undefined);
|
||||
await restoreService.save(testData);
|
||||
expect(writeSpy).toHaveBeenCalledWith(testData);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { isRestorePoint } from '../restore.parser.js';
|
||||
import { RestorePoint } from '../restore.type.js';
|
||||
|
||||
describe('isRestorePoint()', () => {
|
||||
it('validates a well defined object', () => {
|
||||
let restorePoint: RestorePoint = {
|
||||
playback: Playback.Roll,
|
||||
selectedEventId: '123',
|
||||
startedAt: 1,
|
||||
addedTime: 2,
|
||||
pausedAt: 3,
|
||||
firstStart: 1,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||
|
||||
restorePoint = {
|
||||
playback: Playback.Roll,
|
||||
selectedEventId: '123',
|
||||
startedAt: null,
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
firstStart: 1,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||
});
|
||||
|
||||
describe('rejects a badly formatted file', () => {
|
||||
it('with invalid playback value', () => {
|
||||
const restorePoint = {
|
||||
playback: 'unknown',
|
||||
selectedEventId: '123',
|
||||
startedAt: null,
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
groupStartAt: 10,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||
});
|
||||
it('with missing playback value', () => {
|
||||
const restorePoint = {
|
||||
selectedEventId: '123',
|
||||
startedAt: null,
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
groupStartAt: 10,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||
});
|
||||
it('with incorrect value', () => {
|
||||
const restorePoint = {
|
||||
playback: Playback.Roll,
|
||||
selectedEventId: '123',
|
||||
startedAt: 'testing',
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
groupStartAt: 10,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { RestorePoint } from '../restore.type.js';
|
||||
import { restoreService } from '../restore.service.js';
|
||||
|
||||
describe('restoreService', () => {
|
||||
describe('load()', () => {
|
||||
it('loads working file with times', async () => {
|
||||
const expected: RestorePoint = {
|
||||
playback: Playback.Play,
|
||||
selectedEventId: 'da5b4',
|
||||
startedAt: 1234,
|
||||
addedTime: 5678,
|
||||
pausedAt: 9087,
|
||||
firstStart: 1234,
|
||||
};
|
||||
|
||||
const mockRead = vi.fn().mockResolvedValue(expected);
|
||||
|
||||
const testLoad = await restoreService.load(mockRead);
|
||||
expect(testLoad).toStrictEqual(expected);
|
||||
expect(mockRead).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('loads working file without times', async () => {
|
||||
const expected: RestorePoint = {
|
||||
playback: Playback.Stop,
|
||||
selectedEventId: null,
|
||||
startedAt: null,
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
firstStart: 1234,
|
||||
};
|
||||
|
||||
const mockRead = vi.fn().mockResolvedValue(expected);
|
||||
|
||||
const testLoad = await restoreService.load(mockRead);
|
||||
expect(testLoad).toStrictEqual(expected);
|
||||
expect(mockRead).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not load wrong play state', async () => {
|
||||
const expected = {
|
||||
// Missing required field 'firstStart' to make validation fail
|
||||
playback: 'does-not-exist',
|
||||
selectedEventId: 'da5b4',
|
||||
startedAt: 1234,
|
||||
addedTime: 1234,
|
||||
pausedAt: 1234,
|
||||
groupStartAt: 10,
|
||||
};
|
||||
|
||||
const mockRead = vi.fn().mockResolvedValue(expected);
|
||||
|
||||
const testLoad = await restoreService.load(mockRead);
|
||||
// Should return null because isRestorePoint validation fails
|
||||
expect(testLoad).toBe(null);
|
||||
expect(mockRead).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns null when file read fails', async () => {
|
||||
const mockRead = vi.fn().mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const testLoad = await restoreService.load(mockRead);
|
||||
expect(testLoad).toBe(null);
|
||||
expect(mockRead).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('save()', () => {
|
||||
it('saves data to file', async () => {
|
||||
const testData: RestorePoint = {
|
||||
playback: Playback.Play,
|
||||
selectedEventId: '1234',
|
||||
startedAt: 1234,
|
||||
addedTime: 1234,
|
||||
pausedAt: 1234,
|
||||
firstStart: 1234,
|
||||
};
|
||||
|
||||
const mockWrite = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await restoreService.save(testData, mockWrite);
|
||||
expect(mockWrite).toHaveBeenCalledWith(testData);
|
||||
});
|
||||
|
||||
it('handles write failures gracefully', async () => {
|
||||
const testData: RestorePoint = {
|
||||
playback: Playback.Pause,
|
||||
selectedEventId: '5678',
|
||||
startedAt: 5678,
|
||||
addedTime: 5678,
|
||||
pausedAt: 5678,
|
||||
firstStart: 5678,
|
||||
};
|
||||
|
||||
const mockWrite = vi.fn().mockRejectedValue(new Error('Write failed'));
|
||||
|
||||
// Should not throw, and should still call write
|
||||
await expect(restoreService.save(testData, mockWrite)).resolves.toBeUndefined();
|
||||
expect(mockWrite).toHaveBeenCalledWith(testData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear()', () => {
|
||||
it('clears the restore file', async () => {
|
||||
const mockWrite = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await restoreService.clear(mockWrite);
|
||||
expect(mockWrite).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('handles clear failures gracefully', async () => {
|
||||
const mockWrite = vi.fn().mockRejectedValue(new Error('Clear failed'));
|
||||
|
||||
// Should not throw
|
||||
await expect(restoreService.clear(mockWrite)).resolves.toBeUndefined();
|
||||
expect(mockWrite).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { is } from '../../utils/is.js';
|
||||
|
||||
import type { RestorePoint } from './restore.type.js';
|
||||
|
||||
/**
|
||||
* Utility validates a RestorePoint
|
||||
*/
|
||||
export function isRestorePoint(restorePoint: unknown): restorePoint is RestorePoint {
|
||||
if (!is.object(restorePoint)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!is.objectWithKeys(restorePoint, [
|
||||
'playback',
|
||||
'selectedEventId',
|
||||
'startedAt',
|
||||
'addedTime',
|
||||
'pausedAt',
|
||||
'firstStart',
|
||||
])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.string(restorePoint.playback) && !Object.values(Playback).includes(restorePoint.playback as Playback)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.string(restorePoint.selectedEventId) && restorePoint.selectedEventId !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.number(restorePoint.startedAt) && restorePoint.startedAt !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.number(restorePoint.addedTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.number(restorePoint.pausedAt) && restorePoint.pausedAt !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.number(restorePoint.firstStart) && restorePoint.firstStart !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
|
||||
import { publicFiles } from '../../setup/index.js';
|
||||
|
||||
import { isRestorePoint } from './restore.parser.js';
|
||||
import type { RestorePoint } from './restore.type.js';
|
||||
|
||||
let failedCreateAttempts = 0;
|
||||
let savedState: RestorePoint | null = null;
|
||||
let fileRef: JSONFile<RestorePoint | null> | null = null;
|
||||
|
||||
/**
|
||||
* Service manages saving snapshot of application state
|
||||
* that can then be restored when reopening
|
||||
*/
|
||||
export const restoreService = {
|
||||
save,
|
||||
load,
|
||||
clear,
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves a restore point
|
||||
* @param [writeFn=write] - allows overriding the write function for testing
|
||||
* @public
|
||||
*/
|
||||
async function save(data: RestorePoint, writeFn = write) {
|
||||
// after three failed attempts, mark the service as unavailable
|
||||
if (failedCreateAttempts > 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deepEqual(data, savedState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFn(data);
|
||||
savedState = { ...data };
|
||||
failedCreateAttempts = 0;
|
||||
} catch (_error) {
|
||||
failedCreateAttempts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts reading a restore point from a given file path
|
||||
* Returns null if none found, restore point otherwise
|
||||
* @param [readFn=read] - allows overriding the read function for testing
|
||||
* @public
|
||||
*/
|
||||
async function load(readFn = read): Promise<RestorePoint | null> {
|
||||
try {
|
||||
const maybeRestorePoint = await readFn();
|
||||
if (isRestorePoint(maybeRestorePoint)) {
|
||||
return maybeRestorePoint;
|
||||
}
|
||||
} catch (_error) {
|
||||
// no need to notify the user
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the restore file
|
||||
* @param [writeFn=write] - allows overriding the write function for testing
|
||||
* @public
|
||||
*/
|
||||
async function clear(writeFn = write) {
|
||||
try {
|
||||
await writeFn(null);
|
||||
} catch (_error) {
|
||||
// nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialised file reference
|
||||
* @private
|
||||
*/
|
||||
async function init(): Promise<JSONFile<RestorePoint | null>> {
|
||||
if (fileRef) return fileRef;
|
||||
|
||||
fileRef = new JSONFile<RestorePoint | null>(publicFiles.restoreFile);
|
||||
return fileRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads from an intialized file reference
|
||||
* @private
|
||||
*/
|
||||
async function read(): Promise<RestorePoint | null> {
|
||||
const file = await init();
|
||||
return file.read();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes to an intialized file reference
|
||||
* @throws - if writing fails
|
||||
* @private
|
||||
*/
|
||||
async function write(data: RestorePoint | null) {
|
||||
const file = await init();
|
||||
return file.write(data);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Playback, MaybeString, MaybeNumber } from 'ontime-types';
|
||||
|
||||
export type RestorePoint = {
|
||||
playback: Playback;
|
||||
selectedEventId: MaybeString;
|
||||
startedAt: MaybeNumber;
|
||||
addedTime: number;
|
||||
pausedAt: MaybeNumber;
|
||||
firstStart: MaybeNumber;
|
||||
};
|
||||
@@ -25,7 +25,8 @@ import { triggerAutomations } from '../../api-data/automation/automation.service
|
||||
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
|
||||
|
||||
import { EventTimer } from '../EventTimer.js';
|
||||
import { RestorePoint, restoreService } from '../RestoreService.js';
|
||||
import type { RestorePoint } from '../restore-service/restore.type.js';
|
||||
import { restoreService } from '../restore-service/restore.service.js';
|
||||
import { skippedOutOfEvent } from '../timerUtils.js';
|
||||
|
||||
import {
|
||||
@@ -635,6 +636,8 @@ const eventTimer = new EventTimer({
|
||||
});
|
||||
export const runtimeService = new RuntimeService(eventTimer);
|
||||
|
||||
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
|
||||
|
||||
/**
|
||||
* Decorator manages side effects from updating the runtime
|
||||
* This should only be applied to functions that are exposed for consumption
|
||||
@@ -671,21 +674,30 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
// combine all big changes
|
||||
const hasImmediateChanges = entryChanged || justStarted || hasChangedPlayback || offsetModeChanged;
|
||||
|
||||
// clock has changed by a second or more
|
||||
const updateClock = getShouldClockUpdate(RuntimeService.previousState.clock, state.clock);
|
||||
if (updateClock) {
|
||||
batch.add('clock', state.clock);
|
||||
RuntimeService.previousState.clock = state.clock;
|
||||
}
|
||||
|
||||
// if any values have changed, values that have the possibility to tick are updated when the seconds roll over
|
||||
/**
|
||||
* if any values have changed.
|
||||
* values that have the possibility to tick are updated when the seconds roll over
|
||||
*/
|
||||
const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer);
|
||||
if (updateTimer) {
|
||||
batch.add('timer', state.timer);
|
||||
RuntimeService.previousState.timer = { ...state.timer };
|
||||
}
|
||||
|
||||
// if any values have changed, values that have the possibility to tick are modulated by `hasClockUpdate`
|
||||
/**
|
||||
* clock has changed by a second or more.
|
||||
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
||||
*/
|
||||
const updateClock = updateTimer || getShouldClockUpdate(RuntimeService.previousState.clock, state.clock);
|
||||
if (updateClock) {
|
||||
batch.add('clock', state.clock);
|
||||
RuntimeService.previousState.clock = state.clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
* values that have the possibility to tick are modulated by `updateClock || hasImmediateChanges`
|
||||
*/
|
||||
const updateRuntime = getShouldOffsetUpdate(
|
||||
RuntimeService.previousState?.offset,
|
||||
state.offset,
|
||||
@@ -696,16 +708,16 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
RuntimeService.previousState.offset = structuredClone(state.offset);
|
||||
}
|
||||
|
||||
// if any values have changed
|
||||
/**
|
||||
* if any values have changed.
|
||||
*/
|
||||
const updateRundownData = !deepEqual(RuntimeService.previousState.rundown, state.rundown);
|
||||
if (updateRundownData) {
|
||||
batch.add('rundown', state.rundown);
|
||||
RuntimeService.previousState.rundown = structuredClone(state.rundown);
|
||||
}
|
||||
|
||||
function updateMaybeEntryIfChanged<
|
||||
K extends keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>,
|
||||
>(key: K) {
|
||||
function updateMaybeEntryIfChanged<K extends EntryUpdateKeys>(key: K) {
|
||||
const previousEntry = RuntimeService.previousState[key];
|
||||
const currentEntry = state[key];
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ export function isNewSecond(
|
||||
/**
|
||||
* Checks whether we should update the clock value
|
||||
* - we have rolled into a new seconds unit
|
||||
* this is different from the timer update as it looks at the clock as counting up
|
||||
*/
|
||||
export function getShouldClockUpdate(previousUpdate: number, now: number): boolean {
|
||||
const newSeconds = millisToSeconds(now, TimerType.CountUp) !== millisToSeconds(previousUpdate, TimerType.CountUp);
|
||||
@@ -32,8 +31,10 @@ export function getShouldClockUpdate(previousUpdate: number, now: number): boole
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether we should update the timer value
|
||||
* - we have rolled into a new seconds unit
|
||||
* Checks whether we should update the timer values
|
||||
* - `current` and `secondaryTimer` trigger on seconds roll over
|
||||
* - the rest trigger on any change
|
||||
* - `elapsed` and `expectedFinish` is not checked
|
||||
*/
|
||||
export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean {
|
||||
if (previousValue === undefined) return true;
|
||||
@@ -53,6 +54,11 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether we should update the offset values
|
||||
* - `mode` triggers update
|
||||
* - `absolute`, `relative`, `expected**End` are ticked with `didDependencyUpdate`
|
||||
*/
|
||||
export function getShouldOffsetUpdate(
|
||||
previousValue: Offset | undefined,
|
||||
currentValue: Offset,
|
||||
@@ -60,7 +66,6 @@ export function getShouldOffsetUpdate(
|
||||
): boolean {
|
||||
if (previousValue === undefined) return true;
|
||||
if (previousValue.mode !== currentValue.mode) return true;
|
||||
// absolute, relative, expected*End are ticked with `didDependencyUpdate`
|
||||
return didDependencyUpdate && !deepEqual(previousValue, currentValue);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,18 @@
|
||||
* @link https://developers.google.com/identity/protocols/oauth2/limited-input-device
|
||||
*/
|
||||
|
||||
import { AuthenticationStatus, CustomFields, DatabaseModel, LogOrigin, MaybeString, Rundown } from 'ontime-types';
|
||||
import {
|
||||
AuthenticationStatus,
|
||||
CustomFields,
|
||||
DatabaseModel,
|
||||
isOntimeEvent,
|
||||
isOntimeMilestone,
|
||||
LogOrigin,
|
||||
MaybeString,
|
||||
OntimeGroup,
|
||||
Rundown,
|
||||
SupportedEntry,
|
||||
} from 'ontime-types';
|
||||
import { ImportMap, getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { sheets, type sheets_v4 } from '@googleapis/sheets';
|
||||
@@ -348,6 +359,21 @@ export async function upload(sheetId: string, options: ImportMap) {
|
||||
const { sheetMetadata } = parseExcel(readResponse.data.values, getProjectCustomFields(), 'not-used', options);
|
||||
const rundown = getCurrentRundown();
|
||||
|
||||
const sheetOrder: string[] = [];
|
||||
let prevGroup: string | null = null;
|
||||
for (const id of rundown.flatOrder) {
|
||||
const entry = rundown.entries[id];
|
||||
|
||||
if (isOntimeEvent(entry) || isOntimeMilestone(entry)) {
|
||||
if (prevGroup && entry.parent === null) {
|
||||
// if we were in a group and are now not insert a group end
|
||||
sheetOrder.push(`group-end-${prevGroup}`);
|
||||
}
|
||||
prevGroup = entry.parent;
|
||||
}
|
||||
sheetOrder.push(entry.id);
|
||||
}
|
||||
|
||||
const titleMetadata = Object.values(sheetMetadata)[0];
|
||||
if (titleMetadata === undefined) {
|
||||
throw new Error(`Sheet read failed: failed to find title row`);
|
||||
@@ -380,16 +406,20 @@ export async function upload(sheetId: string, options: ImportMap) {
|
||||
range: {
|
||||
dimension: 'ROWS',
|
||||
startIndex: titleRow + 1,
|
||||
endIndex: titleRow + rundown.order.length,
|
||||
endIndex: titleRow + sheetOrder.length,
|
||||
sheetId: worksheetId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// update the corresponding row with event data
|
||||
rundown.order.forEach((entryId, index) => {
|
||||
const entry = rundown.entries[entryId];
|
||||
return updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
|
||||
sheetOrder.forEach((entryId, index) => {
|
||||
const isGroupEnd = entryId.startsWith('group-end-');
|
||||
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
|
||||
const entry = isGroupEnd
|
||||
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
|
||||
: structuredClone(rundown.entries[id]);
|
||||
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
|
||||
});
|
||||
|
||||
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { isOntimeGroup, isOntimeEvent, OntimeEvent, OntimeEntry, RGBColour } from 'ontime-types';
|
||||
import {
|
||||
OntimeEntry,
|
||||
RGBColour,
|
||||
isOntimeDelay,
|
||||
OntimeEntryCommonKeys,
|
||||
isOntimeGroup,
|
||||
isOntimeMilestone,
|
||||
} from 'ontime-types';
|
||||
import { cssOrHexToColour, isLightColour, millisToString, mixColours } from 'ontime-utils';
|
||||
|
||||
import type { sheets_v4 } from '@googleapis/sheets';
|
||||
@@ -70,64 +77,67 @@ export function getA1Notation(row: number, column: number): string {
|
||||
|
||||
/**
|
||||
* @description - creates updateCells request from ontime event
|
||||
* @param {OntimeEntry} event
|
||||
* @param {OntimeEntry} entry
|
||||
* @param {number} index - index of the event
|
||||
* @param {number} worksheetId
|
||||
* @param {object} metadata - object with all the cell positions of the title of each attribute
|
||||
* @returns {sheets_v4.Schema} - list of update requests
|
||||
*/
|
||||
export function cellRequestFromEvent(
|
||||
event: OntimeEntry,
|
||||
entry: OntimeEntry,
|
||||
index: number,
|
||||
worksheetId: number,
|
||||
metadata: object,
|
||||
): sheets_v4.Schema$Request {
|
||||
const rowData = Object.entries(metadata)
|
||||
.filter(([_, value]) => value !== undefined)
|
||||
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [keyof OntimeEvent | 'blank', { col: number; row: number }][];
|
||||
|
||||
const titleCol = rowData[0][1].col;
|
||||
const rowData = Object.entries(metadata) // check what headings are available in the sheet
|
||||
.filter(([_, value]) => value !== undefined) // drop anything that is undefined
|
||||
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [
|
||||
OntimeEntryCommonKeys | 'blank',
|
||||
{ col: number; row: number },
|
||||
][]; // sort the array by the column index
|
||||
|
||||
// inset blank data is there is spacing between relevant ontime columns
|
||||
for (const [index, e] of rowData.entries()) {
|
||||
if (index !== 0) {
|
||||
const prevCol = rowData[index - 1][1].col;
|
||||
const thisCol = e[1].col;
|
||||
const diff = thisCol - prevCol;
|
||||
if (diff > 1) {
|
||||
const fillArr = new Array<(typeof rowData)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]);
|
||||
rowData.splice(index, 0, ...fillArr);
|
||||
}
|
||||
if (index === 0) continue;
|
||||
const prevCol = rowData[index - 1][1].col;
|
||||
const thisCol = e[1].col;
|
||||
const diff = thisCol - prevCol;
|
||||
if (diff > 1) {
|
||||
const fillArr = new Array<(typeof rowData)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]);
|
||||
rowData.splice(index, 0, ...fillArr);
|
||||
}
|
||||
}
|
||||
|
||||
const colors = isOntimeEvent(event) || isOntimeGroup(event) ? getAccessibleColour(event.colour) : undefined;
|
||||
const cellColor: sheets_v4.Schema$CellData = !colors
|
||||
const colours = 'colour' in entry ? getAccessibleColour(entry.colour) : undefined;
|
||||
const cellColor: sheets_v4.Schema$CellData = !colours
|
||||
? {}
|
||||
: {
|
||||
userEnteredFormat: {
|
||||
backgroundColor: toSheetColourLevel(colors.background),
|
||||
backgroundColor: toSheetColourLevel(colours.background),
|
||||
textFormat: {
|
||||
foregroundColor: toSheetColourLevel(colors.text),
|
||||
foregroundColor: toSheetColourLevel(colours.text),
|
||||
},
|
||||
borders: {
|
||||
bottom: {
|
||||
style: 'SOLID',
|
||||
color: toSheetColourLevel(colors.border),
|
||||
color: toSheetColourLevel(colours.border),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const returnRows: sheets_v4.Schema$CellData[] = rowData.map(([key, _]) => {
|
||||
return { ...getCellData(key, event), ...cellColor };
|
||||
return { ...getCellData(key, entry), ...cellColor };
|
||||
});
|
||||
|
||||
const headerLocation = rowData[0][1];
|
||||
|
||||
return {
|
||||
updateCells: {
|
||||
start: {
|
||||
sheetId: worksheetId,
|
||||
rowIndex: index + rowData[0][1]['row'] + 1,
|
||||
columnIndex: titleCol,
|
||||
rowIndex: index + headerLocation.row + 1,
|
||||
columnIndex: headerLocation.col,
|
||||
},
|
||||
fields: 'userEnteredValue,userEnteredFormat',
|
||||
rows: [
|
||||
@@ -139,40 +149,39 @@ export function cellRequestFromEvent(
|
||||
};
|
||||
}
|
||||
|
||||
function getCellData(key: keyof OntimeEvent | 'blank', event: OntimeEntry) {
|
||||
if (isOntimeEvent(event)) {
|
||||
if (key === 'blank') {
|
||||
return {};
|
||||
}
|
||||
if (key === 'colour') {
|
||||
return { userEnteredValue: { stringValue: event[key] } };
|
||||
}
|
||||
if (key.startsWith('custom')) {
|
||||
const customKey = key.split(':')[1];
|
||||
return { userEnteredValue: { stringValue: event.custom[customKey] } };
|
||||
}
|
||||
|
||||
if (typeof event[key] === 'number') {
|
||||
return { userEnteredValue: { stringValue: millisToString(event[key]) } };
|
||||
}
|
||||
if (typeof event[key] === 'string') {
|
||||
return { userEnteredValue: { stringValue: event[key] } };
|
||||
}
|
||||
if (typeof event[key] === 'boolean') {
|
||||
return { userEnteredValue: { boolValue: event[key] } };
|
||||
}
|
||||
function getCellData(key: OntimeEntryCommonKeys | 'blank', entry: OntimeEntry) {
|
||||
if (isOntimeDelay(entry) || key === 'blank') {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (isOntimeGroup(event)) {
|
||||
if (key === 'title') {
|
||||
return { userEnteredValue: { stringValue: event[key] } };
|
||||
}
|
||||
if (key === 'timerType') {
|
||||
return { userEnteredValue: { stringValue: 'group' } };
|
||||
}
|
||||
// we need to flatten the milestones
|
||||
if (key.startsWith('custom')) {
|
||||
const customKey = key.split(':')[1];
|
||||
return { userEnteredValue: { stringValue: entry.custom[customKey] } };
|
||||
}
|
||||
|
||||
return {};
|
||||
// we need to remap the event type to timer type in the case of groups and milestones
|
||||
if (key === 'timerType') {
|
||||
if (isOntimeGroup(entry))
|
||||
return { userEnteredValue: { stringValue: entry.id.startsWith('group-end') ? 'group-end' : 'group' } };
|
||||
if (isOntimeMilestone(entry)) return { userEnteredValue: { stringValue: 'milestone' } };
|
||||
return { userEnteredValue: { stringValue: entry.timerType } };
|
||||
}
|
||||
|
||||
// typescript cannot guarantee that the key exists for every entry
|
||||
// so we check for the key existence and assert the type
|
||||
if (!(key in entry)) return {};
|
||||
const value = entry[key as keyof OntimeEntry];
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return { userEnteredValue: { stringValue: millisToString(value) } };
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return { userEnteredValue: { stringValue: value } };
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return { userEnteredValue: { boolValue: value } };
|
||||
}
|
||||
}
|
||||
|
||||
type googleSheetCellColour = {
|
||||
|
||||
@@ -53,6 +53,8 @@ export function getAppDataPath(): string {
|
||||
* │ │ ├─ override.css
|
||||
* │ ├─ logo/
|
||||
* │ │ ├─ logo.png
|
||||
* │ ├─ translations/
|
||||
* │ │ ├─ translations.json
|
||||
*/
|
||||
|
||||
/** resolve file URL in both CJS and ESM (build and dev) */
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { timeNow } from '../utils/time.js';
|
||||
import type { RestorePoint } from '../services/RestoreService.js';
|
||||
import type { RestorePoint } from '../services/restore-service/restore.type.js';
|
||||
import { getCurrent, getExpectedFinish, getRuntimeOffset, getTimerPhase } from '../services/timerUtils.js';
|
||||
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
|
||||
import { timerConfig } from '../setup/config.js';
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
|
||||
/**
|
||||
* This CSS file allows user customisation of the UI
|
||||
* We expose some CSS properties to facilitate this (see below in :root)
|
||||
* In the cases where this is missing, you can add your selectors here
|
||||
*/
|
||||
|
||||
:root {
|
||||
/** Background colour for the views */
|
||||
--background-color-override: #ececec;
|
||||
|
||||
/** Main text colour for the views */
|
||||
--color-override: #101010;
|
||||
|
||||
/** Text colour for the views */
|
||||
--secondary-color-override: #404040;
|
||||
|
||||
/** Accent text colour, used on active elements */
|
||||
--accent-color-override: #fa5656;
|
||||
|
||||
/** Label text colour, used on active elements */
|
||||
--label-color-override: #6c6c6c;
|
||||
|
||||
/** Timer text colour */
|
||||
--timer-color-override: #202020;
|
||||
--timer-warning-color-override: #ffbc56;
|
||||
--timer-danger-color-override: #e69000;
|
||||
--timer-overtime-color-override: #fa5656;
|
||||
--timer-pending-color-override: #578AF4;
|
||||
|
||||
/** Background for card elements on background */
|
||||
--card-background-color-override: #fff;
|
||||
|
||||
/** Font used for all text in views */
|
||||
--font-family-override: 'Open Sans';
|
||||
|
||||
/** Colour used for external message and aux timer in /timer */
|
||||
--external-color-override: #161616;
|
||||
|
||||
/** View specific features: /backstage */
|
||||
/** ---- Background highlight for blink behaviour */
|
||||
--card-background-color-blink-override: #339e4e;
|
||||
/** ---- Colour used for progress bar background */
|
||||
--timer-progress-bg-override: #fff;
|
||||
/** ---- Colour used for progress bar progress */
|
||||
--timer-progress-override: #202020;
|
||||
|
||||
/** View specific features: /op */
|
||||
--operator-customfield-font-size-override: 1.25rem;
|
||||
--operator-running-bg-override: #339e4e;
|
||||
|
||||
/** View specific features: /studio */
|
||||
--studio-active: #101010;
|
||||
--studio-idle: #cfcfcf;
|
||||
--studio-active-label: #101010;
|
||||
--studio-idle-label: #595959;
|
||||
--studio-overtime: #101010;
|
||||
}
|
||||
|
||||
/**
|
||||
* You can inspect the page in your browser and add the selectors here.
|
||||
* In the below example, we change the colour of the overlay message in the stage-timer view.
|
||||
*/
|
||||
.stage-timer > .message-overlay--active > div {
|
||||
color: red;
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "4.0.0-alpha.5",
|
||||
"version": "4.0.0-beta.2",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
|
||||
@@ -7,4 +7,5 @@ export enum RefetchKey {
|
||||
UrlPresets = 'url-presets',
|
||||
ViewSettings = 'view-settings',
|
||||
Translation = 'translation',
|
||||
Settings = 'settings',
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const langEn = {
|
||||
'common.expected_finish': 'Expected Finish',
|
||||
'common.minutes': 'min',
|
||||
'common.seconds': 'sec',
|
||||
'common.now': 'Now',
|
||||
'common.next': 'Next',
|
||||
'common.scheduled_start': 'Scheduled start',
|
||||
@@ -13,6 +14,7 @@ export const langEn = {
|
||||
'common.no_data': 'No data',
|
||||
'countdown.ended': 'Event ended at',
|
||||
'countdown.running': 'Event running',
|
||||
'countdown.loaded': 'Event loaded',
|
||||
'countdown.select_event': 'Select an event to follow',
|
||||
'countdown.to_start': 'Time to start',
|
||||
'countdown.waiting': 'Waiting for event start',
|
||||
|
||||
Generated
+377
-320
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user