Compare commits

..

21 Commits

Author SHA1 Message Date
Carlos Valente 9a6feed49b refactor: improve groups and time end 2026-06-28 09:08:24 +02:00
Carlos Valente c6cf9f2bf7 fixup! refactor: add to correct section 2026-06-28 08:48:14 +02:00
Carlos Valente 2f73303554 refactor: simplify object creation 2026-06-28 08:46:11 +02:00
Carlos Valente 96943ca528 refactor: add to correct section 2026-06-28 08:45:54 +02:00
Carlos Valente 8cfffd32c7 fix: duplicate rundowns from service layer 2026-06-27 16:30:04 +02:00
Carlos Valente 24a606a246 fixup! improve linting and documentation 2026-06-27 16:27:59 +02:00
Carlos Valente c322028821 docs 2026-06-27 16:27:59 +02:00
Carlos Valente 9f40d962ea improve linting and documentation 2026-06-27 16:27:59 +02:00
Claude 1e851ae40d refactor(mcp): port tools to current services with typed translation handlers
- Tool handlers are now a typed map of thin translation wrappers: wire
  arguments are cast once at the boundary to types derived (Pick) from
  ontime-types, and all service calls are fully typed — a change to a
  service signature or domain type now fails compilation in api-mcp
  instead of drifting silently. No more 'as never' casts.
- Entry mutations target the loaded rundown explicitly, matching the
  rundownId-first service signatures introduced with secondary rundowns.
- Rundown create/rename/delete/duplicate call the rundown service;
  mcp.service.ts and its duplicated router logic are deleted.
- ontime_create_entry supports all entry types (event, delay, milestone,
  group) and entry tools accept flag and custom field values, matching
  what the workflow prompts already instruct agents to do. Tool names
  use entry terminology consistently with the service layer.
- ontime_create_project description fixed: creating a project switches
  to it.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude 2bcda2dfa4 refactor(mcp): scope bearer authentication to the MCP route
Reverts the change to the shared authenticate middleware, which made
every authenticated route accept Authorization: Bearer tokens. Bearer
support now lives in a wrapper middleware inside api-mcp, mounted only
on /mcp; all other requests fall through to the app middleware
unchanged. Removing the api-mcp module restores stock auth behaviour.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude e2a2ea1d24 refactor(rundown): extract rundown rename/delete/duplicate into service layer
Moves the logic previously inlined in the router handlers into
rundown.service.ts so other consumers (such as the MCP server) can reuse
it without duplication. Status codes and error messages are unchanged.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude 5be596b83e refactor(mcp): dispatch map for resources, userPrompt helper, enrich prompt guidance
mcp.resources.ts: replace 6-branch if-chain with a RESOURCE_READERS dispatch map.
Each resource is now a one-liner { mimeType, read } entry; handleReadResource
collapses to a 3-line lookup with a single throw.

mcp.prompts.ts: extract repeated message-wrapping boilerplate into userPrompt()
helper, reducing each prompt from ~10 lines of structure to a single call.

Enrich all four prompts with operational domain knowledge:
- create_rundown_from_agenda: entry type selection (event/milestone/delay/group),
  timerType and endAction guidance, linkStart chain semantics, flag usage, colour
  conventions
- bulk_edit_rundown: timeStrategy (lock-end vs lock-duration), endAction automation
  risk, clarified time-shift mechanics
- validate_rundown: totalDelay/totalDays checks, linkStart chain validation,
  play-next chain disclosure, flag audit
- restructure_rundown: group awareness (entries vs order arrays), insert order type,
  move-out vs move-in patterns

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude ab15a65585 simplify: unify 405 handlers, fix double-read in renameRundown, guard mcpClientConfig
- mcp.router.ts: extract identical GET/DELETE 405 responses into methodNotAllowed
- mcp.service.ts: pass the already-patched rundown object to initRundown instead
  of re-fetching it from the data provider
- McpSection.tsx: compute mcpClientConfig only when mcpEndpointUrl is truthy

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 65ef442a9d refactor(mcp): consolidate documentation into mcp.schema.ts
Single file to update when the data model changes:
- EVENT_TIMER_FIELDS and EVENT_WRITABLE_FIELDS (tool JSON schemas) moved from mcp.tools.ts
- ONTIME_SCHEMA_MARKDOWN and ONTIME_DOCS_MARKDOWN moved from mcp.resources.ts
- mcp.tools.ts and mcp.resources.ts now import from mcp.schema.ts
- Removed inline data model notes from the agenda prompt; replaced with a reference
  to the ontime://schema resource
- Fixed inaccurate enum values in schema (Milestone type added, TimerType/EndAction
  corrected to match ontime-types)

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 71a7f26493 refactor(mcp): address review — split files, fix naming, stateless router
Split the monolithic mcp.router.ts into focused modules:
- mcp.service.ts: coordination helpers (rundownListResponse, renameRundown, deleteRundown)
- mcp.tools.ts: tool definitions (ontime_ prefix, annotation presets, shared field schemas,
  CHARACTER_LIMIT truncation) + handleToolCall dispatcher
- mcp.prompts.ts: prompt definitions + handleGetPrompt
- mcp.resources.ts: resource definitions (schema, live data, docs) + handleReadResource
- mcp.server.ts: createMcpServer factory that wires the above modules
- mcp.router.ts: thin stateless Express router (POST/GET405/DELETE405)

Other changes per review comments:
- Rename ontime_delete_event → ontime_delete_entry (entry vs event naming convention)
- Remove hardcoded cue/colour prefix conventions from tool descriptions and prompts;
  replace with "ask the user what convention they prefer"
- Remove automations tools (left for a later PR)
- Add Bearer auth comment in authenticate.ts explaining the MCP use case
- Extract McpSection component from FeaturePanel to isolate URL state re-renders
- Update bulk_edit prompt to mention linkStart cascade behaviour
- Add 405 comments explaining why GET/DELETE are not supported in stateless mode

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude b850a51204 mcp: add ontime://docs resource for documentation discoverability
Exposes a curated index of docs.getontime.no topic URLs as the
ontime://docs MCP resource, and adds a "Further reading" footer to
ontime://schema so agents reading the data model know where to find
deeper documentation.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 0094c15da2 feat(mcp): expose rundown and schema as readable MCP resources
Adds five resources so the agent can load context once per session
instead of re-fetching via tools, and so it can ground its answers in
Ontime's data model without guessing:

- ontime://schema — markdown reference for the event/rundown/group
  model, time format (ms from midnight), cue conventions, colours,
  playback states
- ontime://rundown/current — full currently-loaded rundown (JSON)
- ontime://rundowns — every rundown in the project plus the loaded ID
- ontime://project/info — project metadata
- ontime://project/custom-fields — custom field definitions

Declares the resources capability on the MCP server and registers
resources/list and resources/read handlers that dispatch by URI.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude e7d62f44e7 feat(mcp): batch rundown edits, project file management, workflow prompts
Adds 8 tools and 3 prompts to cover four priority pre-production flows:
build-from-agenda (batch create), bulk edit, validate, restructure.

Tools:
- create_events_batch, batch_update_events — avoid per-event round trips
- list_projects, load_project, create_project, rename_project,
  duplicate_project, delete_project — project file management

Prompts:
- bulk_edit_rundown — teaches the agent to use batch_update_events vs
  per-event updates based on whether fields are shared
- validate_rundown — pre-show checks for cue gaps, overlaps, missing
  fields, duration anomalies
- restructure_rundown — teaches reorder_event sequencing
- create_rundown_from_agenda — updated to use create_events_batch

Safety:
- Mutating tools now attach a playback warning to their response when
  timer state is not stopped, so the agent can relay it to the user
- create_automation description mentions the in-app test button

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 3c8d7e51f5 chore: ignore .claude/ metadata directory
https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 3da3950fce fix(mcp): session ID mismatch, rename_rundown data loss, DELETE status
- Session ID: deterministic UUID passed to sessionIdGenerator so the
  map key matches the mcp-session-id header sent to clients; previously
  two independent randomUUID() calls produced different IDs, breaking
  all post-initialize requests
- rename_rundown: use spread { ...rundown, title } instead of narrow
  structuredClone cast that silently stripped order/entries/revision
- DELETE /mcp: return 204 No Content instead of 200 with body

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude ac3694ce4d feat: add MCP server for AI agent integration
Adds a Streamable HTTP MCP server at /mcp that exposes 19 tools
covering rundown entries, rundown management, runtime state, project
info, automations, and custom fields. Includes Bearer token auth
support and a UI card in Settings > Sharing showing the endpoint URL
and Claude Desktop config.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
54 changed files with 484 additions and 863 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.10.0", "version": "4.9.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.10.0", "version": "4.9.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -8,7 +8,6 @@ import { useLocation } from 'react-router';
import { isLocalhost, supportsFullscreen } from '../../../externals'; import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -106,8 +105,6 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && ( {isLocalhost && (
@@ -120,27 +117,3 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</Dialog.Root> </Dialog.Root>
); );
} }
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation();
const { data: urlPresets } = useUrlPresets();
const navPresets = urlPresets.filter((preset) => preset.enabled && preset.displayInNav);
if (navPresets.length === 0) return null;
return (
<>
<hr className={style.separator} />
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
to={`preset/${preset.alias}`}
current={location.pathname === `/preset/${preset.alias}`}
postAction={isSmallScreen ? onClose : undefined}
>
{preset.alias}
</ClientLink>
))}
</>
);
}
@@ -1,26 +0,0 @@
@use '@/theme/viewerDefs' as *;
.schedule__ {
padding-top: 0.5rem;
display: flex;
gap: 0.25em;
font-size: $timer-label-size;
color: var(--label-color-override, $timer-color);
}
.schedule__delayed {
color: $ontime-delay-text;
}
.schedule__strike {
text-decoration: line-through;
color: var(--label-color-override, $viewer-label-color);
}
.schedule__over {
color: $playback-over;
}
.schedule__under {
color: $playback-under;
}
@@ -1,88 +0,0 @@
import { OntimeEvent } from 'ontime-types';
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
import ClockTime from '../../../views/common/clock-time/ClockTime';
import { getOffsetState } from '../../utils/offset';
import { ExtendedEntry } from '../../utils/rundownMetadata';
import './ScheduleTime.scss';
import { cx } from '../../utils/styleUtils';
type ScheduleTimeProps = {
event: ExtendedEntry<OntimeEvent>;
showExpected: boolean;
className?: string;
preferredFormat12?: string;
preferredFormat24?: string;
expectedStart: number;
};
//TODO: consider relative mode
export default function ScheduleTime(props: ScheduleTimeProps) {
const {
event,
showExpected,
className,
preferredFormat12 = 'h:mm a',
preferredFormat24 = 'HH:mm',
expectedStart,
} = props;
const { timeStart, duration, delay, countToEnd } = event;
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
// only show new expected value if outside range of the planned value
const isExpectedValueShow = showExpected && isOutsideRange(plannedStart, expectedStart);
const plannedStateClass = isExpectedValueShow ? 'schedule__strike' : delay !== 0 ? 'schedule__delayed' : '';
const expectedOffsetState = getOffsetState(expectedStart - plannedStart);
const expectedStateClass = expectedOffsetState ? `schedule__${expectedOffsetState}` : '';
const plannedEnd = plannedStart + duration;
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
const expectedEndOffsetState = getOffsetState(expectedEnd - plannedEnd);
const expectedEndClass = expectedEndOffsetState ? `schedule__${expectedEndOffsetState}` : '';
return (
<div className={cx(['schedule__', className])}>
<ClockTime
value={plannedStart}
preferredFormat12={preferredFormat12}
preferredFormat24={preferredFormat24}
className={plannedStateClass}
/>
{!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>
);
}
function isOutsideRange(a: number, b: number): boolean {
return Math.abs(a - b) > MILLIS_PER_MINUTE;
}
@@ -37,7 +37,6 @@ describe('getRouteFromPreset()', () => {
alias: 'demopage', alias: 'demopage',
target: OntimeView.Timer, target: OntimeView.Timer,
search: 'user=guest', search: 'user=guest',
displayInNav: false,
options: {}, options: {},
}, },
]; ];
@@ -120,7 +119,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-4685d6', alias: 'cuesheet-4685d6',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: '-', write: '-',
@@ -133,7 +131,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-basic', alias: 'cuesheet-basic',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
}, },
]; ];
const cuesheetPresetWithNavLock: URLPreset[] = [ const cuesheetPresetWithNavLock: URLPreset[] = [
@@ -142,7 +139,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-locked', alias: 'cuesheet-locked',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: 'n=1', search: 'n=1',
displayInNav: false,
}, },
]; ];
@@ -224,7 +220,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -236,7 +231,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -248,7 +242,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -260,7 +253,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
])('should generate URL preset options for %s', (_description, alias, url, expected) => { ])('should generate URL preset options for %s', (_description, alias, url, expected) => {
@@ -194,7 +194,6 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
target: path, target: path,
search: url.searchParams.toString(), search: url.searchParams.toString(),
enabled: true, enabled: true,
displayInNav: false,
}; };
} }
@@ -1,8 +1,7 @@
import { OntimeView, URLPreset } from 'ontime-types'; import { URLPreset } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5'; import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
@@ -23,23 +22,13 @@ const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
export default function URLPresets() { export default function URLPresets() {
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined }); const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
const [actionError, setActionError] = useState<string | null>(null);
const { data, status } = useUrlPresets(); const { data, status } = useUrlPresets();
const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset(); const { deletePreset, isMutating } = useUpdateUrlPreset();
const openNewForm = () => setFormState({ isOpen: true }); const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset }); const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
const closeForm = () => setFormState({ isOpen: false, preset: undefined }); const closeForm = () => setFormState({ isOpen: false, preset: undefined });
const persistPreset = async (preset: URLPreset) => {
setActionError(null);
try {
await updatePreset(preset.alias, preset);
} catch (error) {
setActionError(maybeAxiosError(error));
}
};
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
@@ -64,12 +53,10 @@ export default function URLPresets() {
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} /> <Panel.Loader isLoading={status === 'pending'} />
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />} {formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<th>Enabled</th> <th>Enabled</th>
<th>Show in nav</th>
<th>Target view</th> <th>Target view</th>
<th>Alias</th> <th>Alias</th>
<th /> <th />
@@ -78,23 +65,10 @@ export default function URLPresets() {
<tbody> <tbody>
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />} {data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
{data.map((preset, index) => { {data.map((preset, index) => {
const isCuesheet = preset.target === OntimeView.Cuesheet;
return ( return (
<tr key={preset.alias}> <tr key={preset.alias}>
<td> <td>
<Switch <Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
checked={preset.enabled}
onCheckedChange={(enabled) => persistPreset({ ...preset, enabled })}
disabled={isMutating}
aria-label='Toggle preset enabled'
/>
</td>
<td>
<Switch
checked={preset.displayInNav}
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
disabled={isMutating || isCuesheet}
/>
</td> </td>
<td> <td>
<Tag>{preset.target}</Tag> <Tag>{preset.target}</Tag>
@@ -1,5 +1,5 @@
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types'; import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils'; import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
@@ -12,7 +12,6 @@ import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex'; import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils'; import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss'; import style from './URLPresetForm.module.scss';
@@ -33,7 +32,6 @@ const defaultValues: URLPreset = {
target: OntimeView.Timer, target: OntimeView.Timer,
search: '', search: '',
enabled: true, enabled: true,
displayInNav: false,
}; };
interface URLPresetFormProps { interface URLPresetFormProps {
@@ -63,42 +61,12 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
}); });
const urlRef = useRef<HTMLInputElement>(null); const urlRef = useRef<HTMLInputElement>(null);
// Cuesheet read/write permissions live outside react-hook-form
const initialPermissions = useRef<CuesheetPermissionValues>({
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
});
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>(initialPermissions.current);
// update initial permissions on mount
useEffect(() => {
initialPermissions.current = {
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
};
setCuesheetPermissions(initialPermissions.current);
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- run on mount
}, []);
const isEditingCuesheet = urlPreset && urlPreset.target === OntimeView.Cuesheet;
const isCuesheet = watch('target') === OntimeView.Cuesheet;
const permissionsDirty =
isCuesheet &&
(cuesheetPermissions.read !== initialPermissions.current.read ||
cuesheetPermissions.write !== initialPermissions.current.write);
const noReadAccess = isCuesheet && cuesheetPermissions.read === '-';
const setupSubmit = async (data: URLPreset) => { const setupSubmit = async (data: URLPreset) => {
try { try {
// Preserve / apply cuesheet permissions, which are not part of the form fields
const payload: URLPreset =
data.target === OntimeView.Cuesheet
? { ...data, target: OntimeView.Cuesheet, options: cuesheetPermissions }
: data;
if (urlPreset) { if (urlPreset) {
await updatePreset(urlPreset.alias, payload); await updatePreset(urlPreset.alias, data);
} else { } else {
await addPreset(payload); await addPreset(data);
} }
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -106,7 +74,6 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
} }
}; };
// focus on alias when the form opens
useEffect(() => { useEffect(() => {
setFocus('alias'); setFocus('alias');
}, [setFocus]); }, [setFocus]);
@@ -159,66 +126,44 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<div className={style.expand}> <div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description> <Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<Panel.InlineElements> <Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} /> <Input placeholder='Paste URL' fluid ref={urlRef} />
<Button onClick={generateOptions} disabled={isEditingCuesheet}> <Button onClick={generateOptions}>Generate</Button>
Generate
</Button>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
</Panel.InlineElements> </Panel.InlineElements>
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>} {errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
{!isEditingCuesheet && ( <div>
<> {enDash} or {enDash}
<div> </div>
{enDash} or {enDash} <div>2. Choose a view and its parameters</div>
</div> <div>
<div>2. Choose a view and its parameters</div> <Panel.Description>Target</Panel.Description>
<div> <Select
<Panel.Description>Target</Panel.Description> options={targetOptions}
<Select {...register('target', { required: 'Target is required' })}
options={targetOptions} value={watch('target')}
{...register('target', { required: 'Target is required' })} onValueChange={(value: OntimeViewPresettable | null) => {
value={watch('target')} if (value === null) return;
onValueChange={(value: OntimeViewPresettable | null) => { setValue('target', value, { shouldDirty: true });
if (value === null) return; }}
setValue('target', value, { shouldDirty: true }); />
}} </div>
/> <div>
</div> <Panel.Description>Parameters</Panel.Description>
<div> <Textarea
<Panel.Description>Parameters</Panel.Description> fluid
<Textarea rows={3}
fluid {...register('search', {
rows={3} validate: validateParams,
{...register('search', { })}
validate: validateParams, />
})} <Panel.Error>{errors.search?.message}</Panel.Error>
/> </div>
<Panel.Error>{errors.search?.message}</Panel.Error>
</div>
</>
)}
{isCuesheet && (
<div>
<Panel.Description>Permissions</Panel.Description>
<CuesheetLinkOptions
initialRead={initialPermissions.current.read}
initialWrite={initialPermissions.current.write}
onChange={setCuesheetPermissions}
/>
</div>
)}
<div> <div>
<Panel.Error>{errors.root?.message}</Panel.Error> <Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end'> <Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button <Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}>
variant='primary'
type='submit'
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
loading={isSubmitting || isMutating}
>
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -10,19 +10,13 @@ export default function McpSection() {
const { data: infoData } = useInfo(); const { data: infoData } = useInfo();
const [mcpEndpointUrl, setMcpEndpointUrl] = useState(''); const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
// generate url
useEffect(() => { useEffect(() => {
const baseUrl = (() => { const baseUrl = isOntimeCloud
if (isOntimeCloud) return serverURL; ? serverURL
: infoData.networkInterfaces.length > 0
? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`
: serverURL;
// for local setups we prefer the localhost IP to avoid remote access
if (infoData.networkInterfaces.length > 0) {
return `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`;
}
return serverURL;
})();
// we are reusing the endpoint, so locking config and nav have no effect
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false }) generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
.then(setMcpEndpointUrl) .then(setMcpEndpointUrl)
.catch(() => { .catch(() => {
@@ -1,5 +1,5 @@
import { TranslationObject, langEn } from 'ontime-types'; import { TranslationObject, langEn } from 'ontime-types';
import { useEffect, useMemo } from 'react'; import { useMemo } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
@@ -10,8 +10,6 @@ import Modal from '../../../../../common/components/modal/Modal';
import { useTranslation } from '../../../../../translation/TranslationProvider'; import { useTranslation } from '../../../../../translation/TranslationProvider';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './CustomTranslationModal.module.scss';
interface CustomTranslationModalProps { interface CustomTranslationModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -19,7 +17,14 @@ interface CustomTranslationModalProps {
export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) { export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) {
const { userTranslation, postUserTranslation } = useTranslation(); const { userTranslation, postUserTranslation } = useTranslation();
const customTranslationFormValues = useMemo(() => toFormValues(userTranslation), [userTranslation]);
const defaultValues = useMemo(() => {
const values: Record<string, string> = {};
Object.keys(langEn).forEach((key) => {
values[toFormKey(key)] = userTranslation[key as keyof TranslationObject] || '';
});
return values;
}, [userTranslation]);
const { const {
handleSubmit, handleSubmit,
@@ -28,17 +33,13 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
formState: { isSubmitting, isDirty, errors, isValid }, formState: { isSubmitting, isDirty, errors, isValid },
setError, setError,
} = useForm({ } = useForm({
defaultValues: customTranslationFormValues, defaultValues,
resetOptions: {
keepDirtyValues: true,
},
mode: 'onChange', mode: 'onChange',
}); });
// keep data up to date
useEffect(() => {
if (isOpen) {
reset(customTranslationFormValues);
}
}, [customTranslationFormValues, isOpen, reset]);
const onSubmit = async (formData: Record<string, string>) => { const onSubmit = async (formData: Record<string, string>) => {
try { try {
const translationData: Record<string, string> = {}; const translationData: Record<string, string> = {};
@@ -53,10 +54,6 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
} }
}; };
const resetToEnglish = () => {
reset(toFormValues(langEn), { keepDefaultValues: true });
};
return ( return (
<Modal <Modal
title='Edit custom translations' title='Edit custom translations'
@@ -87,12 +84,9 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
</Panel.Section> </Panel.Section>
} }
footerElements={ footerElements={
<div className={style.footer}> <div>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements align='apart'> <Panel.InlineElements align='apart'>
<Button variant='ghosted' size='large' onClick={resetToEnglish} disabled={isSubmitting}>
Reset to English
</Button>
<Panel.InlineElements> <Panel.InlineElements>
<Button size='large' onClick={onClose}> <Button size='large' onClick={onClose}>
Cancel Cancel
@@ -115,14 +109,6 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
); );
} }
function toFormValues(translation: Partial<TranslationObject>) {
const values: Record<string, string> = {};
Object.keys(langEn).forEach((key) => {
values[toFormKey(key)] = translation[key as keyof TranslationObject] || '';
});
return values;
}
function toFormKey(key: string) { function toFormKey(key: string) {
return key.replace('.', '_'); return key.replace('.', '_');
} }
@@ -1,6 +1,6 @@
import { OntimeView, URLPreset } from 'ontime-types'; import { OntimeView, URLPreset } from 'ontime-types';
import { generateId } from 'ontime-utils'; import { generateId } from 'ontime-utils';
import { useCallback, useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { FieldErrors, useForm } from 'react-hook-form'; import { FieldErrors, useForm } from 'react-hook-form';
import { generateUrl } from '../../common/api/session'; import { generateUrl } from '../../common/api/session';
@@ -18,7 +18,7 @@ import { preventEscape } from '../../common/utils/keyEvent';
import { isUrlSafe } from '../../common/utils/regex'; import { isUrlSafe } from '../../common/utils/regex';
import { isOntimeCloud, serverURL } from '../../externals'; import { isOntimeCloud, serverURL } from '../../externals';
import * as Panel from '../app-settings/panel-utils/PanelUtils'; import * as Panel from '../app-settings/panel-utils/PanelUtils';
import CuesheetLinkOptions, { CuesheetPermissionValues } from './composite/CuesheetLinkOptions'; import CuesheetLinkOptions from './composite/CuesheetLinkOptions';
import style from './GenerateLinkForm.module.scss'; import style from './GenerateLinkForm.module.scss';
@@ -53,26 +53,12 @@ type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) { export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
const [formState, setFormState] = useState<GenerateLinkState>('pending'); const [formState, setFormState] = useState<GenerateLinkState>('pending');
const [url, setUrl] = useState(''); const [url, setUrl] = useState(serverURL);
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>({ const cuesheetReadRef = useRef<HTMLInputElement>(null);
read: 'full', const cuesheetWriteRef = useRef<HTMLInputElement>(null);
write: 'full',
});
const generatedAlias = useRef<string>(`cuesheet-${generateId()}`); const generatedAlias = useRef<string>(`cuesheet-${generateId()}`);
const { addPreset, updatePreset } = useUpdateUrlPreset(); const { addPreset } = useUpdateUrlPreset();
// Tracks the alias we already created this session so re-generating updates rather than duplicates it
const createdAlias = useRef<string | null>(null);
/**
* Permissions live outside react-hook-form, so we reset a successful state manually
* whenever they change - this re-arms the "Create share link" button as the previous
* link no longer reflects the selected permissions.
*/
const handlePermissionsChange = useCallback((permissions: CuesheetPermissionValues) => {
setCuesheetPermissions(permissions);
setFormState((current) => (current === 'success' ? 'pending' : current));
}, []);
const { const {
handleSubmit, handleSubmit,
@@ -104,20 +90,16 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
if (options.read === '-') { if (options.read === '-') {
throw new Error('Cannot create a share with no read permissions'); throw new Error('Cannot create a share with no read permissions');
} }
const payload = { const presets = await addPreset({
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
enabled: true, enabled: true,
alias, alias,
search: '', search: '',
displayInNav: false,
options: { options: {
read: options.read, read: options.read,
write: options.write, write: options.write,
}, },
} as const; });
// Re-generating with the same name updates the existing preset instead of failing on a duplicate alias
const presets = createdAlias.current === alias ? await updatePreset(alias, payload) : await addPreset(payload);
createdAlias.current = alias;
return presets.find((preset) => preset.alias === alias); return presets.find((preset) => preset.alias === alias);
}; };
@@ -126,8 +108,8 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
setFormState('loading'); setFormState('loading');
if (options.path === OntimeView.Cuesheet) { if (options.path === OntimeView.Cuesheet) {
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, { const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
read: cuesheetPermissions.read, read: cuesheetReadRef.current?.value ?? 'full',
write: cuesheetPermissions.write, write: cuesheetWriteRef.current?.value ?? 'full',
}); });
if (!urlPreset) { if (!urlPreset) {
@@ -175,7 +157,6 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
} }
}; };
const noReadAccess = watch('path') === OntimeView.Cuesheet && cuesheetPermissions.read === '-';
const canSubmit = isDirty || formState !== 'success'; const canSubmit = isDirty || formState !== 'success';
return ( return (
@@ -239,7 +220,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
})} })}
/> />
</Panel.ListItem> </Panel.ListItem>
<CuesheetLinkOptions onChange={handlePermissionsChange} /> <CuesheetLinkOptions readRef={cuesheetReadRef} writeRef={cuesheetWriteRef} />
</> </>
)} )}
@@ -287,29 +268,18 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
</Panel.ListGroup> </Panel.ListGroup>
<Panel.Error>{errors.root?.message}</Panel.Error> <Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end' className={style.end}> <Panel.InlineElements align='end' className={style.end}>
<Button <Button type='submit' variant={canSubmit ? 'primary' : 'subtle'} loading={formState === 'loading'}>
type='submit'
variant={canSubmit ? 'primary' : 'subtle'}
loading={formState === 'loading'}
disabled={noReadAccess}
>
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'} {canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
<Panel.Section className={style.column}> <Panel.Section className={style.column}>
<Panel.Description>Share this link</Panel.Description> <Panel.Description>Share this link</Panel.Description>
{url ? ( <QRCode size={172} value={url} />
<> <div className={style.copiableLink} data-testid='copy-link'>
<QRCode size={172} value={url} /> {url}
<div className={style.copiableLink} data-testid='copy-link'> </div>
{url} <CopyTag copyValue={url}>Copy link</CopyTag>
</div>
<CopyTag copyValue={url}>Copy link</CopyTag>
</>
) : (
<Panel.Description>Your link will appear here once you create it.</Panel.Description>
)}
</Panel.Section> </Panel.Section>
</div> </div>
</form> </form>
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { Fragment, RefObject, useMemo, useState } from 'react';
import RadioGroup from '../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../common/components/radio-group/RadioGroup';
import Switch from '../../../common/components/switch/Switch'; import Switch from '../../../common/components/switch/Switch';
@@ -10,141 +10,118 @@ import style from './CuesheetLinkOptions.module.scss';
type AccessMode = 'full' | 'custom'; type AccessMode = 'full' | 'custom';
export interface CuesheetPermissionValues {
read: string;
write: string;
}
interface CuesheetLinkOptionsProps { interface CuesheetLinkOptionsProps {
/** Existing read permission to seed the form with ('full' | '-' | comma separated keys) */ readRef?: RefObject<HTMLInputElement | null>;
initialRead?: string; writeRef?: RefObject<HTMLInputElement | null>;
/** Existing write permission to seed the form with ('full' | '-' | comma separated keys) */
initialWrite?: string;
/** Notifies the parent whenever the resolved read/write permissions change */
onChange: (permissions: CuesheetPermissionValues) => void;
} }
/** A null result means "full or unset" - there is no explicit per-column selection to seed */ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkOptionsProps) {
function parseKeys(permission: string | undefined): Set<string> | null {
if (permission == null || permission === 'full') {
return null;
}
if (permission === '-') {
return new Set();
}
return new Set(permission.split(','));
}
function modeFromPermission(permission: string | undefined): AccessMode {
return permission == null || permission === 'full' ? 'full' : 'custom';
}
export default function CuesheetLinkOptions({ initialRead, initialWrite, onChange }: CuesheetLinkOptionsProps) {
const { data } = useCustomFields(); const { data } = useCustomFields();
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]); const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
const allColumns = useMemo(() => [...cuesheetDefaultColumns, ...customFieldColumns], [customFieldColumns]);
// Parsed seed values - stable for the lifetime of a given preset const [readPermissions, setReadPermissions] = useState<AccessMode>('full');
const initialReadKeys = useMemo(() => parseKeys(initialRead), [initialRead]); const [writePermissions, setWritePermissions] = useState<AccessMode>('full');
const initialWriteKeys = useMemo(() => parseKeys(initialWrite), [initialWrite]);
const [readPermissions, setReadPermissions] = useState<AccessMode>(() => modeFromPermission(initialRead)); const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>(() => {
const [writePermissions, setWritePermissions] = useState<AccessMode>(() => modeFromPermission(initialWrite)); const initialState: Record<string, boolean> = {};
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
// Default for a column we have not seen yet: honour the seed in custom mode, otherwise grant access initialState[column.value] = true;
const defaultRead = useCallback(
(key: string) => (initialReadKeys ? initialReadKeys.has(key) : true),
[initialReadKeys],
);
const defaultWrite = useCallback(
(key: string) => (initialWriteKeys ? initialWriteKeys.has(key) : true),
[initialWriteKeys],
);
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>({});
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>({});
// Custom fields load asynchronously, so reconcile the switch maps whenever the column list grows.
// Newly seen columns are seeded from the initial values (or default to on for a fresh link).
useEffect(() => {
setReadSwitches((prev) => {
const next = { ...prev };
for (const column of allColumns) {
if (!(column.value in next)) next[column.value] = defaultRead(column.value);
}
return next;
}); });
setWriteSwitches((prev) => { return initialState;
const next = { ...prev }; });
for (const column of allColumns) {
if (!(column.value in next)) next[column.value] = defaultWrite(column.value);
}
return next;
});
}, [allColumns, defaultRead, defaultWrite]);
const isReadOn = (key: string) => readSwitches[key] ?? defaultRead(key); const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>(() => {
const isWriteOn = (key: string) => writeSwitches[key] ?? defaultWrite(key); const initialState: Record<string, boolean> = {};
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
initialState[column.value] = true;
});
return initialState;
});
const handleReadModeChange = (value: AccessMode) => { const handleReadModeChange = (value: AccessMode) => {
setReadPermissions(value); setReadPermissions(value);
setReadSwitches((prevReadSwitches) => {
const updatedReadSwitches = { ...prevReadSwitches };
Object.keys(updatedReadSwitches).forEach((key) => {
updatedReadSwitches[key] = true;
});
return updatedReadSwitches;
});
}; };
const handleWriteModeChange = (value: AccessMode) => { const handleWriteModeChange = (value: AccessMode) => {
setWritePermissions(value);
// Full write implies full read
if (value === 'full') { if (value === 'full') {
setReadPermissions('full'); setReadPermissions('full');
} }
setWritePermissions(value);
setReadSwitches((prevReadSwitches) => {
const updatedReadSwitches = { ...prevReadSwitches };
setWriteSwitches((prevWriteSwitches) => {
const updatedWriteSwitches = { ...prevWriteSwitches };
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
updatedReadSwitches[column.value] = true;
updatedWriteSwitches[column.value] = true;
});
return updatedWriteSwitches;
});
return updatedReadSwitches;
});
}; };
const handleReadSwitch = (key: string, value: boolean) => { const handleSwitchChange = (key: string, type: 'read' | 'write', value: boolean) => {
setReadSwitches((prev) => ({ ...prev, [key]: value })); if (type === 'read') {
// A column the recipient cannot read cannot be written either setReadSwitches((prevReadSwitches) => {
if (!value) { const updatedReadSwitches = { ...prevReadSwitches, [key]: value };
setWriteSwitches((prev) => ({ ...prev, [key]: false })); return updatedReadSwitches;
});
} else {
setWriteSwitches((prevWriteSwitches) => {
const updatedWriteSwitches = { ...prevWriteSwitches, [key]: value };
return updatedWriteSwitches;
});
} }
}; };
const handleWriteSwitch = (key: string, value: boolean) => { const getReadPermissions = () => {
setWriteSwitches((prev) => ({ ...prev, [key]: value }));
// Granting write access requires read access
if (value) {
setReadSwitches((prev) => ({ ...prev, [key]: true }));
}
};
const resolvedRead = useMemo(() => {
if (readPermissions === 'full' || writePermissions === 'full') { if (readPermissions === 'full' || writePermissions === 'full') {
return 'full'; return 'full';
} }
const keys = allColumns.filter((column) => isReadOn(column.value)).map((column) => column.value);
return keys.length ? keys.join(',') : '-';
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readPermissions, writePermissions, readSwitches, allColumns]);
const resolvedWrite = useMemo(() => { return Object.entries(readSwitches)
.filter(([_, value]) => value)
.map(([key]) => key)
.join(',');
};
const getWritePermissions = () => {
if (writePermissions === 'full') { if (writePermissions === 'full') {
return 'full'; return 'full';
} }
const keys = allColumns.filter((column) => isWriteOn(column.value)).map((column) => column.value);
return keys.length ? keys.join(',') : '-';
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writePermissions, writeSwitches, allColumns]);
// Notify the parent of the resolved permissions. onChange is expected to be stable. return Object.entries(writeSwitches)
useEffect(() => { .filter(([_, value]) => value)
onChange({ read: resolvedRead, write: resolvedWrite }); .map(([key]) => key)
// eslint-disable-next-line react-hooks/exhaustive-deps .join(',');
}, [resolvedRead, resolvedWrite]); };
const noReadAccess = resolvedRead === '-';
return ( return (
<Panel.Indent> <Panel.Indent>
<input name='read' hidden readOnly ref={readRef} value={getReadPermissions() || '-'} />
<input name='write' hidden readOnly ref={writeRef} value={getWritePermissions() || '-'} />
<div> <div>
<Panel.Field title='Access mode' description='Which parts of the data the link gives access to' /> <Panel.Field title='Access mode' description='Which parts of the data will the link give access to' />
<div> <div>
<RadioGroup
value={writePermissions}
onValueChange={handleWriteModeChange}
orientation='horizontal'
items={[
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
{ value: 'custom', label: 'Custom write' },
]}
/>
<RadioGroup <RadioGroup
value={readPermissions} value={readPermissions}
onValueChange={handleReadModeChange} onValueChange={handleReadModeChange}
@@ -155,18 +132,8 @@ export default function CuesheetLinkOptions({ initialRead, initialWrite, onChang
{ value: 'custom', label: 'Custom read' }, { value: 'custom', label: 'Custom read' },
]} ]}
/> />
<RadioGroup
value={writePermissions}
onValueChange={handleWriteModeChange}
orientation='horizontal'
items={[
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
{ value: 'custom', label: 'Custom write' },
]}
/>
</div> </div>
</div> </div>
{noReadAccess && <Panel.Error>Links must contain at least one readable column.</Panel.Error>}
<div className={style.twoCols}> <div className={style.twoCols}>
<div className={style.grid}> <div className={style.grid}>
<Panel.Description>Ontime columns</Panel.Description> <Panel.Description>Ontime columns</Panel.Description>
@@ -176,14 +143,14 @@ export default function CuesheetLinkOptions({ initialRead, initialWrite, onChang
<Fragment key={column.value}> <Fragment key={column.value}>
<div>{column.label}</div> <div>{column.label}</div>
<Switch <Switch
checked={isReadOn(column.value)} checked={Boolean(readSwitches[column.value])}
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)} onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
disabled={readPermissions === 'full' || writePermissions === 'full'} disabled={readPermissions === 'full' || writePermissions === 'full'}
data-testid={`read-${column.value}`} data-testid={`read-${column.value}`}
/> />
<Switch <Switch
checked={isWriteOn(column.value)} checked={Boolean(writeSwitches[column.value])}
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)} onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
disabled={writePermissions === 'full'} disabled={writePermissions === 'full'}
data-testid={`write-${column.value}`} data-testid={`write-${column.value}`}
/> />
@@ -197,16 +164,16 @@ export default function CuesheetLinkOptions({ initialRead, initialWrite, onChang
<Panel.Description>Write</Panel.Description> <Panel.Description>Write</Panel.Description>
{customFieldColumns.map((column) => ( {customFieldColumns.map((column) => (
<Fragment key={column.value}> <Fragment key={column.value}>
<div>{column.label}</div> {column.label}
<Switch <Switch
checked={isReadOn(column.value)} checked={Boolean(readSwitches[column.value])}
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)} onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
disabled={readPermissions === 'full' || writePermissions === 'full'} disabled={readPermissions === 'full' || writePermissions === 'full'}
data-testid={`read-${column.value}`} data-testid={`read-${column.value}`}
/> />
<Switch <Switch
checked={isWriteOn(column.value)} checked={Boolean(writeSwitches[column.value])}
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)} onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
disabled={writePermissions === 'full'} disabled={writePermissions === 'full'}
data-testid={`write-${column.value}`} data-testid={`write-${column.value}`}
/> />
@@ -28,7 +28,6 @@ export const langDe: TranslationObject = {
'timeline.done': 'Beendet', 'timeline.done': 'Beendet',
'timeline.due': 'fällig', 'timeline.due': 'fällig',
'timeline.followedby': 'Gefolgt von', 'timeline.followedby': 'Gefolgt von',
'timeline.standby': 'Bereitschaft',
'project.title': 'Titel', 'project.title': 'Titel',
'project.description': 'Beschreibung', 'project.description': 'Beschreibung',
'project.info': 'Projektinfo', 'project.info': 'Projektinfo',
@@ -28,7 +28,6 @@ export const langEs: TranslationObject = {
'timeline.done': 'Terminado', 'timeline.done': 'Terminado',
'timeline.due': 'pendiente', 'timeline.due': 'pendiente',
'timeline.followedby': 'Seguido por', 'timeline.followedby': 'Seguido por',
'timeline.standby': 'En espera',
'project.title': 'Título', 'project.title': 'Título',
'project.description': 'Descripción', 'project.description': 'Descripción',
'project.info': 'Información del proyecto', 'project.info': 'Información del proyecto',
@@ -28,7 +28,6 @@ export const langFr: TranslationObject = {
'timeline.done': 'Terminé', 'timeline.done': 'Terminé',
'timeline.due': 'dû', 'timeline.due': 'dû',
'timeline.followedby': 'Suivi de', 'timeline.followedby': 'Suivi de',
'timeline.standby': 'En attente',
'project.title': 'Titre', 'project.title': 'Titre',
'project.description': 'Description', 'project.description': 'Description',
'project.info': 'Informations du projet', 'project.info': 'Informations du projet',
@@ -28,7 +28,6 @@ export const langIt: TranslationObject = {
'timeline.done': 'Terminato', 'timeline.done': 'Terminato',
'timeline.due': 'previsto', 'timeline.due': 'previsto',
'timeline.followedby': 'Seguito da', 'timeline.followedby': 'Seguito da',
'timeline.standby': 'In attesa',
'project.title': 'Titolo', 'project.title': 'Titolo',
'project.description': 'Descrizione', 'project.description': 'Descrizione',
'project.info': 'Informazioni sul progetto', 'project.info': 'Informazioni sul progetto',
@@ -28,7 +28,6 @@ export const langPt: TranslationObject = {
'timeline.done': 'Concluído', 'timeline.done': 'Concluído',
'timeline.due': 'Pendente', 'timeline.due': 'Pendente',
'timeline.followedby': 'Seguido por', 'timeline.followedby': 'Seguido por',
'timeline.standby': 'Em espera',
'project.title': 'Título', 'project.title': 'Título',
'project.description': 'Descrição', 'project.description': 'Descrição',
'project.info': 'Informações do projeto', 'project.info': 'Informações do projeto',
+17 -7
View File
@@ -1,5 +1,4 @@
@use '@/theme/viewerDefs' as *; @use '@/theme/viewerDefs' as *;
@use 'sass:color';
$item-height: 3.5rem; $item-height: 3.5rem;
@@ -119,8 +118,7 @@ $item-height: 3.5rem;
} }
.sub--live { .sub--live {
// we need to dim this if the schedule times are ahead of schedule they will be green and diaper in a full green background background-color: $active-green;
background-color: color.adjust($active-green, $lightness: -10%);
} }
.sub--armed { .sub--armed {
@@ -134,10 +132,6 @@ $item-height: 3.5rem;
.sub__schedule { .sub__schedule {
grid-area: schedule; grid-area: schedule;
}
.sub__schedule-inline {
grid-area: schedule;
padding-top: 0.5rem; padding-top: 0.5rem;
display: flex; display: flex;
@@ -146,6 +140,22 @@ $item-height: 3.5rem;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
} }
.sub__schedule--delayed {
color: $ontime-delay-text;
}
.sub__schedule--strike {
text-decoration: line-through;
}
.sub__schedule--over {
color: $playback-over;
}
.sub__schedule--under {
color: $playback-under;
}
.sub__title { .sub__title {
grid-area: title; grid-area: title;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -71,7 +71,7 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
className={cx(['sub', isSelected && 'sub--selected'])} className={cx(['sub', isSelected && 'sub--selected'])}
> >
<div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} /> <div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} />
<div className='sub__schedule-inline'> <div className='sub__schedule'>
<ClockTime value={event.timeStart} preferredFormat12='h:mm a' preferredFormat24='HH:mm' /> <ClockTime value={event.timeStart} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
<ClockTime value={event.timeEnd} preferredFormat12='h:mm a' preferredFormat24='HH:mm' /> <ClockTime value={event.timeEnd} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
@@ -1,24 +1,33 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { getExpectedStart } from 'ontime-utils'; import { dayInMs } from 'ontime-utils';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5'; import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import ScheduleTime from '../../common/components/schedule-time/ScheduleTime';
import useReport from '../../common/hooks-query/useReport'; import useReport from '../../common/hooks-query/useReport';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useExpectedStartData, usePlayback, useSelectedEventId } from '../../common/hooks/useSocket'; import { useExpectedStartData, usePlayback, useSelectedEventId } from '../../common/hooks/useSocket';
import { getOffsetState } from '../../common/utils/offset';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import FollowButton from '../../features/operator/follow-button/FollowButton'; import FollowButton from '../../features/operator/follow-button/FollowButton';
import ClockTime from '../common/clock-time/ClockTime';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options'; import { useCountdownOptions } from './countdown.options';
import {
CountdownEvent,
extendEventData,
getIsLive,
isOutsideRange,
preferredFormat12,
preferredFormat24,
useSubscriptionDisplayData,
} from './countdown.utils';
import './Countdown.scss'; import './Countdown.scss';
import { getIsLive, useSubscriptionDisplayData } from './countdown.utils';
interface CountdownSubscriptionsProps { interface CountdownSubscriptionsProps {
subscribedEvents: ExtendedEntry<OntimeEvent>[]; subscribedEvents: ExtendedEntry<OntimeEvent>[];
@@ -26,7 +35,6 @@ interface CountdownSubscriptionsProps {
} }
export default function CountdownSubscriptions({ subscribedEvents, goToEditMode }: CountdownSubscriptionsProps) { export default function CountdownSubscriptions({ subscribedEvents, goToEditMode }: CountdownSubscriptionsProps) {
'use memo';
const { mainSource, secondarySource, showExpected } = useCountdownOptions(); const { mainSource, secondarySource, showExpected } = useCountdownOptions();
const playback = usePlayback(); const playback = usePlayback();
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
@@ -93,17 +101,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const secondaryData = getPropertyValue(event, secondarySource); const secondaryData = getPropertyValue(event, secondarySource);
const isLive = getIsLive(event.id, selectedEventId, playback); const isLive = getIsLive(event.id, selectedEventId, playback);
const isArmed = !isLive && event.id === selectedEventId; const isArmed = !isLive && event.id === selectedEventId;
const { totalGap, isLinkedToLoaded } = event; const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const expectedStart = getExpectedStart(event, {
currentDay,
totalGap,
actualStart,
plannedStart,
isLinkedToLoaded,
offset,
mode,
});
const { endedAt } = reportData[event.id] ?? { endedAt: null };
const displayTitle = getPropertyValue(event, mainSource ?? 'title'); const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return ( return (
<div <div
@@ -113,13 +111,8 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
data-testid={event.cue} data-testid={event.cue}
> >
<div className='sub__binder' style={{ '--user-color': event.colour }} /> <div className='sub__binder' style={{ '--user-color': event.colour }} />
<ScheduleTime <ScheduleTime event={countdownEvent} showExpected={showExpected} />
event={event} <SubscriptionStatus event={countdownEvent} />
expectedStart={expectedStart}
showExpected={showExpected}
className='sub__schedule'
/>
<SubscriptionStatus event={event} expectedStart={expectedStart} endedAt={endedAt} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>{displayTitle}</div> <div className={cx(['sub__title', !displayTitle && 'subdued'])}>{displayTitle}</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>} {secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div> </div>
@@ -135,15 +128,73 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
); );
} }
interface SubscriptionStatusProps { type ScheduleTimeProps = {
event: ExtendedEntry<OntimeEvent>; event: CountdownEvent;
endedAt: MaybeNumber; showExpected: boolean;
expectedStart: number; };
//TODO: consider relative mode
export function ScheduleTime(props: ScheduleTimeProps) {
const { event, showExpected } = props;
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
// 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={plannedStart}
preferredFormat12={preferredFormat12}
preferredFormat24={preferredFormat24}
className={plannedStateClass}
/>
{!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>
);
} }
function SubscriptionStatus({ event, endedAt, expectedStart }: SubscriptionStatusProps) { interface SubscriptionStatusProps {
'use memo'; event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event, endedAt, expectedStart); }
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
return ( return (
<> <>
@@ -7,6 +7,27 @@
gap: 5rem; gap: 5rem;
} }
.event__title {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: $view-card-padding;
border-radius: $element-border-radius;
font-size: clamp(40px, 4.5vw, 80px);
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 { .event__status {
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
font-size: $header-font-size; font-size: $header-font-size;
@@ -3,7 +3,6 @@ import { getExpectedStart } from 'ontime-utils';
import { IoPencil } from 'react-icons/io5'; import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import TitleCard from '../../common/components/title-card/TitleCard';
import useReport from '../../common/hooks-query/useReport'; import useReport from '../../common/hooks-query/useReport';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useExpectedStartData } from '../../common/hooks/useSocket'; import { useExpectedStartData } from '../../common/hooks/useSocket';
@@ -12,9 +11,10 @@ import { cx } from '../../common/utils/styleUtils';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options'; import { useCountdownOptions } from './countdown.options';
import { useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss'; import './SingleEventCountdown.scss';
import { useSubscriptionDisplayData } from './countdown.utils';
interface SingleEventCountdownProps { interface SingleEventCountdownProps {
subscribedEvent: ExtendedEntry<OntimeEvent>; subscribedEvent: ExtendedEntry<OntimeEvent>;
@@ -39,22 +39,19 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
}); });
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null }; const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
const title = getPropertyValue(subscribedEvent, mainSource ?? 'title'); const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
const secondaryData = getPropertyValue(subscribedEvent, secondarySource); const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
return ( return (
<div className='single-container' data-testid='countdown-event'> <div className='single-container' data-testid='countdown-event'>
<SubscriptionStatus event={subscribedEvent} expectedStart={expectedStart} endedAt={endedAt} /> <SubscriptionStatus event={countdownEvent} />
<TitleCard <div className='event__title' style={{ borderColor: countdownEvent.colour }}>
title={title} <ScheduleTime event={countdownEvent} showExpected={showExpected} />
secondary={secondaryData} {title}
colour={subscribedEvent.colour} {secondaryData && <div className='secondary'>{secondaryData}</div>}
textAlign='center' </div>
size='lg'
event={subscribedEvent}
showExpected={showExpected}
expectedStart={expectedStart}
/>
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}> <div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoPencil /> Edit <IoPencil /> Edit
@@ -65,13 +62,11 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
} }
interface SubscriptionStatusProps { interface SubscriptionStatusProps {
event: ExtendedEntry<OntimeEvent>; event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
endedAt: MaybeNumber;
expectedStart: number;
} }
function SubscriptionStatus({ event, endedAt, expectedStart }: SubscriptionStatusProps) { function SubscriptionStatus({ event }: SubscriptionStatusProps) {
const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event, endedAt, expectedStart); const { status, statusDisplay, timeDisplay } = useSubscriptionDisplayData(event);
return ( return (
<> <>
@@ -1,5 +1,5 @@
import { EntryId, MaybeNumber, OntimeEntry, OntimeEvent, Playback } from 'ontime-types'; import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
import { MILLIS_PER_MINUTE, millisToString, removeLeadingZero } from 'ontime-utils'; import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket'; import { useCountdownSocket } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
@@ -13,6 +13,9 @@ export function sanitiseTitle(title: string | null) {
return title ?? '{no title}'; return title ?? '{no title}';
} }
export const preferredFormat12 = 'h:mm a';
export const preferredFormat24 = 'HH:mm';
/** /**
* Whether the current event is live * Whether the current event is live
*/ */
@@ -37,9 +40,7 @@ export const timerProgress: TimerMessage = {
* Handles events in different days but disregards whether an event has actually played * Handles events in different days but disregards whether an event has actually played
*/ */
export function useSubscriptionDisplayData( export function useSubscriptionDisplayData(
subscribedEvent: ExtendedEntry<OntimeEvent>, subscribedEvent: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number },
endedAt: MaybeNumber,
expectedStart: number,
): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } { ): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
const { playback, current, clock } = useCountdownSocket(); const { playback, current, clock } = useCountdownSocket();
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
@@ -83,11 +84,11 @@ export function useSubscriptionDisplayData(
return { return {
status: 'done', status: 'done',
statusDisplay: getLocalizedString(timerProgress['done']), statusDisplay: getLocalizedString(timerProgress['done']),
timeDisplay: formatTime(endedAt, { format12: 'h:mm a', format24: 'HH:mm' }), timeDisplay: formatTime(subscribedEvent.endedAt, { format12: preferredFormat12, format24: preferredFormat24 }),
}; };
} }
if (expectedStart - clock <= 0) { if (subscribedEvent.expectedStart - clock <= 0) {
return { return {
status: 'due', status: 'due',
statusDisplay: getLocalizedString(timerProgress['future']), // We use future here on purpose for the look of it statusDisplay: getLocalizedString(timerProgress['future']), // We use future here on purpose for the look of it
@@ -98,7 +99,7 @@ export function useSubscriptionDisplayData(
return { return {
status: 'future', status: 'future',
statusDisplay: getLocalizedString(timerProgress['future']), statusDisplay: getLocalizedString(timerProgress['future']),
timeDisplay: bigDuration(expectedStart - clock), timeDisplay: bigDuration(subscribedEvent.expectedStart - clock),
}; };
} }
@@ -162,3 +163,32 @@ export function isLinkedToLoadedEvent(events: OntimeEvent[], loadedId: EntryId |
return true; 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 };
}
@@ -20,7 +20,6 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-read-only', alias: 'cuesheet-read-only',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: '-', write: '-',
@@ -42,7 +41,6 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-flag', alias: 'cuesheet-flag',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: 'flag', write: 'flag',
@@ -64,7 +62,6 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-default', alias: 'cuesheet-default',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
}; };
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit); const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
@@ -79,7 +76,6 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-granular', alias: 'cuesheet-granular',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'cue,title', read: 'cue,title',
write: 'title', write: 'title',
@@ -1,8 +1,6 @@
@use '@/theme/viewerDefs' as *; @use '@/theme/viewerDefs' as *;
$timeline-height: 1rem; $timeline-height: 1rem;
$group-band-height: 0.5rem;
$group-band-gap: 0.25rem;
$timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color)); $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color));
.timelineContainer { .timelineContainer {
@@ -19,21 +17,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
position: relative; position: relative;
font-weight: 600; font-weight: 600;
height: 100%; height: 100%;
// reserve the top region (progress bar + gap + group band) with the card background box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color);
box-shadow: inset 0 calc(#{$timeline-height} + #{$group-band-gap} + #{$group-band-height}) 0 0
var(--card-background-color-override, $viewer-card-bg-color);
}
.groupBand {
height: $group-band-height;
margin-top: $group-band-gap;
width: 100%;
background: var(--group-colour, transparent);
display: flex;
align-items: center;
position: relative;
z-index: 2;
overflow: visible;
} }
.column { .column {
@@ -108,6 +92,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
border-top: 2px solid var(--background-color-override, $viewer-background-color); border-top: 2px solid var(--background-color-override, $viewer-background-color);
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300); box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
text-transform: capitalize;
white-space: normal; white-space: normal;
&[data-status='done'] { &[data-status='done'] {
@@ -134,7 +119,6 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
.status { .status {
width: fit-content; width: fit-content;
text-transform: uppercase;
} }
.status.due { .status.due {
+4 -1
View File
@@ -55,6 +55,10 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize); return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize);
}, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]); }, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]);
if (totalDuration === 0) {
return null;
}
// Pre-calculate event statuses // Pre-calculate event statuses
let currentStatus: ProgressStatus = selectedEventId ? 'done' : 'future'; let currentStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
const statusMap: Record<string, ProgressStatus> = {}; const statusMap: Record<string, ProgressStatus> = {};
@@ -101,7 +105,6 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
title={displayTitle} title={displayTitle}
cue={event.cue} cue={event.cue}
width={position.width} width={position.width}
groupColour={event.groupColour}
/> />
); );
})} })}
@@ -26,7 +26,6 @@ interface TimelineEntryProps {
title: string; title: string;
width: number; width: number;
cue: string; cue: string;
groupColour?: string;
ref?: RefObject<HTMLDivElement | null>; ref?: RefObject<HTMLDivElement | null>;
} }
@@ -49,7 +48,6 @@ export function TimelineEntry({
title, title,
width, width,
cue, cue,
groupColour,
ref, ref,
}: TimelineEntryProps) { }: TimelineEntryProps) {
const formattedStartTime = formatTime(start, formatOptions); const formattedStartTime = formatTime(start, formatOptions);
@@ -69,7 +67,6 @@ export function TimelineEntry({
{ {
'--color': colour, '--color': colour,
'--lighter': lighterColour ?? '', '--lighter': lighterColour ?? '',
'--group-colour': groupColour ?? 'transparent',
left: `${left}px`, left: `${left}px`,
width: `${width}px`, width: `${width}px`,
} as CSSProperties } as CSSProperties
@@ -77,7 +74,6 @@ export function TimelineEntry({
data-testid={cue} data-testid={cue}
> >
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />} {status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
<div className={style.groupBand} />
<div <div
className={cx([style.content, width < 20 && style.hide, !hasLink && style.separeLeft])} className={cx([style.content, width < 20 && style.hide, !hasLink && style.separeLeft])}
data-status={status} data-status={status}
@@ -157,8 +153,6 @@ function TimelineEntryStatus({
statusText = getLocalizedString('timeline.live'); statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') { } else if (statusText === 'pending') {
statusText = getLocalizedString('timeline.due'); statusText = getLocalizedString('timeline.due');
} else if (statusText === 'done') {
statusText = getLocalizedString('timeline.done');
} }
const isDue = status === 'future' && timeToStart <= 0; const isDue = status === 'future' && timeToStart <= 0;
@@ -38,8 +38,6 @@ export default function TimelinePageLoader() {
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) { function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const { mainSource, timeformat } = useTimelineOptions(); const { mainSource, timeformat } = useTimelineOptions();
const { getLocalizedString } = useTranslation();
// holds copy of the rundown with only relevant events // holds copy of the rundown with only relevant events
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId); const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
@@ -52,8 +50,6 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]); const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]);
const hasContent = totalDuration > 0;
return ( return (
<div className='timeline' data-testid='timeline-view'> <div className='timeline' data-testid='timeline-view'>
<ViewParamsEditor target={OntimeView.Timeline} viewOptions={progressOptions} /> <ViewParamsEditor target={OntimeView.Timeline} viewOptions={progressOptions} />
@@ -65,16 +61,12 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} /> <TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
{hasContent ? ( <Timeline
<Timeline firstStart={firstStart}
firstStart={firstStart} rundown={scopedRundown}
rundown={scopedRundown} selectedEventId={selectedEventId}
selectedEventId={selectedEventId} totalDuration={totalDuration}
totalDuration={totalDuration} />
/>
) : (
<EmptyPage text={getLocalizedString('common.no_data')} />
)}
</div> </div>
); );
} }
@@ -20,7 +20,7 @@ export default function TimelineSections({ now, next, followedBy, mainSource }:
const state = useExpectedStartData(); const state = useExpectedStartData();
// gather card data // gather card data
const titleNow = now ? (getPropertyValue(now, mainSource ?? 'title') ?? '-') : getLocalizedString('timeline.standby'); const titleNow = getPropertyValue(now, mainSource ?? 'title') ?? '-';
const dueText = getLocalizedString('timeline.due').toUpperCase(); const dueText = getLocalizedString('timeline.due').toUpperCase();
const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-'; const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-';
const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-'; const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-';
@@ -96,77 +96,63 @@ export function useScopedRundown(
): ScopedRundownData { ): ScopedRundownData {
const { hidePast } = useTimelineOptions(); const { hidePast } = useTimelineOptions();
const data = useMemo( const data = useMemo(() => {
() => computeScopedRundown(rundown, selectedEventId, hidePast), if (rundown.length === 0) {
[hidePast, rundown, selectedEventId], return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
); }
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
let totalDuration = 0;
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i];
// we only deal with playableEvents
if (isOntimeEvent(currentEntry) && isPlayableEvent(currentEntry)) {
if (currentEntry.id === selectedEventId) {
selectedIndex = i;
}
// maybe filter past
if (hidePast && i < selectedIndex) {
continue;
}
// add to scopedRundown
scopedRundown.push(currentEntry);
/**
* Derive timers
* This logic is partially from rundownCache.generate
* With the addition of deriving the current day offset
*/
if (firstStart === null) {
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
}
}
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
}, [hidePast, rundown, selectedEventId]);
return data; return data;
} }
/**
* Pure computation behind useScopedRundown: filters to playable events and derives timers
*/
export function computeScopedRundown(
rundown: ExtendedEntry<OntimeEntry>[],
selectedEventId: MaybeString,
hidePast: boolean,
): ScopedRundownData {
if (rundown.length === 0) {
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
}
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart: number | null = null;
let totalDuration = 0;
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
for (let i = 0; i < rundown.length; i++) {
// we only deal with playableEvents
const currentEntry = rundown[i];
if (!isOntimeEvent(currentEntry) || !isPlayableEvent(currentEntry)) {
continue;
}
if (currentEntry.id === selectedEventId) {
selectedIndex = i;
}
// maybe filter past
if (hidePast && i < selectedIndex) {
continue;
}
// add to scopedRundown
scopedRundown.push(currentEntry);
/**
* Derive timers
* This logic is partially from rundownCache.generate
* With the addition of deriving the current day offset
*/
if (firstStart === null) {
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
}
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
}
type UpcomingEvents = { type UpcomingEvents = {
now: ExtendedEntry<OntimeEvent> | null; now: ExtendedEntry<OntimeEvent> | null;
next: ExtendedEntry<OntimeEvent> | null; next: ExtendedEntry<OntimeEvent> | null;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "4.10.0", "version": "4.9.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/resolver", "name": "@getontime/resolver",
"version": "4.10.0", "version": "4.9.0",
"type": "module", "type": "module",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts", "types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "4.10.0", "version": "4.9.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -110,13 +110,14 @@ type old_URLPreset = {
/** /**
* migrates a url presets from v3 to v4 * migrates a url presets from v3 to v4
* - pathAndParams split into a target and search * - pathAndParams split into a target and search
*
*/ */
export function migrateURLPresets(jsonData: object): URLPreset[] | undefined { export function migrateURLPresets(jsonData: object): URLPreset[] | undefined {
if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) { if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) {
const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset; const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset;
const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => { const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => {
const [target, search] = pathAndParams.split('?'); const [target, search] = pathAndParams.split('?');
return { enabled, alias, target, search, displayInNav: false, options: {} } as URLPreset; return { enabled, alias, target, search, options: {} } as URLPreset;
}); });
return newURLPreset; return newURLPreset;
} }
@@ -208,7 +208,6 @@ describe('v3 to v4', () => {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', 'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
options: {}, options: {},
}, },
{ {
@@ -217,7 +216,6 @@ describe('v3 to v4', () => {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', 'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
options: {}, options: {},
}, },
]; ];
@@ -704,14 +704,11 @@ export async function renameRundown(id: string, title: string) {
await dataProvider.setRundown(id, { ...rundown, title }); await dataProvider.setRundown(id, { ...rundown, title });
/** /**
* If we are modifying the loaded rundown we re-init it * If loaded we re-init the rundown
* This is likely over-kill but the simplest way to ensure state consistency * This is likely over-kill but the simplest way to ensure state consistency
*/ */
if (isCurrentRundown(id)) { if (isCurrentRundown(id)) {
const rundown = dataProvider.getRundown(id); await initRundown(dataProvider.getRundown(id), dataProvider.getCustomFields());
const customField = dataProvider.getCustomFields();
// init rundown does its own refetch
await initRundown(rundown, customField);
} else { } else {
setImmediate(() => { setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns); sendRefetch(RefetchKey.ProjectRundowns);
@@ -12,9 +12,7 @@ describe('parseUrlPresets()', () => {
it('parses data, skipping invalid results', () => { it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn(); const errorEmitter = vi.fn();
const urlPresets = [ const urlPresets = [{ enabled: true, alias: 'alias', target: 'timer', search: 'ss' }] as URLPreset[];
{ enabled: true, alias: 'alias', target: 'timer', search: 'ss', displayInNav: false },
] as URLPreset[];
const result = parseUrlPresets({ urlPresets }, errorEmitter); const result = parseUrlPresets({ urlPresets }, errorEmitter);
expect(result.length).toEqual(1); expect(result.length).toEqual(1);
expect(result.at(0)).toMatchObject({ expect(result.at(0)).toMatchObject({
@@ -38,7 +36,6 @@ describe('parseUrlPresets()', () => {
alias: 'testalias', alias: 'testalias',
target: 'timer', target: 'timer',
search: 'testpathAndParams', search: 'testpathAndParams',
displayInNav: false,
}, },
], ],
} as unknown as DatabaseModel; } as unknown as DatabaseModel;
@@ -26,7 +26,6 @@ export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorE
alias: preset.alias, alias: preset.alias,
target: preset.target, target: preset.target,
search: preset.search ?? '', search: preset.search ?? '',
displayInNav: preset.displayInNav ?? false,
options: preset?.options, options: preset?.options,
}; };
newPresets.push(newPreset); newPresets.push(newPreset);
@@ -21,7 +21,6 @@ router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset
alias: req.body.alias, alias: req.body.alias,
target: req.body.target, target: req.body.target,
search: req.body.search, search: req.body.search,
displayInNav: req.body.displayInNav,
options: req.body.options, options: req.body.options,
}; };
@@ -56,7 +55,6 @@ router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<U
alias: req.body.alias, alias: req.body.alias,
target: req.body.target, target: req.body.target,
search: req.body.search, search: req.body.search,
displayInNav: req.body.displayInNav,
options: req.body.options ?? existingPreset.options, options: req.body.options ?? existingPreset.options,
}; };
@@ -12,7 +12,6 @@ export const validateNewPreset = [
body('alias').isString().trim().notEmpty(), body('alias').isString().trim().notEmpty(),
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)), body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
body('search').isString().trim(), body('search').isString().trim(),
body('displayInNav').isBoolean(),
// options are currently only provided for cuesheet presets // options are currently only provided for cuesheet presets
body('options').optional().isObject(), body('options').optional().isObject(),
@@ -28,7 +27,6 @@ export const validateUpdatePreset = [
body('alias').isString().trim().notEmpty(), body('alias').isString().trim().notEmpty(),
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)), body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
body('search').isString().trim(), body('search').isString().trim(),
body('displayInNav').isBoolean(),
// options are currently only provided for cuesheet presets // options are currently only provided for cuesheet presets
body('options').optional().isObject(), body('options').optional().isObject(),
+65
View File
@@ -0,0 +1,65 @@
# Ontime MCP Server
Ontime exposes an MCP server over Streamable HTTP.
## Endpoint
Default local URL:
```text
http://localhost:4001/mcp
```
If Ontime is running on another host or port, replace `localhost:4001` with that address.
If `ROUTER_PREFIX` is configured, include it before `/mcp`, for example:
```text
http://localhost:4001/stage/mcp
```
The MCP route is stateless. Clients should send MCP requests with `POST`; `GET` and
`DELETE` are not used.
## Authentication
If Ontime has no session password configured, no MCP authentication is required.
If a session password is configured, authenticate with the hashed Ontime token.
The settings UI generates an endpoint URL with the token in the query string:
```text
http://localhost:4001/mcp?token=<hashed-token>
```
If your MCP client supports request headers, you can send the same token as a
bearer token instead:
```http
Authorization: Bearer <hashed-token>
```
This is the same token used in authenticated Ontime share URLs as the `token` query
parameter. The raw session password is not accepted as the bearer token.
## Client Configuration
Use a Streamable HTTP MCP client and point it at the MCP endpoint:
```json
{
"mcpServers": {
"ontime": {
"url": "http://localhost:4001/mcp?token=<hashed-token>"
}
}
}
```
Omit the token when Ontime is not password protected. If you prefer headers, use
`"url": "http://localhost:4001/mcp"` and add an `Authorization` header with the
same bearer token.
## Quick Check
The server should respond to MCP initialization requests at `/mcp`. A plain browser
`GET` request will return `405 Method not allowed`, which is expected.
-43
View File
@@ -33,17 +33,6 @@ export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [
}, },
], ],
}, },
{
name: 'manage_custom_fields',
description: 'List, create, rename, recolour, or delete project-level custom field definitions',
arguments: [
{
name: 'instruction',
description: 'What to do, e.g. "add a Speaker field" or "delete the Camera field"',
required: true,
},
],
},
]; ];
function userPrompt(description: string, text: string): GetPromptResult { function userPrompt(description: string, text: string): GetPromptResult {
@@ -90,7 +79,6 @@ Count to end (countToEnd):
- Do not set \`countToEnd: true\` unless the user explicitly asks for "count to end", "count to scheduled end", or confirms after you explain this behaviour. - Do not set \`countToEnd: true\` unless the user explicitly asks for "count to end", "count to scheduled end", or confirms after you explain this behaviour.
End action (endAction): End action (endAction):
- Controls what happens when the event's timer reaches zero and can have surprising effect. Use only if the user explicitly asks for it.
- \`none\` (default): stops at end; operator must manually start the next event. - \`none\` (default): stops at end; operator must manually start the next event.
- \`load-next\`: pre-arms the next event; operator triggers start. Use when a human handoff is needed. - \`load-next\`: pre-arms the next event; operator triggers start. Use when a human handoff is needed.
- \`play-next\`: automatically starts the next event. Use for seamless back-to-back segments with no gap. - \`play-next\`: automatically starts the next event. Use for seamless back-to-back segments with no gap.
@@ -215,36 +203,5 @@ Efficiency tip: plan moves in the direction of the target position to minimise r
); );
} }
if (name === 'manage_custom_fields') {
return userPrompt(
'List, create, rename, recolour, or delete custom field definitions',
`Manage the project-level custom field definitions for this Ontime project: "${args.instruction}"
Custom fields are project-scoped. They appear as columns in the cuesheet and can be set on any event, milestone, or group. Changes apply to all rundowns in the project.
Steps:
1. Call ontime_get_custom_fields to see what fields already exist. This is mandatory before creating — to avoid duplicates such as "Cam", "camera", and "Cameras".
2. Carry out the instruction using the tools below. Confirm destructive changes (rename, delete) with the user before calling.
Creating a field:
- Call ontime_create_custom_field with { label, type, colour }.
- label: human-readable name (alphanumeric with spaces). The key is auto-derived: spaces → underscores (e.g. "Camera Angle" → "Camera_Angle"). Confirm the derived key with the user before creating.
- type: "text" for short string values, "image" for image URLs. Cannot be changed after creation.
- colour: hex colour (#RRGGBB) used to visually identify this column in the cuesheet. Ask the user what colour to use if not specified.
- Returns: { key, customFields } — use the returned key when setting values on entries.
Renaming or recolouring a field:
- Call ontime_update_custom_field with { key, label?, colour? }.
- Warning: changing the label changes the derived key and cascades to all entry references in all rundowns. Confirm with the user before renaming.
- The field type cannot be changed.
Deleting a field:
- Call ontime_delete_custom_field with { key }.
- Warning: this permanently removes the field definition and its values from every entry in all rundowns. Confirm with the user before deleting.
After any mutation, call ontime_get_custom_fields again to confirm the result and show the user the updated field list with their keys.`,
);
}
throw new Error(`Unknown prompt: ${name}`); throw new Error(`Unknown prompt: ${name}`);
} }
+2 -15
View File
@@ -17,20 +17,9 @@ mcpRouter.post('/', async (req, res) => {
sessionIdGenerator: undefined, // stateless: no session tracking sessionIdGenerator: undefined, // stateless: no session tracking
enableJsonResponse: true, enableJsonResponse: true,
}); });
const server = createMcpServer(); res.on('close', () => transport.close());
let cleanedUp = false;
const cleanup = async () => {
if (cleanedUp) return;
cleanedUp = true;
transport.close();
await server.close?.();
};
res.on('close', () => void cleanup());
res.on('finish', () => void cleanup());
try { try {
const server = createMcpServer();
await server.connect(transport); await server.connect(transport);
await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body); await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body);
} catch (error) { } catch (error) {
@@ -39,8 +28,6 @@ mcpRouter.post('/', async (req, res) => {
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message }, id: req.body?.id ?? null }); res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message }, id: req.body?.id ?? null });
} }
} finally {
await cleanup();
} }
}); });
+5 -14
View File
@@ -177,15 +177,8 @@ Examples:
## Custom fields ## Custom fields
Custom fields are project-scoped definitions. Get definitions at \`ontime://project/custom-fields\` or with \`ontime_get_custom_fields\`. Custom fields are project-scoped definitions. Get definitions at \`ontime://project/custom-fields\` or with \`ontime_get_custom_fields\`.
Events, milestones and groups store values at \`entry.custom[fieldKey]\`. Events, milestones and groups store values at \`entry.custom[fieldKey]\`.
Only use existing field keys when setting \`custom\` values. Adding, renaming, or deleting custom field definitions is a separate project-level operation.
Managing field definitions: When the user wants to assign custom values, show the existing custom field list first if there is any ambiguity, so you do not create duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`.
- Read: \`ontime_get_custom_fields\` — returns \`{ [key]: { label, type, colour } }\`
- Create: \`ontime_create_custom_field { label, type, colour }\` — key is auto-derived from label (spaces → underscores). Confirm the derived key before creating. Returns \`{ key, customFields }\`.
- Rename/recolour: \`ontime_update_custom_field { key, label?, colour? }\` — renaming the label changes the derived key and cascades to all entry references across all rundowns. Type cannot be changed.
- Delete: \`ontime_delete_custom_field { key }\` — removes the field definition and its values from every entry in all rundowns. Destructive, confirm first.
When setting \`custom\` values on entries, only use existing field keys. If a key does not exist, create it with \`ontime_create_custom_field\` first.
Show the existing field list before creating to avoid duplicates such as \`Cam\`, \`camera\`, and \`Cameras\`.
## Targeting rundowns ## Targeting rundowns
Entry read/write tools accept an optional \`rundownId\`. Entry read/write tools accept an optional \`rundownId\`.
@@ -232,10 +225,8 @@ Main site: https://docs.getontime.no
- URL presets / shared views: https://docs.getontime.no/features/url-presets/ - URL presets / shared views: https://docs.getontime.no/features/url-presets/
- HTTP Integration: https://docs.getontime.no/api/http/ - HTTP Integration: https://docs.getontime.no/api/http/
- OSC Integration: https://docs.getontime.no/api/osc/ - OSC Integration: https://docs.getontime.no/api/osc/
- Make custom views: https://docs.getontime.no/features/custom-views/
## Helpful references ## API reference
- Ontime Cloud: https://docs.getontime.no/ontime-cloud/ - REST API overview: https://docs.getontime.no/api/
- Connect to companion module: https://docs.getontime.no/additional-notes/companion-module/ - WebSocket events: https://docs.getontime.no/api/websocket/
- Import data from spreadsheets: https://docs.getontime.no/features/import-spreadsheet/
`; `;
+2 -31
View File
@@ -17,10 +17,7 @@ import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '
import { import {
addEntry, addEntry,
batchEditEntries, batchEditEntries,
createCustomField,
deleteCustomField,
deleteEntries, deleteEntries,
editCustomField,
editEntry, editEntry,
groupEntries, groupEntries,
reorderEntry, reorderEntry,
@@ -106,16 +103,8 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
} }
if (unknownKeys.size > 0) { if (unknownKeys.size > 0) {
const missing = [...unknownKeys].join(', '); const keys = [...unknownKeys].join(', ');
const available = Object.keys(customFields); throw new Error(`Unknown custom field key(s): ${keys}. Call ontime_get_custom_fields to list available keys.`);
const hint =
available.length > 0 ? `Available keys: ${available.join(', ')}.` : 'No custom fields are defined yet.';
throw new Error(
`Unknown custom field key(s): ${missing}. ${hint} ` +
`Call ontime_create_custom_field with { label, type, colour } to create a missing field — ` +
`the key is auto-derived from the label (spaces → underscores, e.g. label "Camera" → key "Camera"). ` +
`Call ontime_get_custom_fields to list existing keys.`,
);
} }
} }
@@ -331,21 +320,3 @@ export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids:
const rundown = await batchEditEntries(rundownId, args.ids, args.data); const rundown = await batchEditEntries(rundownId, args.ids, args.data);
return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order }; return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order };
} }
export async function createCustomFieldForMcp(args: { label: string; type: 'text' | 'image'; colour: string }) {
const updated = await createCustomField({ label: args.label, type: args.type, colour: args.colour });
const key = Object.keys(updated).find((k) => updated[k].label === args.label) ?? args.label;
return { key, customFields: updated };
}
export async function updateCustomFieldForMcp(args: { key: string; label?: string; colour?: string }) {
const projectRundowns = getDataProvider().getProjectRundowns();
const updated = await editCustomField(args.key, { label: args.label, colour: args.colour }, projectRundowns);
return { customFields: updated };
}
export async function deleteCustomFieldForMcp(args: { key: string }) {
const projectRundowns = getDataProvider().getProjectRundowns();
const updated = await deleteCustomField(args.key, projectRundowns);
return { customFields: updated };
}
+1 -74
View File
@@ -25,9 +25,7 @@ import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js';
import { import {
batchCreateEntriesForMcp, batchCreateEntriesForMcp,
batchUpdateEntriesForMcp, batchUpdateEntriesForMcp,
createCustomFieldForMcp,
createEntryForMcp, createEntryForMcp,
deleteCustomFieldForMcp,
deleteEntriesForMcp, deleteEntriesForMcp,
findEntry, findEntry,
getRundownById, getRundownById,
@@ -35,7 +33,6 @@ import {
reorderEntryForMcp, reorderEntryForMcp,
toRundownList, toRundownList,
ungroupEntryForMcp, ungroupEntryForMcp,
updateCustomFieldForMcp,
updateEntryForMcp, updateEntryForMcp,
type BatchCreateEntryArgs, type BatchCreateEntryArgs,
type CreateEntryArgs, type CreateEntryArgs,
@@ -374,64 +371,6 @@ export const TOOL_DEFINITIONS = [
inputSchema: { type: 'object', properties: {} }, inputSchema: { type: 'object', properties: {} },
annotations: READ, annotations: READ,
}, },
{
name: 'ontime_create_custom_field',
description:
'Create a new project-level custom field definition. Custom fields add typed columns to every entry in all rundowns. The key is auto-derived from the label (spaces → underscores, e.g. "Camera Angle" → "Camera_Angle"). After creation, use the key in entry.custom when creating or updating entries.',
inputSchema: {
type: 'object',
required: ['label', 'type', 'colour'],
properties: {
label: {
type: 'string',
description:
'Human-readable label (alphanumeric with spaces, e.g. "Camera"). Determines the key — ask the user to confirm before creating to avoid duplicates like "Cam", "camera", "Cameras".',
},
type: {
type: 'string',
enum: ['text', 'image'],
description:
'Field type — cannot be changed after creation. Use "text" for short text values; "image" for image URLs.',
},
colour: {
type: 'string',
description: 'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet.',
},
},
},
annotations: WRITE,
},
{
name: 'ontime_update_custom_field',
description:
'Update a custom field label or colour. Changing the label renames the derived key (spaces → underscores) and updates all entry references across all rundowns. Field type cannot be changed.',
inputSchema: {
type: 'object',
required: ['key'],
properties: {
key: { type: 'string', description: 'Current field key (from ontime_get_custom_fields)' },
label: {
type: 'string',
description: 'New human-readable label (optional). Changes the derived key and cascades to all entries.',
},
colour: { type: 'string', description: 'New hex colour (#RRGGBB) (optional)' },
},
},
annotations: WRITE_IDEM,
},
{
name: 'ontime_delete_custom_field',
description:
'Delete a custom field definition and remove its values from all entries in all rundowns. Destructive and cannot be undone — confirm with the user before calling.',
inputSchema: {
type: 'object',
required: ['key'],
properties: {
key: { type: 'string', description: 'Field key to delete (from ontime_get_custom_fields)' },
},
},
annotations: WRITE_DESTRUCTIVE,
},
// --- Project file management --- // --- Project file management ---
{ {
name: 'ontime_list_projects', name: 'ontime_list_projects',
@@ -529,7 +468,7 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
const serialised = text(data); const serialised = text(data);
if (serialised.length > CHARACTER_LIMIT) { if (serialised.length > CHARACTER_LIMIT) {
return ok({ return ok({
warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_entry.`, warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_entry. Entry IDs in order: ${rundown.order.join(', ')}`,
truncated: true, truncated: true,
rundownId: rundown.id, rundownId: rundown.id,
order: rundown.order, order: rundown.order,
@@ -632,18 +571,6 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
ontime_get_custom_fields: async () => ok(getProjectCustomFields()), ontime_get_custom_fields: async () => ok(getProjectCustomFields()),
ontime_create_custom_field: async (args) => {
return ok(await createCustomFieldForMcp(args as { label: string; type: 'text' | 'image'; colour: string }));
},
ontime_update_custom_field: async (args) => {
return ok(await updateCustomFieldForMcp(args as { key: string; label?: string; colour?: string }));
},
ontime_delete_custom_field: async (args) => {
return ok(await deleteCustomFieldForMcp(args as { key: string }));
},
ontime_list_projects: async () => ok(await getProjectList()), ontime_list_projects: async () => ok(await getProjectList()),
ontime_load_project: async (args) => { ontime_load_project: async (args) => {
@@ -87,8 +87,8 @@ describe('safeMerge', () => {
it('should merge the urlPresets key when present', () => { it('should merge the urlPresets key when present', () => {
const newData = { const newData = {
urlPresets: [ urlPresets: [
{ enabled: true, alias: 'alias1', target: 'timer', search: '', displayInNav: false }, { enabled: true, alias: 'alias1', search: '' },
{ enabled: true, alias: 'alias2', target: 'timer', search: '', displayInNav: false }, { enabled: true, alias: 'alias2', search: '' },
] as URLPreset[], ] as URLPreset[],
}; };
-2
View File
@@ -43,7 +43,6 @@ export const demoDb: DatabaseModel = {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', 'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
}, },
{ {
enabled: true, enabled: true,
@@ -51,7 +50,6 @@ export const demoDb: DatabaseModel = {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff', 'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff',
displayInNav: false,
}, },
], ],
customFields: { customFields: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "4.10.0", "version": "4.9.0",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"keywords": [ "keywords": [
"ontime", "ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "4.10.0", "version": "4.9.0",
"name": "ontime-types", "name": "ontime-types",
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -20,7 +20,6 @@ type BaseURLPreset = {
enabled: boolean; enabled: boolean;
alias: string; alias: string;
search: string; search: string;
displayInNav: boolean;
options?: Record<string, string>; options?: Record<string, string>;
}; };
@@ -29,7 +28,6 @@ type CuesheetUrlPreset = {
enabled: boolean; enabled: boolean;
alias: string; alias: string;
search: string; search: string;
displayInNav: boolean;
options?: { options?: {
read?: string; read?: string;
write?: string; write?: string;
-1
View File
@@ -26,7 +26,6 @@ export const langEn = {
'timeline.done': 'done', 'timeline.done': 'done',
'timeline.due': 'due', 'timeline.due': 'due',
'timeline.followedby': 'Followed by', 'timeline.followedby': 'Followed by',
'timeline.standby': 'Standby',
'project.title': 'Title', 'project.title': 'Title',
'project.description': 'Description', 'project.description': 'Description',
'project.info': 'Project Info', 'project.info': 'Project Info',