From 0140ac5551c5c466ffc3a5bbbece6cc327c6bcc8 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 11 Dec 2023 11:27:26 +0100 Subject: [PATCH 001/276] wip(SOFIE-69): looping section --- .../lib/__tests__/rundownTiming.test.ts | 51 ++-- meteor/client/lib/rundown.ts | 2 + meteor/client/lib/rundownTiming.ts | 12 +- meteor/client/lib/viewPort.ts | 4 +- meteor/client/styles/rundownView.scss | 19 +- meteor/client/ui/AfterBroadcastForm.tsx | 3 +- meteor/client/ui/Prompter/PrompterView.tsx | 2 +- .../ui/RundownList/RundownListItemView.tsx | 7 +- .../ui/RundownList/RundownPlaylistUi.tsx | 8 +- meteor/client/ui/RundownView.tsx | 59 +++- .../RundownTiming/PlaylistEndTiming.tsx | 3 +- .../RundownView/RundownTiming/RundownName.tsx | 10 +- .../RundownTiming/RundownTimingProvider.tsx | 59 +++- .../SegmentContainer/withResolvedSegment.ts | 2 + meteor/client/ui/SegmentList/LinePart.tsx | 27 +- meteor/client/ui/SegmentList/SegmentList.scss | 15 + meteor/client/ui/SegmentList/SegmentList.tsx | 3 +- .../ui/SegmentList/SegmentListContainer.tsx | 4 +- .../SegmentScratchpad/SegmentScratchpad.tsx | 3 +- .../SegmentScratchpadContainer.tsx | 4 +- .../SegmentStoryboard/SegmentStoryboard.scss | 15 + .../SegmentStoryboard/SegmentStoryboard.tsx | 9 +- .../SegmentStoryboardContainer.tsx | 4 +- .../ui/SegmentStoryboard/StoryboardPart.tsx | 29 +- .../Parts/SegmentTimelinePart.tsx | 65 ++++- .../ui/SegmentTimeline/SegmentContextMenu.tsx | 38 ++- .../ui/SegmentTimeline/SegmentTimeline.tsx | 1 + .../SegmentTimelineContainer.tsx | 9 +- meteor/client/ui/Settings/Studio/Generic.tsx | 23 ++ .../TriggeredActionsEditor.tsx | 6 +- .../client/ui/Shelf/NextBreakTimingPanel.tsx | 4 +- meteor/lib/Rundown.ts | 29 +- meteor/lib/api/pubsub.ts | 8 + meteor/lib/api/userActions.ts | 16 + meteor/lib/clientUserAction.ts | 4 + meteor/lib/collections/libCollections.ts | 12 +- meteor/lib/collections/rundownPlaylistUtil.ts | 6 +- meteor/lib/userAction.ts | 2 + meteor/server/api/userActions.ts | 47 +++ meteor/server/publications/_publications.ts | 1 + .../publications/partsUI/publication.ts | 273 ++++++++++++++++++ .../partsUI/reactiveContentCache.ts | 50 ++++ .../partsUI/rundownContentObserver.ts | 74 +++++ .../corelib/src/dataModel/RundownPlaylist.ts | 53 ++++ packages/corelib/src/dataModel/Studio.ts | 4 + packages/corelib/src/worker/studio.ts | 13 + .../job-worker/src/playout/lookahead/util.ts | 1 + .../src/playout/model/PlayoutModel.ts | 13 + .../model/implementation/PlayoutModelImpl.ts | 198 +++++++++++++ .../src/playout/quickLoopMarkers.ts | 21 ++ .../job-worker/src/playout/selectNextPart.ts | 44 ++- packages/job-worker/src/playout/setNext.ts | 2 + .../src/playout/timings/partPlayback.ts | 1 + .../job-worker/src/workers/studio/jobs.ts | 3 + 54 files changed, 1276 insertions(+), 99 deletions(-) create mode 100644 meteor/server/publications/partsUI/publication.ts create mode 100644 meteor/server/publications/partsUI/reactiveContentCache.ts create mode 100644 meteor/server/publications/partsUI/rundownContentObserver.ts create mode 100644 packages/job-worker/src/playout/quickLoopMarkers.ts diff --git a/meteor/client/lib/__tests__/rundownTiming.test.ts b/meteor/client/lib/__tests__/rundownTiming.test.ts index 4557beec47..794f89e010 100644 --- a/meteor/client/lib/__tests__/rundownTiming.test.ts +++ b/meteor/client/lib/__tests__/rundownTiming.test.ts @@ -119,7 +119,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -183,7 +184,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -286,7 +288,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -391,7 +394,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -521,7 +525,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -677,7 +682,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -834,7 +840,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -940,7 +947,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_NONZERO_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -1072,7 +1080,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_NONZERO_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -1198,7 +1207,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -1323,7 +1333,8 @@ describe('rundown Timing Calculator', () => { piecesMap, segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -1473,7 +1484,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -1623,7 +1635,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -1779,7 +1792,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -1929,7 +1943,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -2079,7 +2094,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ @@ -2235,7 +2251,8 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [] + [], + {} ) expect(result).toEqual( literal({ diff --git a/meteor/client/lib/rundown.ts b/meteor/client/lib/rundown.ts index e3f446e798..8ea7db4e8b 100644 --- a/meteor/client/lib/rundown.ts +++ b/meteor/client/lib/rundown.ts @@ -45,6 +45,7 @@ import { PartId, PieceId, RundownId, SegmentId, ShowStyleBaseId } from '@sofie-a import { PieceInstances, Segments } from '../collections' import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { assertNever } from '@sofie-automation/shared-lib/dist/lib/lib' +import { getPartInstanceTimingId } from './rundownTiming' export namespace RundownUtils { export function padZeros(input: number, places?: number): string { @@ -439,6 +440,7 @@ export namespace RundownUtils { previousPart.instance.part.autoNext && previousPart.instance.part.expectedDuration ), + previousPartId: previousPart?.instance ? getPartInstanceTimingId(previousPart?.instance) : null, // TODO: this should be no longer needed }) // set the flags for isLiveSegment, isNextSegment, autoNextPart, hasAlreadyPlayed diff --git a/meteor/client/lib/rundownTiming.ts b/meteor/client/lib/rundownTiming.ts index 96e715f2ec..50a8983bb3 100644 --- a/meteor/client/lib/rundownTiming.ts +++ b/meteor/client/lib/rundownTiming.ts @@ -26,6 +26,7 @@ import { getCurrentTime, objectFromEntries } from '../../lib/lib' import { Settings } from '../../lib/Settings' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { isLoopRunning } from '../../lib/Rundown' // Minimum duration that a part can be assigned. Used by gap parts to allow them to "compress" to indicate time running out. const MINIMAL_NONZERO_DURATION = 1 @@ -111,7 +112,8 @@ export class RundownTimingCalculator { * the currentPartInstance. * * This is being used for calculating Segment Duration Budget */ - segmentEntryPartInstances: CalculateTimingsPartInstance[] + segmentEntryPartInstances: CalculateTimingsPartInstance[], + partsInQuickLoop: Record ): RundownTimingContext { let totalRundownDuration = 0 let remainingRundownDuration = 0 @@ -504,7 +506,7 @@ export class RundownTimingCalculator { // this is a line before next line localAccum = this.linearParts[i][1] || 0 // only null the values if not looping, if looping, these will be offset by the countdown for the last part - if (!playlist.loop) { + if (!partsInQuickLoop[unprotectString(this.linearParts[i][0])]) { this.linearParts[i][1] = null // we use null to express 'will not probably be played out, if played in order' } } else if (i === currentAIndex) { @@ -546,8 +548,9 @@ export class RundownTimingCalculator { } } // contiunation of linearParts calculations for looping playlists - if (playlist.loop) { + if (isLoopRunning(playlist)) { for (let i = 0; i < nextAIndex; i++) { + if (!partsInQuickLoop[unprotectString(this.linearParts[i][0])]) continue // offset the parts before the on air line by the countdown for the end of the rundown this.linearParts[i][1] = (this.linearParts[i][1] || 0) + waitAccumulator - localAccum + currentRemaining @@ -658,6 +661,7 @@ export class RundownTimingCalculator { breakIsLastRundown, isLowResolution, nextRundownAnchor, + partsInQuickLoop, }) } @@ -721,6 +725,8 @@ export interface RundownTimingContext { partCountdown?: Record /** The calculated durations of each of the Parts: as-planned/as-run depending on state. */ partDurations?: Record + /** TODO */ + partsInQuickLoop?: Record /** The offset of each of the Parts from the beginning of the Playlist. */ partStartsAt?: Record /** Same as partStartsAt, but will include display duration overrides diff --git a/meteor/client/lib/viewPort.ts b/meteor/client/lib/viewPort.ts index 4ac1afe1ad..29dd0f3a12 100644 --- a/meteor/client/lib/viewPort.ts +++ b/meteor/client/lib/viewPort.ts @@ -3,7 +3,7 @@ import { isProtectedString } from '../../lib/lib' import RundownViewEventBus, { RundownViewEvents } from '../../lib/api/triggers/RundownViewEventBus' import { Settings } from '../../lib/Settings' import { PartId, PartInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PartInstances, Parts } from '../collections' +import { PartInstances, UIParts } from '../collections' import { logger } from '../../lib/logging' const HEADER_MARGIN = 24 // TODOSYNC: TV2 uses 15. If it's needed to be different, it needs to be made generic somehow.. @@ -72,7 +72,7 @@ export async function scrollToPart( zoomInToFit?: boolean ): Promise { quitFocusOnPart() - const part = Parts.findOne(partId) + const part = UIParts.findOne(partId) if (part) { await scrollToSegment(part.segmentId, forceScroll, noAnimation) diff --git a/meteor/client/styles/rundownView.scss b/meteor/client/styles/rundownView.scss index d590da90aa..7f8829d413 100644 --- a/meteor/client/styles/rundownView.scss +++ b/meteor/client/styles/rundownView.scss @@ -1512,6 +1512,22 @@ svg.icon { z-index: -1; } + &.out-of-the-loop { + &::before { + content: ' '; + display: block; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 2; + pointer-events: none; + } + } + + &:not(.live) { .segment-timeline__part__nextline.auto-next:not(.segment-timeline__part__nextline--endline), .segment-timeline__part__nextline.invalid:not(.segment-timeline__part__nextline--endline) { @@ -1626,9 +1642,6 @@ svg.icon { > svg { margin-left: 1px; margin-right: 5px; - > path { - stroke: $general-next-color; - } } } } diff --git a/meteor/client/ui/AfterBroadcastForm.tsx b/meteor/client/ui/AfterBroadcastForm.tsx index b0383d7925..ab33ed424e 100644 --- a/meteor/client/ui/AfterBroadcastForm.tsx +++ b/meteor/client/ui/AfterBroadcastForm.tsx @@ -13,6 +13,7 @@ import { MultiLineTextInputControl } from '../lib/Components/MultiLineTextInput' import { TextInputControl } from '../lib/Components/TextInput' import { Spinner } from '../lib/Spinner' import { NotificationCenter, Notification, NoticeLevel } from '../../lib/notifications/notifications' +import { isLoopRunning } from '../../lib/Rundown' type ProblemType = 'nothing' | 'minor' | 'major' @@ -30,7 +31,7 @@ const DEFAULT_STATE = { export function AfterBroadcastForm({ playlist }: { playlist: DBRundownPlaylist }): JSX.Element { const { t } = useTranslation() - const shouldDeactivateRundown = !playlist.loop + const shouldDeactivateRundown = isLoopRunning(playlist) const [problems, setProblems] = useState(DEFAULT_STATE.problems) const [description, setDescription] = useState(DEFAULT_STATE.description.slice()) const [userName, setUserName] = useState(DEFAULT_STATE.userName) diff --git a/meteor/client/ui/Prompter/PrompterView.tsx b/meteor/client/ui/Prompter/PrompterView.tsx index 27a8720b4c..a33b794994 100644 --- a/meteor/client/ui/Prompter/PrompterView.tsx +++ b/meteor/client/ui/Prompter/PrompterView.tsx @@ -608,7 +608,7 @@ export const Prompter = translateWithTracker, if (playlist) { const rundownIDs = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) this.subscribe(CorelibPubSub.segments, rundownIDs, {}) - this.subscribe(CorelibPubSub.parts, rundownIDs, null) + this.subscribe(MeteorPubSub.uiParts, playlist._id) this.subscribe(CorelibPubSub.partInstances, rundownIDs, playlist.activationId ?? null) this.subscribe(CorelibPubSub.pieces, rundownIDs, null) this.subscribe(CorelibPubSub.pieceInstancesSimple, rundownIDs, null) diff --git a/meteor/client/ui/RundownList/RundownListItemView.tsx b/meteor/client/ui/RundownList/RundownListItemView.tsx index 5a82d23eb4..109dcb5922 100644 --- a/meteor/client/ui/RundownList/RundownListItemView.tsx +++ b/meteor/client/ui/RundownList/RundownListItemView.tsx @@ -16,6 +16,7 @@ import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTi import { TOOLTIP_DEFAULT_DELAY } from '../../lib/lib' import { Meteor } from 'meteor/meteor' import { RundownPlaylists } from '../../collections' +import { isLoopDefined } from '../../../lib/Rundown' interface IRundownListItemViewProps { isActive: boolean @@ -60,7 +61,7 @@ export default React.memo(function RundownListItemView({ const rundownNameContent = rundownViewUrl ? ( - {isOnlyRundownInPlaylist && playlist.loop && } + {isOnlyRundownInPlaylist && isLoopDefined(playlist) && } {rundown.name} ) : ( @@ -131,7 +132,7 @@ export default React.memo(function RundownListItemView({ {expectedDuration ? ( - isOnlyRundownInPlaylist && playlist.loop ? ( + isOnlyRundownInPlaylist && isLoopDefined(playlist) ? ( - {playlist.loop && } + {isPlaylistLooping && } {playlist.name} @@ -220,7 +222,7 @@ export function RundownPlaylistUi({ {expectedDuration ? ( expectedDuration - ) : playlist.loop ? ( + ) : isPlaylistLooping ? ( (( currentPartInstance, nextPartInstance, currentSegmentPartIds: currentPartInstance - ? Parts.find( + ? UIParts.find( { segmentId: currentPartInstance?.part.segmentId, }, @@ -1325,7 +1330,7 @@ export const RundownView = translateWithTracker(( ).map((part) => part._id) : [], nextSegmentPartIds: nextPartInstance - ? Parts.find( + ? UIParts.find( { segmentId: nextPartInstance?.part.segmentId, }, @@ -1632,7 +1637,7 @@ export const RundownView = translateWithTracker(( this.subscribe(CorelibPubSub.rundownBaselineAdLibPieces, rundownIDs) this.subscribe(CorelibPubSub.adLibActions, rundownIDs) this.subscribe(CorelibPubSub.rundownBaselineAdLibActions, rundownIDs) - this.subscribe(CorelibPubSub.parts, rundownIDs, null) + this.subscribe(MeteorPubSub.uiParts, playlistId) this.subscribe(CorelibPubSub.partInstances, rundownIDs, playlist.activationId ?? null) }) this.autorun(() => { @@ -2097,6 +2102,38 @@ export const RundownView = translateWithTracker(( } } + onSetQuickLoopStart = (marker: QuickLoopMarker | null, e: any) => { + const { t } = this.props + if (this.state.studioMode && this.props.playlist) { + const playlistId = this.props.playlist._id + doUserAction( + t, + e, + UserAction.SET_QUICK_LOOP_START, + (e, ts) => MeteorCall.userAction.setQuickLoopStart(e, ts, playlistId, marker), + (err) => { + if (err) logger.error(err) + } + ) + } + } + + onSetQuickLoopEnd = (marker: QuickLoopMarker | null, e: any) => { + const { t } = this.props + if (this.state.studioMode && this.props.playlist) { + const playlistId = this.props.playlist._id + doUserAction( + t, + e, + UserAction.SET_QUICK_LOOP_END, + (e, ts) => MeteorCall.userAction.setQuickLoopEnd(e, ts, playlistId, marker), + (err) => { + if (err) logger.error(err) + } + ) + } + } + onPieceDoubleClick = (item: PieceUi, e: React.MouseEvent) => { const { t } = this.props if ( @@ -2121,7 +2158,7 @@ export const RundownView = translateWithTracker(( if (!segmentId) { if (e.sourceLocator.partId) { - const part = Parts.findOne(e.sourceLocator.partId) + const part = UIParts.findOne(e.sourceLocator.partId) if (part) { segmentId = part.segmentId } @@ -2572,16 +2609,16 @@ export const RundownView = translateWithTracker(( ) } - + const isPlaylistLooping = isLoopDefined(this.props.playlist) return ( - {this.props.playlist?.loop && ( + {isPlaylistLooping && this.props.playlist.quickLoop?.start?.type === QuickLoopMarkerType.PLAYLIST && ( 1} /> )}
{this.renderSegments()}
- {this.props.playlist?.loop && ( + {isPlaylistLooping && this.props.playlist.quickLoop?.end?.type === QuickLoopMarkerType.PLAYLIST && ( 1} @@ -3008,6 +3045,8 @@ export const RundownView = translateWithTracker(( onSetNext={this.onSetNext} onSetNextSegment={this.onSetNextSegment} onQueueNextSegment={this.onQueueNextSegment} + onSetQuickLoopStart={this.onSetQuickLoopStart} + onSetQuickLoopEnd={this.onSetQuickLoopEnd} studioMode={this.state.studioMode} enablePlayFromAnywhere={!!studio.settings.enablePlayFromAnywhere} /> diff --git a/meteor/client/ui/RundownView/RundownTiming/PlaylistEndTiming.tsx b/meteor/client/ui/RundownView/RundownTiming/PlaylistEndTiming.tsx index 2d2406e14d..a5ebede714 100644 --- a/meteor/client/ui/RundownView/RundownTiming/PlaylistEndTiming.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/PlaylistEndTiming.tsx @@ -8,6 +8,7 @@ import { withTiming, WithTiming } from './withTiming' import ClassNames from 'classnames' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { getPlaylistTimingDiff } from '../../../lib/rundownTiming' +import { isLoopRunning } from '../../../../lib/Rundown' interface IEndTimingProps { rundownPlaylist: DBRundownPlaylist @@ -54,7 +55,7 @@ export const PlaylistEndTiming = withTranslation()(
) ) : this.props.timingDurations ? ( - this.props.rundownPlaylist.loop ? ( + isLoopRunning(this.props.rundownPlaylist) ? ( this.props.timingDurations.partCountdown && rundownPlaylist.activationId && rundownPlaylist.currentPartInfo ? ( diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownName.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownName.tsx index 2a0cca0ecd..ab4c9ffa88 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownName.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/RundownName.tsx @@ -9,6 +9,7 @@ import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { RundownUtils } from '../../../lib/rundown' import { getCurrentTime } from '../../../../lib/lib' import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' +import { isLoopDefined } from '../../../../lib/Rundown' interface IRundownNameProps { rundownPlaylist: DBRundownPlaylist @@ -23,6 +24,7 @@ export const RundownName = withTranslation()( render(): JSX.Element { const { rundownPlaylist, currentRundown, rundownCount, t } = this.props const expectedStart = PlaylistTiming.getExpectedStart(rundownPlaylist.timing) + const isPlaylistLooping = isLoopDefined(rundownPlaylist) return ( - {rundownPlaylist.loop && } {currentRundown.name} {rundownPlaylist.name} + {isPlaylistLooping && } {currentRundown.name} {rundownPlaylist.name} ) : ( - {rundownPlaylist.loop && } {rundownPlaylist.name} + {isPlaylistLooping && } {rundownPlaylist.name} )} {!this.props.hideDiff && diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index 82c375a342..205df90569 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -4,16 +4,22 @@ import * as PropTypes from 'prop-types' import { withTracker } from '../../../lib/ReactMeteorData/react-meteor-data' import { getCurrentTime, protectString } from '../../../../lib/lib' import { MeteorReactComponent } from '../../../lib/MeteorReactComponent' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBRundownPlaylist, QuickLoopMarkerType } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { PartInstance, wrapPartToTemporaryInstance } from '../../../../lib/collections/PartInstances' import { RundownTiming, TimeEventArgs } from './RundownTiming' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { RundownTimingCalculator, RundownTimingContext } from '../../../lib/rundownTiming' +import { + RundownTimingCalculator, + RundownTimingContext, + TimingId, + getPartInstanceTimingId, +} from '../../../lib/rundownTiming' import { PartId, PartInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { RundownPlaylistCollectionUtil } from '../../../../lib/collections/rundownPlaylistUtil' import { sortPartInstancesInSortedSegments } from '@sofie-automation/corelib/dist/playout/playlist' import { CalculateTimingsPiece } from '@sofie-automation/corelib/dist/playout/timings' +import { isLoopDefined } from '../../../../lib/Rundown' const TIMING_DEFAULT_REFRESH_INTERVAL = 1000 / 60 // the interval for high-resolution events (timeupdateHR) const LOW_RESOLUTION_TIMING_DECIMATOR = 15 @@ -51,6 +57,7 @@ interface IRundownTimingProviderTrackedProps { segmentEntryPartInstances: MinimalPartInstance[] segments: DBSegment[] segmentsMap: Map + partsInQuickLoop: Record } type MinimalPartInstance = Pick< @@ -79,6 +86,7 @@ export const RundownTimingProvider = withTracker< segmentEntryPartInstances: [], segments: [], segmentsMap: new Map(), + partsInQuickLoop: {}, } } @@ -166,6 +174,8 @@ export const RundownTimingProvider = withTracker< partInstances = sortPartInstancesInSortedSegments(partInstances, segments) + const partsInQuickLoop = findPartInstancesInQuickLoop(playlist, partInstances) + if (firstPartInstanceInCurrentSegmentPlay) segmentEntryPartInstances.push(firstPartInstanceInCurrentSegmentPlay) if (firstPartInstanceInPreviousSegmentPlay) segmentEntryPartInstances.push(firstPartInstanceInPreviousSegmentPlay) @@ -180,6 +190,7 @@ export const RundownTimingProvider = withTracker< segmentEntryPartInstances, segments, segmentsMap, + partsInQuickLoop, } })( class RundownTimingProvider @@ -331,7 +342,8 @@ export const RundownTimingProvider = withTracker< pieces, segmentsMap, this.props.defaultDuration, - segmentEntryPartInstances + segmentEntryPartInstances, + this.props.partsInQuickLoop ) if (!isSynced) { this.durations = Object.assign(this.durations, updatedDurations) @@ -346,6 +358,47 @@ export const RundownTimingProvider = withTracker< } ) +function findPartInstancesInQuickLoop( + playlist: DBRundownPlaylist, + sortedPartInstances: MinimalPartInstance[] +): Record { + const partsInQuickLoop: Record = {} + if (!isLoopDefined(playlist)) return partsInQuickLoop + + let isInQuickLoop = playlist.quickLoop?.start?.type === QuickLoopMarkerType.PLAYLIST + let previousPartInstance: MinimalPartInstance | undefined = undefined + for (const partInstance of sortedPartInstances) { + if ( + isInQuickLoop && + ((playlist.quickLoop?.end?.type === QuickLoopMarkerType.PART && + playlist.quickLoop.end.id === previousPartInstance!.part._id) || + (playlist.quickLoop?.end?.type === QuickLoopMarkerType.SEGMENT && + playlist.quickLoop.end.id === previousPartInstance!.segmentId) || + (playlist.quickLoop?.end?.type === QuickLoopMarkerType.RUNDOWN && + playlist.quickLoop.end.id === previousPartInstance!.rundownId)) + ) { + isInQuickLoop = false + break + } + if ( + !isInQuickLoop && + ((playlist.quickLoop?.start?.type === QuickLoopMarkerType.PART && + playlist.quickLoop.start.id === partInstance.part._id) || + (playlist.quickLoop?.start?.type === QuickLoopMarkerType.SEGMENT && + playlist.quickLoop.start.id === partInstance.segmentId) || + (playlist.quickLoop?.start?.type === QuickLoopMarkerType.RUNDOWN && + playlist.quickLoop.start.id === partInstance.rundownId)) + ) { + isInQuickLoop = true + } + if (isInQuickLoop) { + partsInQuickLoop[getPartInstanceTimingId(partInstance)] = true + } + previousPartInstance = partInstance + } + return partsInQuickLoop +} + function findCurrentAndPreviousPartInstance( activePartInstances: MinimalPartInstance[], currentPartInstanceId: PartInstanceId | undefined, diff --git a/meteor/client/ui/SegmentContainer/withResolvedSegment.ts b/meteor/client/ui/SegmentContainer/withResolvedSegment.ts index 3fd0fa997c..84a9b4f57a 100644 --- a/meteor/client/ui/SegmentContainer/withResolvedSegment.ts +++ b/meteor/client/ui/SegmentContainer/withResolvedSegment.ts @@ -378,6 +378,8 @@ export function withResolvedSegment( props.playlist.holdState !== nextProps.playlist.holdState || props.playlist.nextTimeOffset !== nextProps.playlist.nextTimeOffset || props.playlist.activationId !== nextProps.playlist.activationId || + !_.isEqual(props.playlist.quickLoop?.start, nextProps.playlist.quickLoop?.start) || + !_.isEqual(props.playlist.quickLoop?.end, nextProps.playlist.quickLoop?.end) || PlaylistTiming.getExpectedStart(props.playlist.timing) !== PlaylistTiming.getExpectedStart(nextProps.playlist.timing) ) { diff --git a/meteor/client/ui/SegmentList/LinePart.tsx b/meteor/client/ui/SegmentList/LinePart.tsx index 0420bf63bb..b8f78f830e 100644 --- a/meteor/client/ui/SegmentList/LinePart.tsx +++ b/meteor/client/ui/SegmentList/LinePart.tsx @@ -14,6 +14,8 @@ import { LinePartIdentifier } from './LinePartIdentifier' import { LinePartPieceIndicators } from './LinePartPieceIndicators' import { LinePartTimeline } from './LinePartTimeline' import { LinePartTitle } from './LinePartTitle' +import { TimingDataResolution, TimingTickResolution, withTiming } from '../RundownView/RundownTiming/withTiming' +import { RundownTimingContext, getPartInstanceTimingId } from '../../lib/rundownTiming' interface IProps { segment: SegmentUi @@ -24,7 +26,7 @@ interface IProps { hasAlreadyPlayed: boolean // isLastSegment?: boolean // isLastPartInSegment?: boolean - // isPlaylistLooping?: boolean + isPlaylistLooping: boolean indicatorColumns: Record adLibIndicatorColumns: Record doesPlaylistHaveNextPart?: boolean @@ -39,7 +41,18 @@ interface IProps { onPieceDoubleClick?: (item: PieceUi, e: React.MouseEvent) => void } -export const LinePart: React.FC = function LinePart({ +export const LinePart = withTiming((props: IProps) => { + return { + tickResolution: TimingTickResolution.Synced, + dataResolution: TimingDataResolution.High, + filter: (durations: RundownTimingContext) => { + durations = durations || {} + + const timingId = getPartInstanceTimingId(props.part.instance) + return [(durations.partsInQuickLoop || {})[timingId]] + }, + } +})(function LinePart({ part, segment, isNextPart, @@ -49,6 +62,8 @@ export const LinePart: React.FC = function LinePart({ currentPartWillAutonext, indicatorColumns, adLibIndicatorColumns, + isPlaylistLooping, + timingDurations, onContextMenu, onPieceClick, onPieceDoubleClick, @@ -57,6 +72,9 @@ export const LinePart: React.FC = function LinePart({ (part.instance.timings?.reportedStoppedPlayback ?? part.instance.timings?.plannedStoppedPlayback) !== undefined const [highlight] = useState(false) + const timingId = getPartInstanceTimingId(part.instance) + const isInQuickLoop = (timingDurations.partsInQuickLoop || {})[timingId] + const getPartContext = useCallback(() => { const partElement = document.querySelector('#' + SegmentTimelinePartElementId + part.instance._id) const partDocumentOffset = getElementDocumentOffset(partElement) @@ -111,7 +129,8 @@ export const LinePart: React.FC = function LinePart({ 'invert-flash': highlight, 'segment-opl__part--next': isNextPart, 'segment-opl__part--live': isLivePart, - 'segment-opl__part--has-played': hasAlreadyPlayed, + 'segment-opl__part--has-played': hasAlreadyPlayed && (!isPlaylistLooping || !isInQuickLoop), + 'segment-opl__part--out-of-the-loop': isPlaylistLooping && !isInQuickLoop && !isNextPart && !hasAlreadyPlayed, 'segment-opl__part--invalid': part.instance.part.invalid, 'segment-opl__part--timing-sibling': isPreceededByTimingGroupSibling, }), @@ -163,4 +182,4 @@ export const LinePart: React.FC = function LinePart({ /> ) -} +}) diff --git a/meteor/client/ui/SegmentList/SegmentList.scss b/meteor/client/ui/SegmentList/SegmentList.scss index e4b76cb3f0..d92e8d03df 100644 --- a/meteor/client/ui/SegmentList/SegmentList.scss +++ b/meteor/client/ui/SegmentList/SegmentList.scss @@ -196,6 +196,21 @@ $identifier-area-width: 3em; } } + &--out-of-the-loop { + &::before { + content: ' '; + display: block; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 2; + pointer-events: none; + } + } + > .segment-opl__part-timeline { position: relative; margin-top: 0.6em; diff --git a/meteor/client/ui/SegmentList/SegmentList.tsx b/meteor/client/ui/SegmentList/SegmentList.tsx index 2d35dc4344..fdcac38ea2 100644 --- a/meteor/client/ui/SegmentList/SegmentList.tsx +++ b/meteor/client/ui/SegmentList/SegmentList.tsx @@ -9,7 +9,7 @@ import { literal } from '@sofie-automation/corelib/dist/lib' import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' import { LinePart } from './LinePart' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' -import { ISourceLayerExtended } from '../../../lib/Rundown' +import { ISourceLayerExtended, isLoopRunning } from '../../../lib/Rundown' import { SegmentViewMode } from '../SegmentContainer/SegmentViewModes' import { SegmentListHeader } from './SegmentListHeader' import { useInView } from 'react-intersection-observer' @@ -158,6 +158,7 @@ const SegmentListInner = React.forwardRef(function Segme doesPlaylistHaveNextPart={playlistHasNextPart} onPieceDoubleClick={props.onPieceDoubleClick} onContextMenu={props.onContextMenu} + isPlaylistLooping={isLoopRunning(props.playlist)} /> ) diff --git a/meteor/client/ui/SegmentList/SegmentListContainer.tsx b/meteor/client/ui/SegmentList/SegmentListContainer.tsx index e2e05107f8..deaebffd19 100644 --- a/meteor/client/ui/SegmentList/SegmentListContainer.tsx +++ b/meteor/client/ui/SegmentList/SegmentListContainer.tsx @@ -10,7 +10,7 @@ import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { SegmentList } from './SegmentList' import { unprotectString } from '../../../lib/lib' import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/SegmentTimelineContainer' -import { PartInstances, Parts, Segments } from '../../collections' +import { PartInstances, Segments, UIParts } from '../../collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' export const LIVELINE_HISTORY_SIZE = TIMELINE_LIVELINE_HISTORY_SIZE @@ -28,7 +28,7 @@ export const SegmentListContainer = withResolvedSegment(function Segment }: IProps & ITrackedResolvedSegmentProps) { const partIds = useTracker( () => - Parts.find( + UIParts.find( { segmentId, }, diff --git a/meteor/client/ui/SegmentScratchpad/SegmentScratchpad.tsx b/meteor/client/ui/SegmentScratchpad/SegmentScratchpad.tsx index 197a9a18f9..a2b75cc30d 100644 --- a/meteor/client/ui/SegmentScratchpad/SegmentScratchpad.tsx +++ b/meteor/client/ui/SegmentScratchpad/SegmentScratchpad.tsx @@ -26,6 +26,7 @@ import { PartId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBRundownPlaylist, RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { CalculateTimingsPiece } from '@sofie-automation/corelib/dist/playout/timings' import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' +import { isLoopRunning } from '../../../lib/Rundown' interface IProps { id: string @@ -130,7 +131,7 @@ export const SegmentScratchpad = React.memo( squishedPartsNum > 1 ? Math.max(4, (spaceLeft - PART_WIDTH) / (squishedPartsNum - 1)) : null const playlistHasNextPart = !!props.playlist.nextPartInfo - const playlistIsLooping = props.playlist.loop + const playlistIsLooping = isLoopRunning(props.playlist) renderedParts.forEach((part, index) => { const isLivePart = part.instance._id === props.playlist.currentPartInfo?.partInstanceId diff --git a/meteor/client/ui/SegmentScratchpad/SegmentScratchpadContainer.tsx b/meteor/client/ui/SegmentScratchpad/SegmentScratchpadContainer.tsx index 1ca6b8f2bc..89a46c40b5 100644 --- a/meteor/client/ui/SegmentScratchpad/SegmentScratchpadContainer.tsx +++ b/meteor/client/ui/SegmentScratchpad/SegmentScratchpadContainer.tsx @@ -11,7 +11,7 @@ import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { SegmentScratchpad } from './SegmentScratchpad' import { unprotectString } from '../../../lib/lib' import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/SegmentTimelineContainer' -import { PartInstances, Parts, Segments } from '../../collections' +import { PartInstances, Segments, UIParts } from '../../collections' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' import { MongoFieldSpecifierOnes } from '@sofie-automation/corelib/dist/mongo' import { PartInstance } from '../../../lib/collections/PartInstances' @@ -32,7 +32,7 @@ export const SegmentScratchpadContainer = withResolvedSegment(function S }: IProps & ITrackedResolvedSegmentProps) { const partIds = useTracker( () => - Parts.find( + UIParts.find( { segmentId, }, diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss index b67b4a9e2f..ea61e8bff7 100644 --- a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss +++ b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss @@ -166,6 +166,21 @@ $break-width: 35rem; background: $segment-timeline-background-color; } + &--out-of-the-loop { + &::before { + content: ' '; + display: block; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 2; + pointer-events: none; + } + } + > .segment-storyboard__part__background { position: absolute; background-image: linear-gradient(to bottom, $general-live-color 20px, black 8rem); diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.tsx b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.tsx index c5dfabe025..cbcabb7288 100644 --- a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.tsx +++ b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.tsx @@ -37,6 +37,7 @@ import { RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/Rundo import { CalculateTimingsPiece } from '@sofie-automation/corelib/dist/playout/timings' import { SegmentTimeAnchorTime } from '../RundownView/RundownTiming/SegmentTimeAnchorTime' import { logger } from '../../../lib/logging' +import { isEndOfLoopingShow, isLoopRunning } from '../../../lib/Rundown' interface IProps { id: string @@ -197,7 +198,7 @@ export const SegmentStoryboard = React.memo( squishedPartsNum > 1 ? Math.max(4, (spaceLeft - PART_WIDTH) / (squishedPartsNum - 1)) : null const playlistHasNextPart = !!props.playlist.nextPartInfo - const playlistIsLooping = props.playlist.loop + const playlistIsLooping = isLoopRunning(props.playlist) renderedParts.forEach((part, index) => { const isLivePart = part.instance._id === props.playlist.currentPartInfo?.partInstanceId @@ -218,6 +219,12 @@ export const SegmentStoryboard = React.memo( isNextPart={isNextPart} isLastPartInSegment={part.instance._id === lastValidPartId} isLastSegment={props.isLastSegment} + isEndOfLoopingShow={isEndOfLoopingShow( + props.playlist, + props.isLastSegment, + part.instance._id === lastValidPartId, + part.instance.part + )} isPlaylistLooping={playlistIsLooping} doesPlaylistHaveNextPart={playlistHasNextPart} displayLiveLineCounter={props.displayLiveLineCounter} diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx b/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx index dc58c1020a..f6288b08b2 100644 --- a/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx +++ b/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx @@ -11,7 +11,7 @@ import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { SegmentStoryboard } from './SegmentStoryboard' import { unprotectString } from '../../../lib/lib' import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/SegmentTimelineContainer' -import { PartInstances, Parts, Segments } from '../../collections' +import { PartInstances, Segments, UIParts } from '../../collections' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' import { MongoFieldSpecifierOnes } from '@sofie-automation/corelib/dist/mongo' import { PartInstance } from '../../../lib/collections/PartInstances' @@ -32,7 +32,7 @@ export const SegmentStoryboardContainer = withResolvedSegment(function S }: IProps & ITrackedResolvedSegmentProps) { const partIds = useTracker( () => - Parts.find( + UIParts.find( { segmentId, }, diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx b/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx index e8d54eb4f8..7520dd24dd 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx +++ b/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx @@ -20,6 +20,8 @@ import { PartDisplayDuration } from '../RundownView/RundownTiming/PartDuration' import { InvalidPartCover } from '../SegmentTimeline/Parts/InvalidPartCover' import { SegmentEnd } from '../../lib/ui/icons/segment' import { AutoNextStatus } from '../RundownView/RundownTiming/AutoNextStatus' +import { RundownTimingContext, getPartInstanceTimingId } from '../../lib/rundownTiming' +import { TimingDataResolution, TimingTickResolution, withTiming } from '../RundownView/RundownTiming/withTiming' interface IProps { className?: string @@ -31,6 +33,7 @@ interface IProps { isLastSegment?: boolean isLastPartInSegment?: boolean isPlaylistLooping?: boolean + isEndOfLoopingShow?: boolean doesPlaylistHaveNextPart?: boolean inHold: boolean currentPartWillAutonext: boolean @@ -41,8 +44,18 @@ interface IProps { onHoverOver?: () => void onHoverOut?: () => void } +export const StoryboardPart = withTiming((props: IProps) => { + return { + tickResolution: TimingTickResolution.Synced, + dataResolution: TimingDataResolution.High, + filter: (durations: RundownTimingContext) => { + durations = durations || {} -export function StoryboardPart({ + const timingId = getPartInstanceTimingId(props.part.instance) + return [(durations.partsInQuickLoop || {})[timingId]] + }, + } +})(function StoryboardPart({ className, segment, part, @@ -51,16 +64,18 @@ export function StoryboardPart({ isLastPartInSegment, isLastSegment, isPlaylistLooping, + isEndOfLoopingShow, doesPlaylistHaveNextPart, currentPartWillAutonext, outputLayers, subscriptionsReady, displayLiveLineCounter, style, + timingDurations, onContextMenu, onHoverOver, onHoverOut, -}: IProps): JSX.Element { +}): JSX.Element { const { t } = useTranslation() const [highlight, setHighlight] = useState(false) const willBeAutoNextedInto = isNextPart ? currentPartWillAutonext : part.willProbablyAutoNext @@ -112,6 +127,7 @@ export function StoryboardPart({ const isInvalid = part.instance.part.invalid const isFloated = part.instance.part.floated + const isPartInQuickLoop = timingDurations.partsInQuickLoop?.[getPartInstanceTimingId(part.instance)] ?? false return ( )} - {!isLastSegment && isLastPartInSegment && !part.instance.part.invalid && ( + {!isLastSegment && isLastPartInSegment && !isEndOfLoopingShow && !part.instance.part.invalid && (
)} - {isLastSegment && ( + {(isLastSegment || isEndOfLoopingShow) && (
{(!isLivePart || !doesPlaylistHaveNextPart || isPlaylistLooping) && isLastPartInSegment && (
- {isPlaylistLooping ? t('Loops to top') : t('Show End')} + {isEndOfLoopingShow ? t('Loops to Start') : t('Show End')}
)}
@@ -254,4 +271,4 @@ export function StoryboardPart({ ) : null}
) -} +}) diff --git a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index 955aee4463..7951bde518 100644 --- a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -3,7 +3,7 @@ import _ from 'underscore' import { withTranslation, WithTranslation, TFunction } from 'react-i18next' import ClassNames from 'classnames' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBRundownPlaylist, QuickLoopMarkerType } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { SegmentUi, PartUi, IOutputLayerUi, PieceUi, LIVE_LINE_TIME_PADDING } from '../SegmentTimelineContainer' import { TimingDataResolution, @@ -35,6 +35,7 @@ import { InvalidPartCover } from './InvalidPartCover' import { ISourceLayer } from '@sofie-automation/blueprints-integration' import { UIStudio } from '../../../../lib/api/studios' import { CalculateTimingsPiece } from '@sofie-automation/corelib/dist/playout/timings' +import { isEndOfLoopingShow, isLoopRunning } from '../../../../lib/Rundown' export const SegmentTimelineLineElementId = 'rundown__segment__line__' export const SegmentTimelinePartElementId = 'rundown__segment__part__' @@ -143,6 +144,12 @@ export class SegmentTimelinePartClass extends React.Component )} - {!isEndOfShow && this.props.isLastInSegment && !innerPart.invalid && ( + {!isEndOfShow && !isEndOfLoopingShow && this.props.isLastInSegment && !innerPart.invalid && (
)} - {isEndOfShow && ( + {(isEndOfShow || isEndOfLoopingShow) && (
- {this.props.playlist.loop ? t('Loops to top') : t('Show End')} + {isEndOfLoopingShow ? t('Loops to Start') : t('Show End')}
)} @@ -627,7 +641,13 @@ export class SegmentTimelinePartClass extends React.Component ) : null} {innerPart.floated ?
: null} - + {isQuickLoopStart ?
LOOP START
: null} + {isQuickLoopEnd ? ( +
+ LOOP END +
+ ) : null} {this.props.playlist.nextTimeOffset && this.state.isNext && ( // This is the off-set line
)} - {this.props.isAfterLastValidInSegmentAndItsLive && !this.props.playlist.loop && } - {this.props.isAfterLastValidInSegmentAndItsLive && this.props.playlist.loop && } + {this.props.isAfterLastValidInSegmentAndItsLive && !isPlaylistLooping && } + {this.props.isAfterLastValidInSegmentAndItsLive && isPlaylistLooping && }
{!this.props.isPreview && this.props.part.instance.part.identifier && (
{this.props.part.instance.part.identifier}
)} )} - {this.renderEndOfSegment(t, innerPart, isEndOfShow, isEndOfLoopingShow)} + {this.renderEndOfSegment(t, innerPart, isEndOfShow, endOfLoopingShow)} ) } else { @@ -777,7 +818,7 @@ export class SegmentTimelinePartClass extends React.Component {/* render it empty, just to take up space */} - {this.state.isInsideViewport ? this.renderEndOfSegment(t, innerPart, isEndOfShow, isEndOfLoopingShow) : null} + {this.state.isInsideViewport ? this.renderEndOfSegment(t, innerPart, isEndOfShow, endOfLoopingShow) : null} ) } @@ -800,6 +841,8 @@ export const SegmentTimelinePart = withTranslation()( (durations.partDurations || {})[timingId], (durations.partDisplayStartsAt || {})[timingId], (durations.partDisplayDurations || {})[timingId], + (durations.partsInQuickLoop || {})[timingId], + props.part.previousPartId ? (durations.partsInQuickLoop || {})[props.part.previousPartId] : undefined, firstPartInSegmentId ? (durations.partDisplayStartsAt || {})[firstPartInSegmentId] : undefined, firstPartInSegmentId ? (durations.partDisplayDurations || {})[firstPartInSegmentId] : undefined, ] diff --git a/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx b/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx index d699417a5d..212f9762c6 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx @@ -3,7 +3,11 @@ import Escape from './../../lib/Escape' import { withTranslation } from 'react-i18next' import { ContextMenu, MenuItem } from '@jstarpl/react-contextmenu' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { + DBRundownPlaylist, + QuickLoopMarker, + QuickLoopMarkerType, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { Translated } from '../../lib/ReactMeteorData/ReactMeteorData' import { RundownUtils } from '../../lib/rundown' import { IContextMenuContext } from '../RundownView' @@ -15,6 +19,8 @@ interface IProps { onSetNext: (part: DBPart | undefined, e: any, offset?: number, take?: boolean) => void onSetNextSegment: (segmentId: SegmentId, e: any) => void onQueueNextSegment: (segmentId: SegmentId | null, e: any) => void + onSetQuickLoopStart: (marker: QuickLoopMarker | null, e: any) => void + onSetQuickLoopEnd: (marker: QuickLoopMarker | null, e: any) => void playlist?: DBRundownPlaylist studioMode: boolean contextMenuContext: IContextMenuContext | null @@ -99,6 +105,36 @@ export const SegmentContextMenu = withTranslation()( ) : null} + {this.props.playlist?.quickLoop?.start?.type === QuickLoopMarkerType.PART && + this.props.playlist.quickLoop.start.id === part.partId ? ( + this.props.onSetQuickLoopStart(null, e)}> + {t('Clear QuickLoop Start')} + + ) : ( + + this.props.onSetQuickLoopStart({ type: QuickLoopMarkerType.PART, id: part.instance.part._id }, e) + } + disabled={!!part.instance.orphaned || !canSetAsNext} + > + {t('Set as QuickLoop Start')} + + )} + {this.props.playlist?.quickLoop?.end?.type === QuickLoopMarkerType.PART && + this.props.playlist.quickLoop.end.id === part.partId ? ( + this.props.onSetQuickLoopEnd(null, e)}> + {t('Clear QuickLoop End')} + + ) : ( + + this.props.onSetQuickLoopEnd({ type: QuickLoopMarkerType.PART, id: part.instance.part._id }, e) + } + disabled={!!part.instance.orphaned || !canSetAsNext} + > + {t('Set as QuickLoop End')} + + )} )} diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx index e628bb739e..ef49d876e5 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx @@ -236,6 +236,7 @@ export const BUDGET_GAP_PART = { renderedDuration: 0, startsAt: 0, willProbablyAutoNext: false, + previousPartId: null, } export class SegmentTimelineClass extends React.Component>, IStateHeader> { diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx index bfb2e44999..1aed2319ae 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx @@ -31,7 +31,7 @@ import { import { computeSegmentDuration, getPartInstanceTimingId, RundownTimingContext } from '../../lib/rundownTiming' import { RundownViewShelf } from '../RundownView/RundownViewShelf' import { PartInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PartInstances, Parts, Segments } from '../../collections' +import { PartInstances, Segments, UIParts } from '../../collections' import { catchError } from '../../lib/lib' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' @@ -134,7 +134,7 @@ export const SegmentTimelineContainer = withResolvedSegment( componentDidMount(): void { this.autorun(() => { - const partIds = Parts.find( + const partIds = UIParts.find( { segmentId: this.props.segmentId, }, @@ -221,9 +221,8 @@ export const SegmentTimelineContainer = withResolvedSegment( currentNextPart = this.props.parts.find((part) => part.instance._id === this.props.ownNextPartInstance?._id) } autoNextPart = !!( - currentLivePart && - currentLivePart.instance.part.autoNext && - currentLivePart.instance.part.expectedDuration + (currentLivePart && currentLivePart.instance.part.autoNext && currentLivePart.instance.part.expectedDuration) || + this.props.playlist.quickLoop?.running ) if (isNextSegment && !isLiveSegment && !autoNextPart && this.props.ownCurrentPartInstance) { if ( diff --git a/meteor/client/ui/Settings/Studio/Generic.tsx b/meteor/client/ui/Settings/Studio/Generic.tsx index dbf2283dbe..a0477b0cfa 100644 --- a/meteor/client/ui/Settings/Studio/Generic.tsx +++ b/meteor/client/ui/Settings/Studio/Generic.tsx @@ -13,6 +13,7 @@ import { useHistory } from 'react-router-dom' import { MeteorCall } from '../../../../lib/api/methods' import { LabelActual } from '../../../lib/Components/LabelAndOverrides' import { catchError } from '../../../lib/lib' +import { ForceQuickLoopAutoNext } from '@sofie-automation/corelib/src/dataModel/RundownPlaylist' interface IStudioGenericPropertiesProps { studio: DBStudio @@ -255,6 +256,28 @@ export const StudioGenericProperties = withTranslation()( /> + ) diff --git a/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx b/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx index 61dfbf692f..9ce3d1e5e8 100644 --- a/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx +++ b/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx @@ -24,7 +24,7 @@ import { NotificationCenter, Notification, NoticeLevel } from '../../../../../li import { Meteor } from 'meteor/meteor' import { doModalDialog } from '../../../../lib/ModalDialog' import { PartId, RundownId, ShowStyleBaseId, TriggeredActionId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PartInstances, Parts, RundownPlaylists, Rundowns, TriggeredActions } from '../../../../collections' +import { PartInstances, RundownPlaylists, Rundowns, TriggeredActions, UIParts } from '../../../../collections' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { SourceLayers, OutputLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { RundownPlaylistCollectionUtil } from '../../../../../lib/collections/rundownPlaylistUtil' @@ -205,7 +205,7 @@ export const TriggeredActionsEditor: React.FC = function TriggeredAction const currentPartInstance = PartInstances.findOne(rundownPlaylist.currentPartInfo.partInstanceId) if (currentPartInstance) { thisCurrentPart = currentPartInstance.part - thisCurrentSegmentPartIds = Parts.find({ + thisCurrentSegmentPartIds = UIParts.find({ segmentId: currentPartInstance.segmentId, }).map((part) => part._id) } @@ -214,7 +214,7 @@ export const TriggeredActionsEditor: React.FC = function TriggeredAction const nextPartInstance = PartInstances.findOne(rundownPlaylist.nextPartInfo.partInstanceId) if (nextPartInstance) { thisNextPart = nextPartInstance.part - thisNextSegmentPartIds = Parts.find({ + thisNextSegmentPartIds = UIParts.find({ segmentId: nextPartInstance.segmentId, }).map((part) => part._id) } diff --git a/meteor/client/ui/Shelf/NextBreakTimingPanel.tsx b/meteor/client/ui/Shelf/NextBreakTimingPanel.tsx index 1cd37770dc..a8743fca31 100644 --- a/meteor/client/ui/Shelf/NextBreakTimingPanel.tsx +++ b/meteor/client/ui/Shelf/NextBreakTimingPanel.tsx @@ -22,7 +22,7 @@ interface INextBreakTimingPanelProps { export class NextBreakTimingPanelInner extends MeteorReactComponent> { render(): JSX.Element { - const { playlist, panel, layout } = this.props + const { panel, layout } = this.props const isDashboardLayout = RundownLayoutsAPI.isDashboardLayout(layout) @@ -34,7 +34,7 @@ export class NextBreakTimingPanelInner extends MeteorReactComponent - + ) } diff --git a/meteor/lib/Rundown.ts b/meteor/lib/Rundown.ts index c39a3c4610..5b81ab0acc 100644 --- a/meteor/lib/Rundown.ts +++ b/meteor/lib/Rundown.ts @@ -11,7 +11,7 @@ import { } from '@sofie-automation/corelib/dist/playout/infinites' import { invalidateAfter } from '../lib/invalidatingTime' import { getCurrentTime, groupByToMap, ProtectedString, protectString } from './lib' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBRundownPlaylist, QuickLoopMarkerType } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { isTranslatableMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' import { mongoWhereFilter, MongoQuery } from '@sofie-automation/corelib/dist/mongo' @@ -28,6 +28,7 @@ import { RundownPlaylistCollectionUtil } from './collections/rundownPlaylistUtil import { PieceContentStatusObj } from './api/pieceContentStatus' import { ReadonlyDeep } from 'type-fest' import { PieceInstanceWithTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' +import { TimingId } from '../client/lib/rundownTiming' export interface SegmentExtended extends DBSegment { /** Output layers available in the installation used by this segment */ @@ -50,6 +51,7 @@ export interface PartExtended { renderedDuration: number startsAt: number willProbablyAutoNext: boolean + previousPartId: TimingId | null } export interface IOutputLayerExtended extends IOutputLayer { @@ -404,3 +406,28 @@ export function sortAdlibs( return adlibs.map((a) => a.adlib) } + +export function isLoopDefined(playlist?: DBRundownPlaylist): boolean { + return playlist?.quickLoop?.start != null && playlist?.quickLoop?.end != null +} + +export function isLoopRunning(playlist?: DBRundownPlaylist): boolean { + return !!playlist?.quickLoop?.running +} + +export function isEndOfLoopingShow( + playlist: DBRundownPlaylist | undefined, + isLastSegment: boolean, + isPartLastInSegment: boolean, + part: DBPart +): boolean { + return ( + (isLastSegment && + isPartLastInSegment && + isLoopDefined(playlist) && + playlist?.quickLoop?.end?.type === QuickLoopMarkerType.PLAYLIST) || + (playlist?.quickLoop?.end?.type === QuickLoopMarkerType.SEGMENT && + playlist?.quickLoop.end.id === part.segmentId) || + (playlist?.quickLoop?.end?.type === QuickLoopMarkerType.PART && playlist?.quickLoop.end.id === part._id) + ) +} diff --git a/meteor/lib/api/pubsub.ts b/meteor/lib/api/pubsub.ts index 6cb848b0b7..d799eb4000 100644 --- a/meteor/lib/api/pubsub.ts +++ b/meteor/lib/api/pubsub.ts @@ -34,6 +34,7 @@ import { } from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' import { CorelibPubSub, CorelibPubSubCollections, CorelibPubSubTypes } from '@sofie-automation/corelib/dist/pubsub' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' /** * Ids of possible DDP subscriptions for the UI only @@ -158,6 +159,10 @@ export enum MeteorPubSub { * Fetch the Upgrade Statuses of all Blueprints in the system */ uiBlueprintUpgradeStatuses = 'uiBlueprintUpgradeStatuses', + /** + * Fetch all Parts with UI overrides + */ + uiParts = 'uiParts', } /** @@ -237,6 +242,7 @@ export interface MeteorPubSubTypes { bucketId: BucketId ) => CustomCollectionName.UIBucketContentStatuses [MeteorPubSub.uiBlueprintUpgradeStatuses]: () => CustomCollectionName.UIBlueprintUpgradeStatuses + [MeteorPubSub.uiParts]: (playlistId: RundownPlaylistId) => CustomCollectionName.UIParts } export type AllPubSubCollections = PeripheralDevicePubSubCollections & @@ -255,6 +261,7 @@ export enum CustomCollectionName { UIPieceContentStatuses = 'uiPieceContentStatuses', UIBucketContentStatuses = 'uiBucketContentStatuses', UIBlueprintUpgradeStatuses = 'uiBlueprintUpgradeStatuses', + UIParts = 'uiParts', } export type MeteorPubSubCollections = { @@ -283,6 +290,7 @@ export type MeteorPubSubCustomCollections = { [CustomCollectionName.UIPieceContentStatuses]: UIPieceContentStatus [CustomCollectionName.UIBucketContentStatuses]: UIBucketContentStatus [CustomCollectionName.UIBlueprintUpgradeStatuses]: UIBlueprintUpgradeStatus + [CustomCollectionName.UIParts]: DBPart } /** diff --git a/meteor/lib/api/userActions.ts b/meteor/lib/api/userActions.ts index 3619d135b7..742ccd6ad5 100644 --- a/meteor/lib/api/userActions.ts +++ b/meteor/lib/api/userActions.ts @@ -25,6 +25,7 @@ import { SnapshotId, StudioId, } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { QuickLoopMarker } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' export interface NewUserActionAPI extends MethodContext { take( @@ -327,6 +328,18 @@ export interface NewUserActionAPI extends MethodContext { playlistId: RundownPlaylistId, rundownId: RundownId ): Promise> + setQuickLoopStart( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + marker: QuickLoopMarker | null + ): Promise> + setQuickLoopEnd( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + marker: QuickLoopMarker | null + ): Promise> } export enum UserActionAPIMethods { @@ -407,6 +420,9 @@ export enum UserActionAPIMethods { 'disablePeripheralSubDevice' = 'userAction.system.disablePeripheralSubDevice', 'activateScratchpadMode' = 'userAction.activateScratchpadMode', + + 'setQuickLoopStart' = 'userAction.setQuickLoopStart', + 'setQuickLoopEnd' = 'userAction.setQuickLoopEnd', } export interface ReloadRundownPlaylistResponse { diff --git a/meteor/lib/clientUserAction.ts b/meteor/lib/clientUserAction.ts index 609126dfe9..526b2c1e6a 100644 --- a/meteor/lib/clientUserAction.ts +++ b/meteor/lib/clientUserAction.ts @@ -114,6 +114,10 @@ function userActionToLabel(userAction: UserAction, t: i18next.TFunction) { return t('Refreshing debug states') case UserAction.ACTIVATE_SCRATCHPAD: return t('Activate Scratchpad') + case UserAction.SET_QUICK_LOOP_START: + return t('Setting as QuickLoop Start') + case UserAction.SET_QUICK_LOOP_END: + return t('Setting as QuickLoop End') default: assertNever(userAction) } diff --git a/meteor/lib/collections/libCollections.ts b/meteor/lib/collections/libCollections.ts index 9192074ad2..87f61ff6af 100644 --- a/meteor/lib/collections/libCollections.ts +++ b/meteor/lib/collections/libCollections.ts @@ -6,7 +6,11 @@ import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' -import { createSyncMongoCollection, createSyncReadOnlyMongoCollection } from './lib' +import { + createSyncCustomPublicationMongoCollection, + createSyncMongoCollection, + createSyncReadOnlyMongoCollection, +} from './lib' import { DBOrganization } from './Organization' import { PartInstance } from './PartInstances' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' @@ -17,6 +21,7 @@ import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataMod import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { CustomCollectionName } from '../api/pubsub' export const AdLibActions = createSyncReadOnlyMongoCollection(CollectionName.AdLibActions) @@ -32,6 +37,11 @@ export const PartInstances = createSyncReadOnlyMongoCollection(Col export const Parts = createSyncReadOnlyMongoCollection(CollectionName.Parts) +/** + * A playout UI version of Parts. + */ +export const UIParts = createSyncCustomPublicationMongoCollection(CustomCollectionName.UIParts) // TODO + export const RundownBaselineAdLibActions = createSyncReadOnlyMongoCollection( CollectionName.RundownBaselineAdLibActions ) diff --git a/meteor/lib/collections/rundownPlaylistUtil.ts b/meteor/lib/collections/rundownPlaylistUtil.ts index 7edb062387..d9043eb10e 100644 --- a/meteor/lib/collections/rundownPlaylistUtil.ts +++ b/meteor/lib/collections/rundownPlaylistUtil.ts @@ -12,7 +12,7 @@ import { } from '@sofie-automation/corelib/dist/playout/playlist' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import _ from 'underscore' -import { Rundowns, Segments, Parts, PartInstances, Pieces } from './libCollections' +import { Rundowns, Segments, PartInstances, Pieces, UIParts } from './libCollections' import { FindOptions } from './lib' import { PartInstance } from './PartInstances' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' @@ -153,7 +153,7 @@ export class RundownPlaylistCollectionUtil { options?: FindOptions ): DBPart[] { const rundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) - const parts = Parts.find( + const parts = UIParts.find( { ...selector, rundownId: { @@ -204,7 +204,7 @@ export class RundownPlaylistCollectionUtil { } ).fetch() - const parts = Parts.find( + const parts = UIParts.find( { rundownId: { $in: rundownIds, diff --git a/meteor/lib/userAction.ts b/meteor/lib/userAction.ts index 5a816515fe..db1ea3ddd1 100644 --- a/meteor/lib/userAction.ts +++ b/meteor/lib/userAction.ts @@ -50,4 +50,6 @@ export enum UserAction { PERIPHERAL_DEVICE_REFRESH_DEBUG_STATES, ACTIVATE_SCRATCHPAD, QUEUE_NEXT_SEGMENT, + SET_QUICK_LOOP_START, + SET_QUICK_LOOP_END, } diff --git a/meteor/server/api/userActions.ts b/meteor/server/api/userActions.ts index d37c0acbb1..3aa2cb6569 100644 --- a/meteor/server/api/userActions.ts +++ b/meteor/server/api/userActions.ts @@ -48,6 +48,7 @@ import { import { IngestDataCache, Parts, Pieces, Rundowns } from '../collections' import { IngestCacheType } from '@sofie-automation/corelib/dist/dataModel/IngestDataCache' import { verifyHashedToken } from './singleUseTokens' +import { QuickLoopMarker } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' async function pieceSetInOutPoints( access: VerifiedRundownPlaylistContentAccess, @@ -1218,5 +1219,51 @@ class ServerUserActionAPI } ) } + + async setQuickLoopStart( + userEvent: string, + eventTime: number, + playlistId: RundownPlaylistId, + marker: QuickLoopMarker | null + ): Promise> { + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( + this, + userEvent, + eventTime, + playlistId, + () => { + check(playlistId, String) + }, + StudioJobs.SetQuickLoopMarker, + { + playlistId, + marker, + type: 'start', + } + ) + } + + async setQuickLoopEnd( + userEvent: string, + eventTime: number, + playlistId: RundownPlaylistId, + marker: QuickLoopMarker | null + ): Promise> { + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( + this, + userEvent, + eventTime, + playlistId, + () => { + check(playlistId, String) + }, + StudioJobs.SetQuickLoopMarker, + { + playlistId, + marker, + type: 'end', + } + ) + } } registerClassToMeteorMethods(UserActionAPIMethods, ServerUserActionAPI, false) diff --git a/meteor/server/publications/_publications.ts b/meteor/server/publications/_publications.ts index 2fa6145728..bd5120e0f7 100644 --- a/meteor/server/publications/_publications.ts +++ b/meteor/server/publications/_publications.ts @@ -9,6 +9,7 @@ import './packageManager/playoutContext' import './pieceContentStatusUI/bucket/publication' import './pieceContentStatusUI/rundown/publication' import './organization' +import './partsUI/publication' import './peripheralDevice' import './peripheralDeviceForDevice' import './rundown' diff --git a/meteor/server/publications/partsUI/publication.ts b/meteor/server/publications/partsUI/publication.ts new file mode 100644 index 0000000000..fab52fe865 --- /dev/null +++ b/meteor/server/publications/partsUI/publication.ts @@ -0,0 +1,273 @@ +import { PartId, RundownId, RundownPlaylistId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { check } from 'meteor/check' +import { + CustomPublishCollection, + TriggerUpdate, + meteorCustomPublish, + setUpCollectionOptimizedObserver, +} from '../../lib/customPublication' +import { logger } from '../../logging' +import { CustomCollectionName, MeteorPubSub } from '../../../lib/api/pubsub' +import { RundownPlaylistReadAccess } from '../../security/rundownPlaylist' +import { resolveCredentials } from '../../security/lib/credentials' +import { NoSecurityReadAccess } from '../../security/noSecurity' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { ContentCache, PartOmitedFields, createReactiveContentCache } from './reactiveContentCache' +import { ReadonlyDeep } from 'type-fest' +import { LiveQueryHandle } from '../../lib/lib' +import { RundownPlaylists } from '../../collections' +import { literal } from '@sofie-automation/corelib/dist/lib' +import { + DBRundownPlaylist, + ForceQuickLoopAutoNext, + QuickLoopMarker, + QuickLoopMarkerType, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { MongoFieldSpecifierOnesStrict } from '@sofie-automation/corelib/dist/mongo' +import { RundownsObserver } from '../lib/rundownsObserver' +import { RundownContentObserver } from './rundownContentObserver' +import { ProtectedString, protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep' + +interface UIPartsArgs { + readonly playlistId: RundownPlaylistId +} + +export interface UIPartsState { + contentCache: ReadonlyDeep +} + +interface UIPartsUpdateProps { + newCache: ContentCache + + invalidateRundownIds: RundownId[] + invalidateSegmentIds: SegmentId[] + invalidatePartIds: PartId[] + + invalidateQuickLoop: boolean +} + +type RundownPlaylistFields = '_id' | 'studioId' | 'rundownIdsInOrder' +const rundownPlaylistFieldSpecifier = literal< + MongoFieldSpecifierOnesStrict> +>({ + _id: 1, + studioId: 1, + rundownIdsInOrder: 1, +}) + +async function setupUIPartsPublicationObservers( + args: ReadonlyDeep, + triggerUpdate: TriggerUpdate +): Promise { + const playlist = (await RundownPlaylists.findOneAsync(args.playlistId, { + projection: rundownPlaylistFieldSpecifier, + })) as Pick | undefined + if (!playlist) throw new Error(`RundownPlaylist "${args.playlistId}" not found!`) + + const rundownsObserver = new RundownsObserver(playlist.studioId, playlist._id, (rundownIds) => { + logger.silly(`Creating new RundownContentObserver`) + + // TODO - can this be done cheaper? + const cache = createReactiveContentCache() + + // Push update + triggerUpdate({ newCache: cache }) + + const obs1 = new RundownContentObserver(playlist._id, rundownIds, cache) + + const innerQueries = [ + cache.Segments.find({}).observeChanges({ + added: (id) => triggerUpdate({ invalidateSegmentIds: [protectString(id)] }), + changed: (id) => triggerUpdate({ invalidateSegmentIds: [protectString(id)] }), + removed: (id) => triggerUpdate({ invalidateSegmentIds: [protectString(id)] }), + }), + cache.Parts.find({}).observeChanges({ + added: (id) => triggerUpdate({ invalidatePartIds: [protectString(id)] }), + changed: (id) => triggerUpdate({ invalidatePartIds: [protectString(id)] }), + removed: (id) => triggerUpdate({ invalidatePartIds: [protectString(id)] }), + }), + // cache.Rundowns.find({}).observeChanges({ + // added: (id) => triggerUpdate({ invalidateRundownIds: [protectString(id)] }), + // changed: (id) => triggerUpdate({ invalidateRundownIds: [protectString(id)] }), + // removed: (id) => triggerUpdate({ invalidateRundownIds: [protectString(id)] }), + // }), + cache.RundownPlaylists.find({}).observeChanges({ + added: () => triggerUpdate({ invalidateQuickLoop: true }), + changed: () => triggerUpdate({ invalidateQuickLoop: true }), + removed: () => triggerUpdate({ invalidateQuickLoop: true }), + }), + ] + + return () => { + obs1.dispose() + + for (const query of innerQueries) { + query.stop() + } + } + }) + + // Set up observers: + return [rundownsObserver] +} + +export async function manipulateUIPartsPublicationData( + _args: UIPartsArgs, + state: Partial, + collection: CustomPublishCollection, + updateProps: Partial> | undefined +): Promise { + // Prepare data for publication: + + // We know that `collection` does diffing when 'commiting' all of the changes we have made + // meaning that for anything we will call `replace()` on, we can `remove()` it first for no extra cost + + if (updateProps?.newCache !== undefined) { + state.contentCache = updateProps.newCache ?? undefined + } + + if (!state.contentCache) { + // Remove all the notes + collection.remove(null) + + return + } + + const playlist = state.contentCache.RundownPlaylists.find({}).fetch()[0] + if (!playlist) return + + // const segmentsByRundownId = groupByToMap(state.contentCache.Segments.find({}).fetch(), 'rundownId') + // const orderedSegments = playlist.rundownIdsInOrder.flatMap((rundownId) => { + // return segmentsByRundownId.get(rundownId)?.sort((a, b) => a._rank - b._rank) ?? [] + // }) + + // if (!playlist.quickLoop) return // TODO + + // const contentCache = state.contentCache + const rundownRanks = stringsToIndexLookup(playlist.rundownIdsInOrder as unknown as string[]) // TODO: optimize by storing in state? + const segmentRanks = extractRanks(state.contentCache.Segments.find({}).fetch()) // TODO: optimize by storing in state? + + const startPosition = + playlist.quickLoop?.start && + extractMarkerPosition(playlist.quickLoop.start, -Infinity, state.contentCache, rundownRanks) + const endPosition = + playlist.quickLoop?.end && + extractMarkerPosition(playlist.quickLoop.end, Infinity, state.contentCache, rundownRanks) + + const isLoopDefined = playlist.quickLoop?.start && playlist.quickLoop?.end && startPosition && endPosition + // const isLoopRunning = isLoopDefined && playlist.quickLoop?.running + // const isAutoNexting = isLoopRunning // TODO + // Remove all the parts + collection.remove(null) + + // console.log(startPosition, endPosition, state.contentCache.Parts.find({}).fetch().length) + // console.log('a') + state.contentCache.Parts.find({}).forEach((part) => { + const partPosition = extractPartPosition(part, segmentRanks, rundownRanks) + const isLoopingOverriden = + isLoopDefined && + playlist.quickLoop?.forceAutoNext !== ForceQuickLoopAutoNext.DISABLED && + comparePositions(startPosition, partPosition) >= 0 && + comparePositions(partPosition, endPosition) >= 0 + // console.log(isLoopingOverriden, part.title) + if ( + (part.expectedDuration ?? 0) <= 0 && + playlist.quickLoop?.forceAutoNext === ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION + ) { + part.expectedDuration = 3000 // TODO: use settings + part.expectedDurationWithPreroll = 3000 + } + part.autoNext = part.autoNext || (isLoopingOverriden && (part.expectedDuration ?? 0) > 0) + collection.replace(part) + }) + // console.log('b') +} + +const comparePositions = (a: number[], b: number[]): number => { + if (a[0] > b[0]) return -1 + if (a[0] < b[0]) return 1 + if (a[1] > b[1]) return -1 + if (a[1] < b[1]) return 1 + if (a[2] > b[2]) return -1 + if (a[2] < b[2]) return 1 + return 0 +} + +function extractMarkerPosition( + marker: QuickLoopMarker, + fallback: number, + contentCache: ReadonlyObjectDeep, + rundownRanks: Record +): [number, number, number] { + const startPart = marker.type === QuickLoopMarkerType.PART ? contentCache.Parts.findOne(marker.id) : undefined + const startPartRank = startPart?._rank ?? fallback + + const startSegmentId = marker.type === QuickLoopMarkerType.SEGMENT ? marker.id : startPart?.segmentId + const startSegment = startSegmentId && contentCache.Segments.findOne(startSegmentId) + const startSegmentRank = startSegment?._rank ?? fallback + + const startRundownId = marker.type === QuickLoopMarkerType.RUNDOWN ? marker.id : startSegment?.rundownId + let startRundownRank = startRundownId ? rundownRanks[unprotectString(startRundownId)] : fallback + + if (marker.type === QuickLoopMarkerType.PLAYLIST) startRundownRank = fallback + + return [startRundownRank, startSegmentRank, startPartRank] +} + +function extractPartPosition( + part: DBPart, + segmentRanks: Record, + rundownRanks: Record +): [number, number, number] { + return [ + rundownRanks[part.rundownId as unknown as string] ?? 0, + segmentRanks[part.segmentId as unknown as string] ?? 0, + part._rank, + ] +} + +function stringsToIndexLookup(strings: string[]): Record { + return strings.reduce((result, str, index) => { + result[str] = index + return result + }, {} as Record) +} + +function extractRanks(docs: { _id: ProtectedString; _rank: number }[]): Record { + return docs.reduce((result, doc) => { + result[doc._id as unknown as string] = doc._rank + return result + }, {} as Record) +} + +meteorCustomPublish( + MeteorPubSub.uiParts, + CustomCollectionName.UIParts, + async function (pub, playlistId: RundownPlaylistId) { + check(playlistId, String) + + const credentials = await resolveCredentials({ userId: this.userId, token: undefined }) + + if ( + !credentials || + NoSecurityReadAccess.any() || + (await RundownPlaylistReadAccess.rundownPlaylistContent(playlistId, credentials)) + ) { + await setUpCollectionOptimizedObserver< + Omit, + UIPartsArgs, + UIPartsState, + UIPartsUpdateProps + >( + `pub_${MeteorPubSub.uiParts}_${playlistId}`, + { playlistId }, + setupUIPartsPublicationObservers, + manipulateUIPartsPublicationData, + pub + ) + } else { + logger.warn(`Pub.uiParts: Not allowed: "${playlistId}"`) + } + } +) diff --git a/meteor/server/publications/partsUI/reactiveContentCache.ts b/meteor/server/publications/partsUI/reactiveContentCache.ts new file mode 100644 index 0000000000..62de18307e --- /dev/null +++ b/meteor/server/publications/partsUI/reactiveContentCache.ts @@ -0,0 +1,50 @@ +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { ReactiveCacheCollection } from '../lib/ReactiveCacheCollection' +import { literal } from '@sofie-automation/corelib/dist/lib' +import { MongoFieldSpecifierOnesStrict, MongoFieldSpecifierZeroes } from '@sofie-automation/corelib/dist/mongo' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' + +export type RundownPlaylistCompact = Pick +export const rundownPlaylistFieldSpecifier = literal>({ + _id: 1, + activationId: 1, + quickLoop: 1, // so that it invalidates when the markers or state of the loop change + rundownIdsInOrder: 1, // TODO: maybe we can get it from elsewhere? +}) + +// export type RundownFields = '_id' | 'playlistId' +// export const rundownFieldSpecifier = literal>>({ +// _id: 1, +// playlistId: 1, +// }) + +export type SegmentFields = '_id' | '_rank' | 'rundownId' +export const segmentFieldSpecifier = literal>>({ + _id: 1, + _rank: 1, + rundownId: 1, +}) + +export type PartOmitedFields = 'metaData' +export const partFieldSpecifier = literal>>({ + metaData: 0, +}) + +export interface ContentCache { + // Rundowns: ReactiveCacheCollection> + Segments: ReactiveCacheCollection> + Parts: ReactiveCacheCollection> + RundownPlaylists: ReactiveCacheCollection +} + +export function createReactiveContentCache(): ContentCache { + const cache: ContentCache = { + // Rundowns: new ReactiveCacheCollection>('rundowns'), + Segments: new ReactiveCacheCollection>('segments'), + Parts: new ReactiveCacheCollection>('parts'), + RundownPlaylists: new ReactiveCacheCollection('rundownPlaylists'), + } + + return cache +} diff --git a/meteor/server/publications/partsUI/rundownContentObserver.ts b/meteor/server/publications/partsUI/rundownContentObserver.ts new file mode 100644 index 0000000000..df5806c455 --- /dev/null +++ b/meteor/server/publications/partsUI/rundownContentObserver.ts @@ -0,0 +1,74 @@ +import { Meteor } from 'meteor/meteor' +import { RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { logger } from '../../logging' +import { + ContentCache, + partFieldSpecifier, + // rundownFieldSpecifier, + rundownPlaylistFieldSpecifier, + segmentFieldSpecifier, +} from './reactiveContentCache' +import { Parts, RundownPlaylists, Segments } from '../../collections' + +export class RundownContentObserver { + #observers: Meteor.LiveQueryHandle[] = [] + #cache: ContentCache + + constructor(playlistId: RundownPlaylistId, rundownIds: RundownId[], cache: ContentCache) { + logger.silly(`Creating RundownContentObserver for rundowns "${rundownIds.join(',')}"`) + this.#cache = cache + + this.#observers = [ + RundownPlaylists.observeChanges( + { + _id: playlistId, + }, + cache.RundownPlaylists.link(), + { + fields: rundownPlaylistFieldSpecifier, + } + ), + // Rundowns.observeChanges( + // { + // _id: { + // $in: rundownIds, + // }, + // }, + // cache.Rundowns.link(), + // { + // projection: rundownFieldSpecifier, + // } + // ), + Segments.observeChanges( + { + rundownId: { + $in: rundownIds, + }, + }, + cache.Segments.link(), + { + projection: segmentFieldSpecifier, + } + ), + Parts.observeChanges( + { + rundownId: { + $in: rundownIds, + }, + }, + cache.Parts.link(), + { + projection: partFieldSpecifier, + } + ), + ] + } + + public get cache(): ContentCache { + return this.#cache + } + + public dispose = (): void => { + this.#observers.forEach((observer) => observer.stop()) + } +} diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index dc662cc7dc..fcb60d912e 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -42,6 +42,46 @@ export enum RundownHoldState { COMPLETE = 3, // During full, full is played } +export enum QuickLoopMarkerType { + PART = 'part', + SEGMENT = 'segment', + RUNDOWN = 'rundown', + PLAYLIST = 'playlist', +} + +interface QuickLoopPartMarker { + type: QuickLoopMarkerType.PART + id: PartId +} + +interface QuickLoopSegmentMarker { + type: QuickLoopMarkerType.SEGMENT + id: SegmentId +} + +interface QuickLoopRundownMarker { + type: QuickLoopMarkerType.RUNDOWN + id: RundownId +} + +interface QuickLoopPlaylistMarker { + type: QuickLoopMarkerType.PLAYLIST +} + +export type QuickLoopMarker = + | QuickLoopPartMarker + | QuickLoopSegmentMarker + | QuickLoopRundownMarker + | QuickLoopPlaylistMarker + +export enum ForceQuickLoopAutoNext { + /** Parts will auto-next only when explicitly set by the NRCS/blueprints */ + DISABLED = 'disabled', + /** Parts will auto-next when the expected duration is set and within range */ + ENABLED_WHEN_VALID_DURATION = 'enabled_when_valid_duration', + /** All parts will auto-next. If expected duration is undefined or low, the default display duration will be used */ + ENABLED_FORCING_MIN_DURATION = 'enabled_forcing_min_duration', +} export interface DBRundownPlaylist { _id: RundownPlaylistId /** External ID (source) of the playlist */ @@ -93,6 +133,19 @@ export interface DBRundownPlaylist { */ queuedSegmentId?: SegmentId + quickLoop?: { + /** The Start marker */ + start?: QuickLoopMarker + /** The End marker */ + end?: QuickLoopMarker + /** Whether the user is allowed to make alterations to the Start/End markers */ + locked: boolean + /** Whether the loop has two valid markers and is currently running (the current Part is within the loop) */ + running: boolean + /** Whether the loop has autoNext should force auto-next on contained Parts */ + forceAutoNext: ForceQuickLoopAutoNext + } + /** Actual time of playback starting */ startedPlayback?: Time /** Timestamp for the last time an incorrect part was reported as started */ diff --git a/packages/corelib/src/dataModel/Studio.ts b/packages/corelib/src/dataModel/Studio.ts index 19653e22db..371b9dd56d 100644 --- a/packages/corelib/src/dataModel/Studio.ts +++ b/packages/corelib/src/dataModel/Studio.ts @@ -3,6 +3,7 @@ import { ObjectWithOverrides } from '../settings/objectWithOverrides' import { StudioId, OrganizationId, BlueprintId, ShowStyleBaseId, MappingsHash, PeripheralDeviceId } from './Ids' import { BlueprintHash, LastBlueprintConfig } from './Blueprint' import { MappingsExt, MappingExt } from '@sofie-automation/shared-lib/dist/core/model/Timeline' +import { ForceQuickLoopAutoNext } from './RundownPlaylist' export { MappingsExt, MappingExt, MappingsHash } @@ -52,6 +53,9 @@ export interface IStudioSettings { /** Whether to allow scratchpad mode, before a Part is playing in a Playlist */ allowScratchpad?: boolean + + /** If and how to force auto-nexting in a looping Playlist */ + forceQuickLoopAutoNext?: ForceQuickLoopAutoNext } export type StudioLight = Omit diff --git a/packages/corelib/src/worker/studio.ts b/packages/corelib/src/worker/studio.ts index 4d298aa7f6..f7660ecdc7 100644 --- a/packages/corelib/src/worker/studio.ts +++ b/packages/corelib/src/worker/studio.ts @@ -15,6 +15,7 @@ import { JSONBlob } from '@sofie-automation/shared-lib/dist/lib/JSONBlob' import { CoreRundownPlaylistSnapshot } from '../snapshots' import { NoteSeverity } from '@sofie-automation/blueprints-integration' import { ITranslatableMessage } from '../TranslatableMessage' +import { QuickLoopMarker } from '../dataModel/RundownPlaylist' /** List of all Jobs performed by the Worker related to a certain Studio */ export enum StudioJobs { @@ -180,6 +181,11 @@ export enum StudioJobs { * Activate scratchpad mode for the Rundown containing the nexted Part. */ ActivateScratchpad = 'activateScratchpad', + + /** + * Set QuickLoop marker + */ + SetQuickLoopMarker = 'setQuickLoopMarker', } export interface RundownPlayoutPropsBase { @@ -310,6 +316,11 @@ export interface ActivateScratchpadProps extends RundownPlayoutPropsBase { rundownId: RundownId } +export interface SetQuickLoopMarkerProps extends RundownPlayoutPropsBase { + type: 'start' | 'end' + marker: QuickLoopMarker | null +} + /** * Set of valid functions, of form: * `id: (data) => return` @@ -360,6 +371,8 @@ export type StudioJobFunc = { [StudioJobs.BlueprintIgnoreFixUpConfigForStudio]: () => void [StudioJobs.ActivateScratchpad]: (data: ActivateScratchpadProps) => void + + [StudioJobs.SetQuickLoopMarker]: (data: SetQuickLoopMarkerProps) => void } export function getStudioQueueName(id: StudioId): string { diff --git a/packages/job-worker/src/playout/lookahead/util.ts b/packages/job-worker/src/playout/lookahead/util.ts index 3e3de1cbce..8ce4a93eda 100644 --- a/packages/job-worker/src/playout/lookahead/util.ts +++ b/packages/job-worker/src/playout/lookahead/util.ts @@ -58,6 +58,7 @@ export function getOrderedPartsAfterPlayhead( const strippedPlaylist = { queuedSegmentId: alreadyConsumedQueuedSegmentId ? undefined : playlist.queuedSegmentId, loop: playlist.loop, + quickLoop: playlist.quickLoop, } const nextNextPart = selectNextPart( context, diff --git a/packages/job-worker/src/playout/model/PlayoutModel.ts b/packages/job-worker/src/playout/model/PlayoutModel.ts index b82a99384d..c88f53fe02 100644 --- a/packages/job-worker/src/playout/model/PlayoutModel.ts +++ b/packages/job-worker/src/playout/model/PlayoutModel.ts @@ -13,6 +13,7 @@ import { ABSessionAssignments, ABSessionInfo, DBRundownPlaylist, + QuickLoopMarker, RundownHoldState, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { ReadonlyDeep } from 'type-fest' @@ -234,6 +235,11 @@ export interface PlayoutModel extends PlayoutModelReadonly, StudioPlayoutModelBa */ cycleSelectedPartInstances(): void + /** + * Update loop markers anytime something sinificant occurs that could result in entering or exiting the mode. + */ + updateQuickLoopState(): void + /** * Set the RundownPlaylist as deactivated */ @@ -313,6 +319,13 @@ export interface PlayoutModel extends PlayoutModelReadonly, StudioPlayoutModelBa */ setRundownStartedPlayback(rundownId: RundownId, timestamp: number): void + /** + * Set or clear a QuickLoop Marker + * @param type + * @param marker + */ + setQuickLoopMarker(type: 'start' | 'end', marker: QuickLoopMarker | null): void + /** Lifecycle */ /** diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 380ca28514..08f932d349 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -14,6 +14,9 @@ import { ABSessionAssignments, ABSessionInfo, DBRundownPlaylist, + ForceQuickLoopAutoNext, + QuickLoopMarker, + QuickLoopMarkerType, RundownHoldState, SelectedPartInstance, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' @@ -55,6 +58,9 @@ import { ExpectedPackageDBFromStudioBaselineObjects } from '@sofie-automation/co import { ExpectedPlayoutItemStudio } from '@sofie-automation/corelib/dist/dataModel/ExpectedPlayoutItem' import { StudioBaselineHelper } from '../../../studio/model/StudioBaselineHelper' import { EventsJobs } from '@sofie-automation/corelib/dist/worker/events' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep' +import { IBlueprintMutatablePart } from '@sofie-automation/blueprints-integration' export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { public readonly playlistId: RundownPlaylistId @@ -290,6 +296,16 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } + clearQuickLoopMarkers(): void { + if (!this.playlistImpl.quickLoop || this.playlistImpl.quickLoop.locked) return + + this.playlistImpl.quickLoop.start = undefined + this.playlistImpl.quickLoop.end = undefined + this.playlistImpl.quickLoop.running = false + + this.#playlistHasChanged = true + } + #fixupPieceInstancesForPartInstance(partInstance: DBPartInstance, pieceInstances: PieceInstance[]): void { for (const pieceInstance of pieceInstances) { // Future: should these be PieceInstance already, or should that be handled here? @@ -428,10 +444,162 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } + updateQuickLoopState(): void { + if (this.playlistImpl.quickLoop == null) return + if (this.playlistImpl.quickLoop.start == null || this.playlistImpl.quickLoop.end == null) return + + const orderedParts = this.getAllOrderedParts() + // let startPart + // let endPart + // let startSegment + // let endSegment + // const startPartIndex = -1 + // const endPartIndex = -1 + const rundownIds = this.getRundownIds() + type Positions = { + partRank: number + segmentRank: number + rundownRank: number + } + const findMarkerPosition = (marker: QuickLoopMarker, type: 'start' | 'end'): Positions => { + let part: ReadonlyObjectDeep | undefined + let segment: ReadonlyObjectDeep | undefined + let rundownRank + if (marker.type === QuickLoopMarkerType.PART) { + const partId = marker.id + const partIndex = orderedParts.findIndex((part) => part._id === partId) + part = orderedParts[partIndex] + } + if (marker.type === QuickLoopMarkerType.SEGMENT) { + segment = this.findSegment(marker.id)?.segment + } else if (part != null) { + segment = this.findSegment(part.segmentId)?.segment + } + if (marker.type === QuickLoopMarkerType.RUNDOWN) { + rundownRank = rundownIds.findIndex((id) => id === marker.id) + } else if (part ?? segment != null) { + rundownRank = rundownIds.findIndex((id) => id === (part ?? segment)?.rundownId) + } + const fallback = type === 'start' ? -Infinity : Infinity + return { + partRank: part?._rank ?? fallback, + segmentRank: segment?._rank ?? fallback, + rundownRank: rundownRank ?? fallback, + } + } + + const startPosition = findMarkerPosition(this.playlistImpl.quickLoop.start, 'start') + const endPosition = findMarkerPosition(this.playlistImpl.quickLoop.end, 'end') + + const comparePositions = (a: Positions, b: Positions): number => { + if (a.rundownRank > b.rundownRank) return -1 + if (a.rundownRank < b.rundownRank) return 1 + if (a.segmentRank > b.segmentRank) return -1 + if (a.segmentRank < b.segmentRank) return 1 + if (a.partRank > b.partRank) return -1 + if (a.partRank < b.partRank) return 1 + return 0 + } + + // if (this.playlistImpl.quickLoop.start.type === QuickLoopMarkerType.PART) { + // const startPartId = this.playlistImpl.quickLoop.start.id + // startPartIndex = orderedParts.findIndex((part) => part._id === startPartId) + // startPart = orderedParts[startPartIndex] + // } + // if (this.playlistImpl.quickLoop.start.type === QuickLoopMarkerType.SEGMENT) { + // startSegment = this.findSegment(this.playlistImpl.quickLoop.start.id)?.segment + // } else if (startPart != null) { + // startSegment = this.findSegment(startPart.segmentId)?.segment + // } + + // // TODO: Compare ranks first of rundowns, then of segments, then of parts + // if (this.playlistImpl.quickLoop.end.type === QuickLoopMarkerType.PART) { + // const endPartId = this.playlistImpl.quickLoop.end.id + // endPartIndex = orderedParts.findIndex((part) => part._id === endPartId) + // endPart = orderedParts[startPartIndex] + // } + // if (this.playlistImpl.quickLoop.end.type === QuickLoopMarkerType.SEGMENT) { + // endSegment = this.findSegment(this.playlistImpl.quickLoop.end.id)?.segment + // } else if (endPart != null) { + // endSegment = this.findSegment(endPart.segmentId)?.segment + // } + // if (startPartIndex < 0) this.playlistImpl.quickLoop.start = undefined + // if (endPartIndex < 0) this.playlistImpl.quickLoop.end = undefined + // if (startPartIndex > endPartIndex) { + // this.playlistImpl.quickLoop.start = undefined + // this.playlistImpl.quickLoop.end = undefined + // } + + const extractPartPosition = (partInstance: PlayoutPartInstanceModel) => { + const currentSegment = this.findSegment(partInstance.partInstance.segmentId)?.segment + const currentRundownIndex = rundownIds.findIndex((id) => id === partInstance.partInstance.rundownId) + + return { + partRank: partInstance.partInstance.part._rank, + segmentRank: currentSegment?._rank ?? 0, + rundownRank: currentRundownIndex ?? 0, + } + } + + const currentPartPosition: Positions | undefined = this.currentPartInstance + ? extractPartPosition(this.currentPartInstance) + : undefined + const nextPartPosition: Positions | undefined = this.nextPartInstance + ? extractPartPosition(this.nextPartInstance) + : undefined + + const isCurrentBetweenMarkers = currentPartPosition + ? comparePositions(startPosition, currentPartPosition) >= 0 && + comparePositions(currentPartPosition, endPosition) >= 0 + : false + const isNextBetweenMarkers = nextPartPosition + ? comparePositions(startPosition, nextPartPosition) >= 0 && + comparePositions(nextPartPosition, endPosition) >= 0 + : false + + this.playlistImpl.quickLoop.running = + this.playlistImpl.quickLoop.start != null && + this.playlistImpl.quickLoop.end != null && + isCurrentBetweenMarkers // && + // currentPartInstance?.partInstance.orphaned != null //|| + // (currentPartIndex >= startPartIndex && currentPartIndex <= endPartIndex)) + if (this.playlistImpl.quickLoop.running && this.currentPartInstance) { + updatePartOverrides(this.currentPartInstance, this.playlistImpl.quickLoop.forceAutoNext) + } + if (this.nextPartInstance && isNextBetweenMarkers) { + updatePartOverrides(this.nextPartInstance, this.playlistImpl.quickLoop.forceAutoNext) + } + this.#playlistHasChanged = true + + console.log('isNextBetweenMarkers', isNextBetweenMarkers, nextPartPosition) + + function updatePartOverrides(partInstance: PlayoutPartInstanceModel, forceAutoNext: ForceQuickLoopAutoNext) { + const partPropsToUpdate: Partial> = {} + if ( + !partInstance.partInstance.part.expectedDuration && + forceAutoNext === ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION + ) { + partPropsToUpdate.expectedDuration = 3000 // TODO: where to take the default duration from? + } + if ( + (partInstance.partInstance.part.expectedDuration || partPropsToUpdate.expectedDuration) && + forceAutoNext !== ForceQuickLoopAutoNext.DISABLED && + !partInstance.partInstance.part.autoNext + ) { + partPropsToUpdate.autoNext = true + } + if (Object.keys(partPropsToUpdate).length) { + partInstance.updatePartProps(partPropsToUpdate) + if (partPropsToUpdate.expectedDuration) partInstance.recalculateExpectedDurationWithPreroll() + } + } + } + deactivatePlaylist(): void { delete this.playlistImpl.activationId this.clearSelectedPartInstances() + this.clearQuickLoopMarkers() this.#playlistHasChanged = true } @@ -684,6 +852,36 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#baselineHelper.setExpectedPlayoutItems(playoutItems) } + setQuickLoopMarker(type: 'start' | 'end', marker: QuickLoopMarker | null): void { + if (this.playlistImpl.quickLoop?.locked) { + throw new Error('Looping is locked') + } + this.playlistImpl.quickLoop = { + running: false, + locked: false, + ...this.playlistImpl.quickLoop, + forceAutoNext: this.context.studio.settings.forceQuickLoopAutoNext ?? ForceQuickLoopAutoNext.DISABLED, + } + if (type === 'start') { + if (marker == null) { + delete this.playlistImpl.quickLoop.start + this.playlistImpl.quickLoop.running = false + } else { + this.playlistImpl.quickLoop.start = marker + } + } else { + if (marker == null) { + delete this.playlistImpl.quickLoop.end + this.playlistImpl.quickLoop.running = false + } else { + this.playlistImpl.quickLoop.end = marker + } + } + + this.updateQuickLoopState() + this.#playlistHasChanged = true + } + /** Lifecycle */ /** @deprecated */ diff --git a/packages/job-worker/src/playout/quickLoopMarkers.ts b/packages/job-worker/src/playout/quickLoopMarkers.ts new file mode 100644 index 0000000000..0ebd8e441a --- /dev/null +++ b/packages/job-worker/src/playout/quickLoopMarkers.ts @@ -0,0 +1,21 @@ +import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' +import { SetQuickLoopMarkerProps } from '@sofie-automation/corelib/dist/worker/studio' +import { JobContext } from '../jobs' +import { runJobWithPlayoutModel } from './lock' + +export async function handleSetQuickLoopMarker(context: JobContext, data: SetQuickLoopMarkerProps): Promise { + return runJobWithPlayoutModel( + context, + data, + async (playoutModel) => { + const playlist = playoutModel.playlist + if (!playlist.activationId) throw UserError.create(UserErrorMessage.InactiveRundown) + }, + async (playoutModel) => { + const playlist = playoutModel.playlist + if (!playlist.activationId) throw new Error(`Playlist has no activationId!`) + + playoutModel.setQuickLoopMarker(data.type, data.marker) + } + ) +} diff --git a/packages/job-worker/src/playout/selectNextPart.ts b/packages/job-worker/src/playout/selectNextPart.ts index 607574c4d1..024594f5d0 100644 --- a/packages/job-worker/src/playout/selectNextPart.ts +++ b/packages/job-worker/src/playout/selectNextPart.ts @@ -1,7 +1,7 @@ import { DBPart, isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' import { JobContext } from '../jobs' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBRundownPlaylist, QuickLoopMarkerType } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReadonlyDeep } from 'type-fest' import { PlayoutSegmentModel } from './model/PlayoutSegmentModel' @@ -33,7 +33,7 @@ export interface SelectNextPartResult { export function selectNextPart( context: JobContext, - rundownPlaylist: Pick, + rundownPlaylist: Pick, previousPartInstance: ReadonlyDeep | null, currentlySelectedPartInstance: ReadonlyDeep | null, segments: readonly PlayoutSegmentModel[], @@ -68,6 +68,42 @@ export function selectNextPart( return undefined } + const findQuickLoopStartPart = (length: number): SelectNextPartResult | undefined => { + if (rundownPlaylist.quickLoop?.start?.type === QuickLoopMarkerType.PART) { + const startPartId = rundownPlaylist.quickLoop.start.id + return findFirstPlayablePart(0, (part) => part._id === startPartId, length) + } + if (rundownPlaylist.quickLoop?.start?.type === QuickLoopMarkerType.SEGMENT) { + const startSegmentId = rundownPlaylist.quickLoop.start.id + return findFirstPlayablePart(0, (part) => part.segmentId === startSegmentId, length) + } + if (rundownPlaylist.quickLoop?.start?.type === QuickLoopMarkerType.PLAYLIST) { + return findFirstPlayablePart(0, undefined, length) + } + return undefined + } + + if (rundownPlaylist.quickLoop?.running && previousPartInstance) { + const currentIndex = parts.findIndex((p) => p._id === previousPartInstance.part._id) + if ( + rundownPlaylist.quickLoop?.end?.type === QuickLoopMarkerType.PART && + previousPartInstance.part._id === rundownPlaylist.quickLoop.end.id + ) { + return findQuickLoopStartPart(currentIndex + 1) ?? null + } else if ( + rundownPlaylist.quickLoop?.end?.type === QuickLoopMarkerType.SEGMENT && + previousPartInstance.part.segmentId === rundownPlaylist.quickLoop.end.id + ) { + const nextPlayablePart = findFirstPlayablePart( + currentIndex + 1, + (part) => part.segmentId === previousPartInstance.part.segmentId + ) + if (!nextPlayablePart) { + findQuickLoopStartPart(currentIndex + 1) ?? null + } + } + } + let searchFromIndex = 0 if (previousPartInstance) { const currentIndex = parts.findIndex((p) => p._id === previousPartInstance.part._id) @@ -145,6 +181,10 @@ export function selectNextPart( nextPart = findFirstPlayablePart(0, undefined, searchFromIndex - 1) } + if (rundownPlaylist.quickLoop?.end?.type === QuickLoopMarkerType.PLAYLIST && !nextPart && previousPartInstance) { + nextPart = findQuickLoopStartPart(searchFromIndex - 1) + } + if (span) span.end() return nextPart ?? null } diff --git a/packages/job-worker/src/playout/setNext.ts b/packages/job-worker/src/playout/setNext.ts index d2a6e0da78..fe006673b3 100644 --- a/packages/job-worker/src/playout/setNext.ts +++ b/packages/job-worker/src/playout/setNext.ts @@ -121,6 +121,8 @@ export async function setNextPart( resetPartInstancesWhenChangingSegment(context, playoutModel) + playoutModel.updateQuickLoopState() + await cleanupOrphanedItems(context, playoutModel) if (span) span.end() diff --git a/packages/job-worker/src/playout/timings/partPlayback.ts b/packages/job-worker/src/playout/timings/partPlayback.ts index 39194e2088..71f493a516 100644 --- a/packages/job-worker/src/playout/timings/partPlayback.ts +++ b/packages/job-worker/src/playout/timings/partPlayback.ts @@ -58,6 +58,7 @@ export async function onPartPlaybackStarted( // this is the next part, clearly an autoNext has taken place playoutModel.cycleSelectedPartInstances() + playoutModel.updateQuickLoopState() reportPartInstanceHasStarted(context, playoutModel, playingPartInstance, data.startedPlayback) diff --git a/packages/job-worker/src/workers/studio/jobs.ts b/packages/job-worker/src/workers/studio/jobs.ts index f97b9a9dea..6c107b5e7e 100644 --- a/packages/job-worker/src/workers/studio/jobs.ts +++ b/packages/job-worker/src/workers/studio/jobs.ts @@ -45,6 +45,7 @@ import { handleTimelineTriggerTime, handleOnPlayoutPlaybackChanged } from '../.. import { handleExecuteAdlibAction } from '../../playout/adlibAction' import { handleTakeNextPart } from '../../playout/take' import { handleActivateScratchpad } from '../../playout/scratchpad' +import { handleSetQuickLoopMarker } from '../../playout/quickLoopMarkers' type ExecutableFunction = ( context: JobContext, @@ -101,4 +102,6 @@ export const studioJobHandlers: StudioJobHandlers = { [StudioJobs.BlueprintIgnoreFixUpConfigForStudio]: handleBlueprintIgnoreFixUpConfigForStudio, [StudioJobs.ActivateScratchpad]: handleActivateScratchpad, + + [StudioJobs.SetQuickLoopMarker]: handleSetQuickLoopMarker, } From 199b8b1cc20639fdaabb32682e8d41220bde6a1b Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 14 Dec 2023 10:42:51 +0100 Subject: [PATCH 002/276] wip(SOFIE-69): appearance of the looping section --- meteor/client/styles/rundownView.scss | 71 ++++++++++++++++++- .../RundownTiming/RundownTimingProvider.tsx | 2 +- meteor/client/ui/SegmentList/SegmentList.scss | 2 +- .../SegmentStoryboard/SegmentStoryboard.scss | 2 +- .../Parts/SegmentTimelinePart.tsx | 24 +++++-- meteor/lib/Rundown.ts | 13 ++-- 6 files changed, 97 insertions(+), 17 deletions(-) diff --git a/meteor/client/styles/rundownView.scss b/meteor/client/styles/rundownView.scss index 7f8829d413..591c45b55a 100644 --- a/meteor/client/styles/rundownView.scss +++ b/meteor/client/styles/rundownView.scss @@ -1392,6 +1392,11 @@ svg.icon { z-index: 30; } + &.quickloop-start { + left: 1px; + border-left: 1px solid white; + } + .segment-timeline__part__nextline__label { display: none; position: absolute; @@ -1435,6 +1440,35 @@ svg.icon { left: -2px; @include take-arrow(); } + + .segment-timeline__part__nextline__quickloop-start { + border-left: 3px solid white; + display: block; + position: absolute; + top: -2px; + bottom: 0; + left: -6px; + } + } + + .segment-timeline__part__nextline__quickloop-end { + border-right: 3px solid white; + display: block; + position: absolute; + top: -12px; + bottom: 0; + right: -2px; + z-index: 40; + + &::before { + content: ' '; + border-left: 1px solid white; + display: block; + position: absolute; + top: 0; + bottom: 0; + right: 2px; + } } .segment-timeline__part__invalid-cover { @@ -1512,6 +1546,10 @@ svg.icon { z-index: -1; } + &.quickloop-end { + z-index: 1; + } + &.out-of-the-loop { &::before { content: ' '; @@ -1521,7 +1559,7 @@ svg.icon { left: 0; bottom: 0; right: 0; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.5); z-index: 2; pointer-events: none; } @@ -1744,6 +1782,37 @@ svg.icon { } } + .segment-timeline__part__quickloop-line { + min-height: $segment-layer-height; + line-height: $segment-layer-height; + font-weight: 400; + + .segment-timeline__part__quickloop-background { + width: 100%; + position: absolute; + min-height: $segment-layer-height; + background-image: linear-gradient(to right, white 20%, rgba(255, 255, 255, 0) 0%); + background-position: center left; + background-size: 10px 2px; + background-repeat: repeat-x; + } + + .segment-timeline__part__quickloop-start, .segment-timeline__part__quickloop-end { + position: absolute; + background: $segment-background-color; + padding: 0 0.3em; + white-space: nowrap; + } + + .segment-timeline__part__quickloop-start { + left: 0; + } + + .segment-timeline__part__quickloop-end { + right: 0; + } + } + // Group all layers belonging to an output group .segment-timeline__output-group { min-height: $segment-layer-height; diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index 205df90569..c01847abba 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -378,7 +378,7 @@ function findPartInstancesInQuickLoop( playlist.quickLoop.end.id === previousPartInstance!.rundownId)) ) { isInQuickLoop = false - break + // a `break` should be here, but it can't because when looping over a single part we need to include the three instances of that part shown at once } if ( !isInQuickLoop && diff --git a/meteor/client/ui/SegmentList/SegmentList.scss b/meteor/client/ui/SegmentList/SegmentList.scss index d92e8d03df..e530c03386 100644 --- a/meteor/client/ui/SegmentList/SegmentList.scss +++ b/meteor/client/ui/SegmentList/SegmentList.scss @@ -205,7 +205,7 @@ $identifier-area-width: 3em; left: 0; bottom: 0; right: 0; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.5); z-index: 2; pointer-events: none; } diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss index ea61e8bff7..9aa8c8c860 100644 --- a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss +++ b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss @@ -175,7 +175,7 @@ $break-width: 35rem; left: 0; bottom: 0; right: 0; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.5); z-index: 2; pointer-events: none; } diff --git a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index 7951bde518..52c4c3a87b 100644 --- a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -612,6 +612,7 @@ export class SegmentTimelinePartClass extends React.Component )} + {} ) } @@ -693,6 +694,7 @@ export class SegmentTimelinePartClass extends React.Component ) : null} {innerPart.floated ?
: null} - {isQuickLoopStart ?
LOOP START
: null} - {isQuickLoopEnd ? ( -
- LOOP END -
- ) : null} +
+ {isPartInQuickLoop &&
} + {isQuickLoopStart ? ( +
+ START +
+ ) : null} + {isQuickLoopEnd ? ( +
+ END +
+ ) : null} +
{this.props.playlist.nextTimeOffset && this.state.isNext && ( // This is the off-set line
} {this.props.isAfterLastValidInSegmentAndItsLive && isPlaylistLooping && }
+ {isQuickLoopStart &&
} {!this.props.isPreview && this.props.part.instance.part.identifier && (
{this.props.part.instance.part.identifier}
)}
)} + {isQuickLoopEnd &&
} {this.renderEndOfSegment(t, innerPart, isEndOfShow, endOfLoopingShow)}
) diff --git a/meteor/lib/Rundown.ts b/meteor/lib/Rundown.ts index 5b81ab0acc..6df0ff5851 100644 --- a/meteor/lib/Rundown.ts +++ b/meteor/lib/Rundown.ts @@ -422,12 +422,11 @@ export function isEndOfLoopingShow( part: DBPart ): boolean { return ( - (isLastSegment && - isPartLastInSegment && - isLoopDefined(playlist) && - playlist?.quickLoop?.end?.type === QuickLoopMarkerType.PLAYLIST) || - (playlist?.quickLoop?.end?.type === QuickLoopMarkerType.SEGMENT && - playlist?.quickLoop.end.id === part.segmentId) || - (playlist?.quickLoop?.end?.type === QuickLoopMarkerType.PART && playlist?.quickLoop.end.id === part._id) + isPartLastInSegment && + isLoopDefined(playlist) && + ((isLastSegment && playlist?.quickLoop?.end?.type === QuickLoopMarkerType.PLAYLIST) || + (playlist?.quickLoop?.end?.type === QuickLoopMarkerType.SEGMENT && + playlist?.quickLoop.end.id === part.segmentId) || + (playlist?.quickLoop?.end?.type === QuickLoopMarkerType.PART && playlist?.quickLoop.end.id === part._id)) ) } From 225b03caed6779276b897a2b56fce3e2cc3108c3 Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 19 Dec 2023 10:59:52 +0100 Subject: [PATCH 003/276] wip(SOFIE-69): improve finding next part --- meteor/client/styles/rundownView.scss | 11 +- .../Parts/SegmentTimelinePart.tsx | 6 +- .../SegmentTimelineContainer.tsx | 5 +- packages/job-worker/src/ingest/updateNext.ts | 4 +- .../src/playout/activePlaylistActions.ts | 4 +- packages/job-worker/src/playout/adlibUtils.ts | 3 +- packages/job-worker/src/playout/lib.ts | 4 +- .../job-worker/src/playout/lookahead/util.ts | 4 +- .../model/implementation/PlayoutModelImpl.ts | 210 ++++++++---------- .../job-worker/src/playout/selectNextPart.ts | 25 ++- packages/job-worker/src/playout/take.ts | 21 +- .../src/playout/timings/partPlayback.ts | 6 +- 12 files changed, 156 insertions(+), 147 deletions(-) diff --git a/meteor/client/styles/rundownView.scss b/meteor/client/styles/rundownView.scss index 591c45b55a..336c39c934 100644 --- a/meteor/client/styles/rundownView.scss +++ b/meteor/client/styles/rundownView.scss @@ -1445,7 +1445,7 @@ svg.icon { border-left: 3px solid white; display: block; position: absolute; - top: -2px; + top: 0; bottom: 0; left: -6px; } @@ -1456,7 +1456,7 @@ svg.icon { display: block; position: absolute; top: -12px; - bottom: 0; + bottom: -1.3em; right: -2px; z-index: 40; @@ -1471,6 +1471,8 @@ svg.icon { } } + &:first-child + .segment-timeline__part__invalid-cover { position: absolute; top: 0; @@ -1786,10 +1788,14 @@ svg.icon { min-height: $segment-layer-height; line-height: $segment-layer-height; font-weight: 400; + position: absolute; + bottom: -$segment-layer-height; + width: 100%; .segment-timeline__part__quickloop-background { width: 100%; position: absolute; + bottom: -1px; min-height: $segment-layer-height; background-image: linear-gradient(to right, white 20%, rgba(255, 255, 255, 0) 0%); background-position: center left; @@ -1799,6 +1805,7 @@ svg.icon { .segment-timeline__part__quickloop-start, .segment-timeline__part__quickloop-end { position: absolute; + bottom: -1px; background: $segment-background-color; padding: 0 0.3em; white-space: nowrap; diff --git a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index 52c4c3a87b..192676aa83 100644 --- a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -724,12 +724,14 @@ export class SegmentTimelinePartClass extends React.Component
} {isQuickLoopStart ? (
- START + START +
) : null} {isQuickLoopEnd ? (
- END + + END
) : null} diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx index 1aed2319ae..12dd31234c 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx @@ -221,8 +221,9 @@ export const SegmentTimelineContainer = withResolvedSegment( currentNextPart = this.props.parts.find((part) => part.instance._id === this.props.ownNextPartInstance?._id) } autoNextPart = !!( - (currentLivePart && currentLivePart.instance.part.autoNext && currentLivePart.instance.part.expectedDuration) || - this.props.playlist.quickLoop?.running + currentLivePart && + currentLivePart.instance.part.autoNext && + currentLivePart.instance.part.expectedDuration ) if (isNextSegment && !isLiveSegment && !autoNextPart && this.props.ownCurrentPartInstance) { if ( diff --git a/packages/job-worker/src/ingest/updateNext.ts b/packages/job-worker/src/ingest/updateNext.ts index 8fe4157ea2..1790840f3b 100644 --- a/packages/job-worker/src/ingest/updateNext.ts +++ b/packages/job-worker/src/ingest/updateNext.ts @@ -49,7 +49,9 @@ export async function ensureNextPartIsValid(context: JobContext, playoutModel: P currentPartInstance.partInstance, nextPartInstance.partInstance, orderedSegments, - orderedParts + orderedParts, + false, + false ) if ( diff --git a/packages/job-worker/src/playout/activePlaylistActions.ts b/packages/job-worker/src/playout/activePlaylistActions.ts index 79e709c534..8cde6311b5 100644 --- a/packages/job-worker/src/playout/activePlaylistActions.ts +++ b/packages/job-worker/src/playout/activePlaylistActions.ts @@ -57,7 +57,9 @@ export async function activateRundownPlaylist( null, null, playoutModel.getAllOrderedSegments(), - playoutModel.getAllOrderedParts() + playoutModel.getAllOrderedParts(), + false, + false ) await setNextPart(context, playoutModel, firstPart, false) diff --git a/packages/job-worker/src/playout/adlibUtils.ts b/packages/job-worker/src/playout/adlibUtils.ts index 65b65b4dff..37eecd3d8d 100644 --- a/packages/job-worker/src/playout/adlibUtils.ts +++ b/packages/job-worker/src/playout/adlibUtils.ts @@ -203,7 +203,8 @@ function updateRankForAdlibbedPartInstance( null, playoutModel.getAllOrderedSegments(), playoutModel.getAllOrderedParts(), - false // We want to insert it before any trailing invalid piece + false, // We want to insert it before any trailing invalid piece + true // We want to insert it at the end of the loop ) newPartInstance.setRank( getRank( diff --git a/packages/job-worker/src/playout/lib.ts b/packages/job-worker/src/playout/lib.ts index 3214165b2b..6cd3766939 100644 --- a/packages/job-worker/src/playout/lib.ts +++ b/packages/job-worker/src/playout/lib.ts @@ -41,7 +41,9 @@ export async function resetRundownPlaylist(context: JobContext, playoutModel: Pl null, null, playoutModel.getAllOrderedSegments(), - playoutModel.getAllOrderedParts() + playoutModel.getAllOrderedParts(), + false, + false ) await setNextPart(context, playoutModel, firstPart, false) } else { diff --git a/packages/job-worker/src/playout/lookahead/util.ts b/packages/job-worker/src/playout/lookahead/util.ts index 8ce4a93eda..0733698c04 100644 --- a/packages/job-worker/src/playout/lookahead/util.ts +++ b/packages/job-worker/src/playout/lookahead/util.ts @@ -66,7 +66,9 @@ export function getOrderedPartsAfterPlayhead( nextPartInstance ?? currentPartInstance ?? null, null, orderedSegments, - orderedParts + orderedParts, + false, + false ) if (!nextNextPart) { // We don't know where to begin searching, so we can't do anything diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 08f932d349..ea8da00ce2 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -446,133 +446,113 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou updateQuickLoopState(): void { if (this.playlistImpl.quickLoop == null) return - if (this.playlistImpl.quickLoop.start == null || this.playlistImpl.quickLoop.end == null) return - - const orderedParts = this.getAllOrderedParts() - // let startPart - // let endPart - // let startSegment - // let endSegment - // const startPartIndex = -1 - // const endPartIndex = -1 - const rundownIds = this.getRundownIds() - type Positions = { - partRank: number - segmentRank: number - rundownRank: number - } - const findMarkerPosition = (marker: QuickLoopMarker, type: 'start' | 'end'): Positions => { - let part: ReadonlyObjectDeep | undefined - let segment: ReadonlyObjectDeep | undefined - let rundownRank - if (marker.type === QuickLoopMarkerType.PART) { - const partId = marker.id - const partIndex = orderedParts.findIndex((part) => part._id === partId) - part = orderedParts[partIndex] + const wasLoopRunning = this.playlistImpl.quickLoop.running + + let isNextBetweenMarkers = false + if (this.playlistImpl.quickLoop.start == null || this.playlistImpl.quickLoop.end == null) { + this.playlistImpl.quickLoop.running = false + } else { + const orderedParts = this.getAllOrderedParts() + + const rundownIds = this.getRundownIds() + type Positions = { + partRank: number + segmentRank: number + rundownRank: number } - if (marker.type === QuickLoopMarkerType.SEGMENT) { - segment = this.findSegment(marker.id)?.segment - } else if (part != null) { - segment = this.findSegment(part.segmentId)?.segment + const findMarkerPosition = (marker: QuickLoopMarker, type: 'start' | 'end'): Positions => { + let part: ReadonlyObjectDeep | undefined + let segment: ReadonlyObjectDeep | undefined + let rundownRank + if (marker.type === QuickLoopMarkerType.PART) { + const partId = marker.id + const partIndex = orderedParts.findIndex((part) => part._id === partId) + part = orderedParts[partIndex] + } + if (marker.type === QuickLoopMarkerType.SEGMENT) { + segment = this.findSegment(marker.id)?.segment + } else if (part != null) { + segment = this.findSegment(part.segmentId)?.segment + } + if (marker.type === QuickLoopMarkerType.RUNDOWN) { + rundownRank = rundownIds.findIndex((id) => id === marker.id) + } else if (part ?? segment != null) { + rundownRank = rundownIds.findIndex((id) => id === (part ?? segment)?.rundownId) + } + const fallback = type === 'start' ? -Infinity : Infinity + return { + partRank: part?._rank ?? fallback, + segmentRank: segment?._rank ?? fallback, + rundownRank: rundownRank ?? fallback, + } } - if (marker.type === QuickLoopMarkerType.RUNDOWN) { - rundownRank = rundownIds.findIndex((id) => id === marker.id) - } else if (part ?? segment != null) { - rundownRank = rundownIds.findIndex((id) => id === (part ?? segment)?.rundownId) + + const startPosition = findMarkerPosition(this.playlistImpl.quickLoop.start, 'start') + const endPosition = findMarkerPosition(this.playlistImpl.quickLoop.end, 'end') + + const comparePositions = (a: Positions, b: Positions): number => { + if (a.rundownRank > b.rundownRank) return -1 + if (a.rundownRank < b.rundownRank) return 1 + if (a.segmentRank > b.segmentRank) return -1 + if (a.segmentRank < b.segmentRank) return 1 + if (a.partRank > b.partRank) return -1 + if (a.partRank < b.partRank) return 1 + return 0 } - const fallback = type === 'start' ? -Infinity : Infinity - return { - partRank: part?._rank ?? fallback, - segmentRank: segment?._rank ?? fallback, - rundownRank: rundownRank ?? fallback, + + const extractPartPosition = (partInstance: PlayoutPartInstanceModel) => { + const currentSegment = this.findSegment(partInstance.partInstance.segmentId)?.segment + const currentRundownIndex = rundownIds.findIndex((id) => id === partInstance.partInstance.rundownId) + + return { + partRank: partInstance.partInstance.part._rank, + segmentRank: currentSegment?._rank ?? 0, + rundownRank: currentRundownIndex ?? 0, + } } - } - const startPosition = findMarkerPosition(this.playlistImpl.quickLoop.start, 'start') - const endPosition = findMarkerPosition(this.playlistImpl.quickLoop.end, 'end') - - const comparePositions = (a: Positions, b: Positions): number => { - if (a.rundownRank > b.rundownRank) return -1 - if (a.rundownRank < b.rundownRank) return 1 - if (a.segmentRank > b.segmentRank) return -1 - if (a.segmentRank < b.segmentRank) return 1 - if (a.partRank > b.partRank) return -1 - if (a.partRank < b.partRank) return 1 - return 0 - } - - // if (this.playlistImpl.quickLoop.start.type === QuickLoopMarkerType.PART) { - // const startPartId = this.playlistImpl.quickLoop.start.id - // startPartIndex = orderedParts.findIndex((part) => part._id === startPartId) - // startPart = orderedParts[startPartIndex] - // } - // if (this.playlistImpl.quickLoop.start.type === QuickLoopMarkerType.SEGMENT) { - // startSegment = this.findSegment(this.playlistImpl.quickLoop.start.id)?.segment - // } else if (startPart != null) { - // startSegment = this.findSegment(startPart.segmentId)?.segment - // } - - // // TODO: Compare ranks first of rundowns, then of segments, then of parts - // if (this.playlistImpl.quickLoop.end.type === QuickLoopMarkerType.PART) { - // const endPartId = this.playlistImpl.quickLoop.end.id - // endPartIndex = orderedParts.findIndex((part) => part._id === endPartId) - // endPart = orderedParts[startPartIndex] - // } - // if (this.playlistImpl.quickLoop.end.type === QuickLoopMarkerType.SEGMENT) { - // endSegment = this.findSegment(this.playlistImpl.quickLoop.end.id)?.segment - // } else if (endPart != null) { - // endSegment = this.findSegment(endPart.segmentId)?.segment - // } - // if (startPartIndex < 0) this.playlistImpl.quickLoop.start = undefined - // if (endPartIndex < 0) this.playlistImpl.quickLoop.end = undefined - // if (startPartIndex > endPartIndex) { - // this.playlistImpl.quickLoop.start = undefined - // this.playlistImpl.quickLoop.end = undefined - // } - - const extractPartPosition = (partInstance: PlayoutPartInstanceModel) => { - const currentSegment = this.findSegment(partInstance.partInstance.segmentId)?.segment - const currentRundownIndex = rundownIds.findIndex((id) => id === partInstance.partInstance.rundownId) - - return { - partRank: partInstance.partInstance.part._rank, - segmentRank: currentSegment?._rank ?? 0, - rundownRank: currentRundownIndex ?? 0, + const currentPartPosition: Positions | undefined = this.currentPartInstance + ? extractPartPosition(this.currentPartInstance) + : undefined + const nextPartPosition: Positions | undefined = this.nextPartInstance + ? extractPartPosition(this.nextPartInstance) + : undefined + + const isCurrentBetweenMarkers = currentPartPosition + ? comparePositions(startPosition, currentPartPosition) >= 0 && + comparePositions(currentPartPosition, endPosition) >= 0 + : false + isNextBetweenMarkers = nextPartPosition + ? comparePositions(startPosition, nextPartPosition) >= 0 && + comparePositions(nextPartPosition, endPosition) >= 0 + : false + + if (this.nextPartInstance && isNextBetweenMarkers) { + updatePartOverrides(this.nextPartInstance, this.playlistImpl.quickLoop.forceAutoNext) } + + this.playlistImpl.quickLoop.running = + this.playlistImpl.quickLoop.start != null && + this.playlistImpl.quickLoop.end != null && + isCurrentBetweenMarkers // && } - const currentPartPosition: Positions | undefined = this.currentPartInstance - ? extractPartPosition(this.currentPartInstance) - : undefined - const nextPartPosition: Positions | undefined = this.nextPartInstance - ? extractPartPosition(this.nextPartInstance) - : undefined - - const isCurrentBetweenMarkers = currentPartPosition - ? comparePositions(startPosition, currentPartPosition) >= 0 && - comparePositions(currentPartPosition, endPosition) >= 0 - : false - const isNextBetweenMarkers = nextPartPosition - ? comparePositions(startPosition, nextPartPosition) >= 0 && - comparePositions(nextPartPosition, endPosition) >= 0 - : false - - this.playlistImpl.quickLoop.running = - this.playlistImpl.quickLoop.start != null && - this.playlistImpl.quickLoop.end != null && - isCurrentBetweenMarkers // && - // currentPartInstance?.partInstance.orphaned != null //|| - // (currentPartIndex >= startPartIndex && currentPartIndex <= endPartIndex)) - if (this.playlistImpl.quickLoop.running && this.currentPartInstance) { + if (this.currentPartInstance && this.playlistImpl.quickLoop.running) { updatePartOverrides(this.currentPartInstance, this.playlistImpl.quickLoop.forceAutoNext) + } else if (this.currentPartInstance && wasLoopRunning) { + // TODO: revert next overrides + } + + if (this.nextPartInstance && !isNextBetweenMarkers) { + // TODO: revert next overrides } - if (this.nextPartInstance && isNextBetweenMarkers) { - updatePartOverrides(this.nextPartInstance, this.playlistImpl.quickLoop.forceAutoNext) + + if (wasLoopRunning && !this.playlistImpl.quickLoop.running) { + // clears the loop markers after leaving the loop, as per the requirements, but perhaps it should be optional + delete this.playlistImpl.quickLoop } this.#playlistHasChanged = true - console.log('isNextBetweenMarkers', isNextBetweenMarkers, nextPartPosition) - function updatePartOverrides(partInstance: PlayoutPartInstanceModel, forceAutoNext: ForceQuickLoopAutoNext) { const partPropsToUpdate: Partial> = {} if ( @@ -865,14 +845,12 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou if (type === 'start') { if (marker == null) { delete this.playlistImpl.quickLoop.start - this.playlistImpl.quickLoop.running = false } else { this.playlistImpl.quickLoop.start = marker } } else { if (marker == null) { delete this.playlistImpl.quickLoop.end - this.playlistImpl.quickLoop.running = false } else { this.playlistImpl.quickLoop.end = marker } diff --git a/packages/job-worker/src/playout/selectNextPart.ts b/packages/job-worker/src/playout/selectNextPart.ts index 024594f5d0..c17ed0e3ca 100644 --- a/packages/job-worker/src/playout/selectNextPart.ts +++ b/packages/job-worker/src/playout/selectNextPart.ts @@ -38,7 +38,8 @@ export function selectNextPart( currentlySelectedPartInstance: ReadonlyDeep | null, segments: readonly PlayoutSegmentModel[], parts0: ReadonlyDeep[], - ignoreUnplayabale = true + ignoreUnplayabale = true, + ignoreQuickLoop = true ): SelectNextPartResult | null { const span = context.startSpan('selectNextPart') @@ -83,7 +84,7 @@ export function selectNextPart( return undefined } - if (rundownPlaylist.quickLoop?.running && previousPartInstance) { + if (!ignoreQuickLoop && rundownPlaylist.quickLoop?.running && previousPartInstance) { const currentIndex = parts.findIndex((p) => p._id === previousPartInstance.part._id) if ( rundownPlaylist.quickLoop?.end?.type === QuickLoopMarkerType.PART && @@ -175,13 +176,19 @@ export function selectNextPart( } } - // if playlist should loop, check from 0 to currentPart - if (rundownPlaylist.loop && !nextPart && previousPartInstance) { - // Search up until the current part - nextPart = findFirstPlayablePart(0, undefined, searchFromIndex - 1) - } - - if (rundownPlaylist.quickLoop?.end?.type === QuickLoopMarkerType.PLAYLIST && !nextPart && previousPartInstance) { + // // if playlist should loop, check from 0 to currentPart + // if (rundownPlaylist.loop && !nextPart && previousPartInstance) { + // // Search up until the current part + // nextPart = findFirstPlayablePart(0, undefined, searchFromIndex - 1) + // } + + // TODO: check how this used to behave when you queue dynamic parts after the last one in a looping playlist + if ( + !ignoreQuickLoop && + rundownPlaylist.quickLoop?.end?.type === QuickLoopMarkerType.PLAYLIST && + !nextPart && + previousPartInstance + ) { nextPart = findQuickLoopStartPart(searchFromIndex - 1) } diff --git a/packages/job-worker/src/playout/take.ts b/packages/job-worker/src/playout/take.ts index 95d95af1ae..092ebd6503 100644 --- a/packages/job-worker/src/playout/take.ts +++ b/packages/job-worker/src/playout/take.ts @@ -198,15 +198,6 @@ export async function performTakeToNextedPart( clearQueuedSegmentId(playoutModel, takePartInstance.partInstance, playoutModel.playlist.nextPartInfo) - const nextPart = selectNextPart( - context, - playoutModel.playlist, - takePartInstance.partInstance, - null, - playoutModel.getAllOrderedSegments(), - playoutModel.getAllOrderedParts() - ) - const showStyle = await pShowStyle const blueprint = await context.getShowStyleBlueprint(showStyle._id) if (blueprint.blueprint.onPreTake) { @@ -240,6 +231,18 @@ export async function performTakeToNextedPart( ) playoutModel.cycleSelectedPartInstances() + playoutModel.updateQuickLoopState() + + const nextPart = selectNextPart( + context, + playoutModel.playlist, + takePartInstance.partInstance, + null, + playoutModel.getAllOrderedSegments(), + playoutModel.getAllOrderedParts(), + false, + false + ) takePartInstance.setTaken(now, timeOffset) diff --git a/packages/job-worker/src/playout/timings/partPlayback.ts b/packages/job-worker/src/playout/timings/partPlayback.ts index 71f493a516..ff920c5484 100644 --- a/packages/job-worker/src/playout/timings/partPlayback.ts +++ b/packages/job-worker/src/playout/timings/partPlayback.ts @@ -58,7 +58,6 @@ export async function onPartPlaybackStarted( // this is the next part, clearly an autoNext has taken place playoutModel.cycleSelectedPartInstances() - playoutModel.updateQuickLoopState() reportPartInstanceHasStarted(context, playoutModel, playingPartInstance, data.startedPlayback) @@ -92,9 +91,12 @@ export async function onPartPlaybackStarted( playingPartInstance.partInstance, null, playoutModel.getAllOrderedSegments(), - playoutModel.getAllOrderedParts() + playoutModel.getAllOrderedParts(), + false, + false ) await setNextPart(context, playoutModel, nextPart, false) + playoutModel.updateQuickLoopState() // complete the take await afterTake(context, playoutModel, playingPartInstance) From 6ac90d5087b36bbc59fe4588128e7eb012a16dc7 Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 19 Dec 2023 17:41:50 +0100 Subject: [PATCH 004/276] wip(SOFIE-69): improve loop markers in timeline view --- meteor/client/styles/rundownView.scss | 58 ++++++++++++----- .../Parts/SegmentTimelinePart.tsx | 63 +++++++++---------- 2 files changed, 70 insertions(+), 51 deletions(-) diff --git a/meteor/client/styles/rundownView.scss b/meteor/client/styles/rundownView.scss index 336c39c934..4b18a9ed0a 100644 --- a/meteor/client/styles/rundownView.scss +++ b/meteor/client/styles/rundownView.scss @@ -1380,6 +1380,7 @@ svg.icon { position: absolute; top: -10px; left: 0; + right: 0; bottom: -1.3em; flex: 0; border-left: 2px solid $part-start-color; @@ -1393,8 +1394,16 @@ svg.icon { } &.quickloop-start { - left: 1px; - border-left: 1px solid white; + left: 0px; + + &::before, &::after { + z-index: 1; + margin-left: 5px; + } + + .segment-timeline__part__nextline__label { + margin-left: 5px; + } } .segment-timeline__part__nextline__label { @@ -1443,11 +1452,13 @@ svg.icon { .segment-timeline__part__nextline__quickloop-start { border-left: 3px solid white; + border-right: 1px solid white; display: block; position: absolute; top: 0; bottom: 0; - left: -6px; + left: -2px; + width: 6px; } } @@ -1457,8 +1468,9 @@ svg.icon { position: absolute; top: -12px; bottom: -1.3em; - right: -2px; + right: 0px; z-index: 40; + width: 6px; &::before { content: ' '; @@ -1548,6 +1560,10 @@ svg.icon { z-index: -1; } + &.quickloop-start { + z-index: 1; + } + &.quickloop-end { z-index: 1; } @@ -1747,6 +1763,11 @@ svg.icon { filter: drop-shadow(2px 2px 0px black) drop-shadow(-2px -2px 0px black) drop-shadow(0 0 5px black); } } + + .segment-timeline__part__nextline__quickloop-start { + border-left-color: $general-next-color; + border-right-color: $general-next-color; + } } } @@ -1786,37 +1807,44 @@ svg.icon { .segment-timeline__part__quickloop-line { min-height: $segment-layer-height; - line-height: $segment-layer-height; font-weight: 400; + display: flex; + flex-direction: row; + align-items: flex-end; position: absolute; - bottom: -$segment-layer-height; + bottom: 0; width: 100%; + overflow: hidden; .segment-timeline__part__quickloop-background { - width: 100%; position: absolute; - bottom: -1px; - min-height: $segment-layer-height; + z-index: -2; + min-height: 1em; background-image: linear-gradient(to right, white 20%, rgba(255, 255, 255, 0) 0%); background-position: center left; background-size: 10px 2px; background-repeat: repeat-x; + left: 0; + right: 0; } .segment-timeline__part__quickloop-start, .segment-timeline__part__quickloop-end { - position: absolute; - bottom: -1px; background: $segment-background-color; padding: 0 0.3em; + margin-bottom: -2px; white-space: nowrap; } - .segment-timeline__part__quickloop-start { - left: 0; + padding-left: 0.6em; + } + + .segment-timeline__identifier + .segment-timeline__part__quickloop-start { + padding-left: 0.3em; } .segment-timeline__part__quickloop-end { - right: 0; + padding-right: 0.6em; + margin-left: auto } } @@ -2339,8 +2367,6 @@ svg.icon { } .segment-timeline__identifier { - position: absolute; - bottom: 0; z-index: -1; padding: 0 4px 0 10px; box-sizing: border-box; diff --git a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index 192676aa83..670c1e5cbe 100644 --- a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -94,6 +94,7 @@ interface IState { isDurationSettling: boolean durationSettlingStartsAt: number liveDuration: number + isInQuickLoop: boolean isInsideViewport: boolean isTooSmallForText: boolean @@ -108,6 +109,8 @@ export class SegmentTimelinePartClass extends React.Component ) : null} {innerPart.floated ?
: null} -
- {isPartInQuickLoop &&
} - {isQuickLoopStart ? ( -
- START - -
- ) : null} - {isQuickLoopEnd ? ( -
- - END -
- ) : null} -
{this.props.playlist.nextTimeOffset && this.state.isNext && ( // This is the off-set line
} {this.props.isAfterLastValidInSegmentAndItsLive && isPlaylistLooping && }
+
+ {this.state.isInQuickLoop &&
} + {!this.props.isPreview && this.props.part.instance.part.identifier && ( +
{this.props.part.instance.part.identifier}
+ )} + {isQuickLoopStart ? ( +
+ START + +
+ ) : null} + {isQuickLoopEnd ? ( +
+ + END +
+ ) : null} +
{isQuickLoopStart &&
} - {!this.props.isPreview && this.props.part.instance.part.identifier && ( -
{this.props.part.instance.part.identifier}
- )}
)} {isQuickLoopEnd &&
} @@ -856,7 +850,6 @@ export const SegmentTimelinePart = withTranslation()( (durations.partDisplayStartsAt || {})[timingId], (durations.partDisplayDurations || {})[timingId], (durations.partsInQuickLoop || {})[timingId], - props.part.previousPartId ? (durations.partsInQuickLoop || {})[props.part.previousPartId] : undefined, firstPartInSegmentId ? (durations.partDisplayStartsAt || {})[firstPartInSegmentId] : undefined, firstPartInSegmentId ? (durations.partDisplayDurations || {})[firstPartInSegmentId] : undefined, ] From 079293a706a124e8935568809ab3b6b356ec6f43 Mon Sep 17 00:00:00 2001 From: ianshade Date: Wed, 20 Dec 2023 10:02:42 +0100 Subject: [PATCH 005/276] wip(SOFIE-69): style line parts --- meteor/client/ui/SegmentList/LinePart.tsx | 18 ++++++ .../ui/SegmentList/LinePartTimeline.tsx | 7 +++ meteor/client/ui/SegmentList/QuickLoopEnd.tsx | 23 ++++++++ meteor/client/ui/SegmentList/SegmentList.scss | 56 +++++++++++++++++++ meteor/client/ui/SegmentList/SegmentList.tsx | 7 ++- .../Parts/SegmentTimelinePart.tsx | 19 +++---- meteor/lib/Rundown.ts | 12 +++- 7 files changed, 127 insertions(+), 15 deletions(-) create mode 100644 meteor/client/ui/SegmentList/QuickLoopEnd.tsx diff --git a/meteor/client/ui/SegmentList/LinePart.tsx b/meteor/client/ui/SegmentList/LinePart.tsx index b8f78f830e..9ff3d45168 100644 --- a/meteor/client/ui/SegmentList/LinePart.tsx +++ b/meteor/client/ui/SegmentList/LinePart.tsx @@ -16,6 +16,7 @@ import { LinePartTimeline } from './LinePartTimeline' import { LinePartTitle } from './LinePartTitle' import { TimingDataResolution, TimingTickResolution, withTiming } from '../RundownView/RundownTiming/withTiming' import { RundownTimingContext, getPartInstanceTimingId } from '../../lib/rundownTiming' +import { LoopingIcon } from '../../lib/ui/icons/looping' interface IProps { segment: SegmentUi @@ -24,6 +25,8 @@ interface IProps { isNextPart: boolean isSinglePartInSegment: boolean hasAlreadyPlayed: boolean + isQuickLoopStart: boolean + isQuickLoopEnd: boolean // isLastSegment?: boolean // isLastPartInSegment?: boolean isPlaylistLooping: boolean @@ -64,6 +67,8 @@ export const LinePart = withTiming((props: IProps) => { adLibIndicatorColumns, isPlaylistLooping, timingDurations, + isQuickLoopStart, + isQuickLoopEnd, onContextMenu, onPieceClick, onPieceDoubleClick, @@ -162,6 +167,17 @@ export const LinePart = withTiming((props: IProps) => { {part.instance.part.identifier !== undefined && ( )} + {isQuickLoopStart && ( +
+ +
+ )} + {isQuickLoopEnd && ( +
+ +
+ )} + {isInQuickLoop &&
}
((props: IProps) => { currentPartWillAutonext={currentPartWillAutonext} hasAlreadyPlayed={hasAlreadyPlayed} onPieceDoubleClick={onPieceDoubleClick} + isQuickLoopStart={isQuickLoopStart} + isQuickLoopEnd={isQuickLoopEnd} /> ) diff --git a/meteor/client/ui/SegmentList/LinePartTimeline.tsx b/meteor/client/ui/SegmentList/LinePartTimeline.tsx index 3063753656..a9d14a5c3c 100644 --- a/meteor/client/ui/SegmentList/LinePartTimeline.tsx +++ b/meteor/client/ui/SegmentList/LinePartTimeline.tsx @@ -14,6 +14,7 @@ import { PieceUi } from '../SegmentContainer/withResolvedSegment' import StudioContext from '../RundownView/StudioContext' import { InvalidPartCover } from '../SegmentTimeline/Parts/InvalidPartCover' import { getPartInstanceTimingId } from '../../lib/rundownTiming' +import { QuickLoopEnd } from './QuickLoopEnd' const TIMELINE_DEFAULT_BASE = 30 * 1000 @@ -24,6 +25,8 @@ interface IProps { isFinished: boolean currentPartWillAutonext: boolean hasAlreadyPlayed: boolean + isQuickLoopStart: boolean + isQuickLoopEnd: boolean onPieceClick?: (item: PieceUi, e: React.MouseEvent) => void onPieceDoubleClick?: (item: PieceUi, e: React.MouseEvent) => void } @@ -72,6 +75,8 @@ export const LinePartTimeline: React.FC = function LinePartTimeline({ part, isLive, isNext, + isQuickLoopStart, + isQuickLoopEnd, currentPartWillAutonext, hasAlreadyPlayed, onPieceClick, @@ -139,6 +144,7 @@ export const LinePartTimeline: React.FC = function LinePartTimeline({ )} {!isLive && !isInvalid && } + {isQuickLoopStart &&
} {transitionPiece && } {!willAutoNextOut && !isInvalid && ( = function LinePartTimeline({ /> )} {willAutoNextOut && } + {isQuickLoopEnd && } {isLive && ( ( + () => ({ + left: `${widthInBase(partDuration, timelineBase)}%`, + }), + [partDuration, timelineBase] + ) + + return
+} diff --git a/meteor/client/ui/SegmentList/SegmentList.scss b/meteor/client/ui/SegmentList/SegmentList.scss index e530c03386..80649c33c8 100644 --- a/meteor/client/ui/SegmentList/SegmentList.scss +++ b/meteor/client/ui/SegmentList/SegmentList.scss @@ -149,6 +149,41 @@ $identifier-area-width: 3em; width: max-content; } } + + > .segment-opl__quickloop-start, > .segment-opl__quickloop-end { + position: absolute; + right: -10px; + filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.6)); + } + + > .segment-opl__quickloop-start { + top: -2px; + } + + > .segment-opl__quickloop-end { + bottom: -2px; + } + + > .segment-opl__quickloop-background { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + right: -1px; + background-image: linear-gradient(to bottom, white 20%, rgba(255, 255, 255, 0) 0%); + background-position: top 5px left; + background-size: 2px 10px; + background-repeat: repeat-y; + filter: drop-shadow(0px 0px 1px rgba(0, 0, 0, 0.6)); + } + + > .segment-opl__quickloop-start ~ .segment-opl__quickloop-background { + top: 10px; + } + + > .segment-opl__quickloop-end ~ .segment-opl__quickloop-background { + bottom: 20px; + } } &--next { @@ -323,6 +358,27 @@ $identifier-area-width: 3em; font-weight: 400; text-shadow: 0 0 5px rgba(0, 0, 0, 1); } + + } + + > .segment-opl__take-line__quickloop-start { + border-left: 3px solid white; + border-right: 1px solid white; + position: absolute; + top: -0.4em; + bottom: 0.3em; + left: 0; + width: 6px; + } + + > .segment-opl__take-line__quickloop-end { + border-right: 3px solid white; + border-left: 1px solid white; + position: absolute; + top: -0.4em; + bottom: 0.3em; + margin-left: -4px; + width: 6px; } > .segment-opl__on-air-line { diff --git a/meteor/client/ui/SegmentList/SegmentList.tsx b/meteor/client/ui/SegmentList/SegmentList.tsx index fdcac38ea2..3a8e211ef1 100644 --- a/meteor/client/ui/SegmentList/SegmentList.tsx +++ b/meteor/client/ui/SegmentList/SegmentList.tsx @@ -9,7 +9,7 @@ import { literal } from '@sofie-automation/corelib/dist/lib' import { isPartPlayable } from '@sofie-automation/corelib/dist/dataModel/Part' import { LinePart } from './LinePart' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' -import { ISourceLayerExtended, isLoopRunning } from '../../../lib/Rundown' +import { ISourceLayerExtended } from '../../../lib/Rundown' import { SegmentViewMode } from '../SegmentContainer/SegmentViewModes' import { SegmentListHeader } from './SegmentListHeader' import { useInView } from 'react-intersection-observer' @@ -19,6 +19,7 @@ import { NoteSeverity } from '@sofie-automation/blueprints-integration' import { UIStudio } from '../../../lib/api/studios' import { RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { CalculateTimingsPiece } from '@sofie-automation/corelib/dist/playout/timings' +import * as RundownLib from '../../../lib/Rundown' interface IProps { id: string @@ -158,7 +159,9 @@ const SegmentListInner = React.forwardRef(function Segme doesPlaylistHaveNextPart={playlistHasNextPart} onPieceDoubleClick={props.onPieceDoubleClick} onContextMenu={props.onContextMenu} - isPlaylistLooping={isLoopRunning(props.playlist)} + isPlaylistLooping={RundownLib.isLoopRunning(props.playlist)} + isQuickLoopStart={RundownLib.isQuickLoopStart(part.partId, props.playlist)} + isQuickLoopEnd={RundownLib.isQuickLoopEnd(part.partId, props.playlist)} /> ) diff --git a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index 670c1e5cbe..1986ecadec 100644 --- a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -3,7 +3,7 @@ import _ from 'underscore' import { withTranslation, WithTranslation, TFunction } from 'react-i18next' import ClassNames from 'classnames' -import { DBRundownPlaylist, QuickLoopMarkerType } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { SegmentUi, PartUi, IOutputLayerUi, PieceUi, LIVE_LINE_TIME_PADDING } from '../SegmentTimelineContainer' import { TimingDataResolution, @@ -35,7 +35,7 @@ import { InvalidPartCover } from './InvalidPartCover' import { ISourceLayer } from '@sofie-automation/blueprints-integration' import { UIStudio } from '../../../../lib/api/studios' import { CalculateTimingsPiece } from '@sofie-automation/corelib/dist/playout/timings' -import { isEndOfLoopingShow, isLoopRunning } from '../../../../lib/Rundown' +import * as RundownLib from '../../../../lib/Rundown' export const SegmentTimelineLineElementId = 'rundown__segment__line__' export const SegmentTimelinePartElementId = 'rundown__segment__part__' @@ -642,8 +642,8 @@ export class SegmentTimelinePartClass extends React.Component( return adlibs.map((a) => a.adlib) } -export function isLoopDefined(playlist?: DBRundownPlaylist): boolean { +export function isLoopDefined(playlist: DBRundownPlaylist | undefined): boolean { return playlist?.quickLoop?.start != null && playlist?.quickLoop?.end != null } -export function isLoopRunning(playlist?: DBRundownPlaylist): boolean { +export function isLoopRunning(playlist: DBRundownPlaylist | undefined): boolean { return !!playlist?.quickLoop?.running } +export function isQuickLoopStart(partId: PartId, playlist: DBRundownPlaylist | undefined): boolean { + return playlist?.quickLoop?.start?.type === QuickLoopMarkerType.PART && playlist.quickLoop.start.id === partId +} + +export function isQuickLoopEnd(partId: PartId, playlist: DBRundownPlaylist | undefined): boolean { + return playlist?.quickLoop?.end?.type === QuickLoopMarkerType.PART && playlist.quickLoop.end.id === partId +} + export function isEndOfLoopingShow( playlist: DBRundownPlaylist | undefined, isLastSegment: boolean, From 971c7d3bb03d0e4860d7499c4594e9b47b8518be Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 21 Dec 2023 13:29:38 +0100 Subject: [PATCH 006/276] wip(SOFIE-69): more styling --- meteor/client/styles/rundownView.scss | 18 +- meteor/client/ui/SegmentList/LinePart.tsx | 1 + .../ui/SegmentList/LinePartTimeline.tsx | 4 +- meteor/client/ui/SegmentList/SegmentList.scss | 17 ++ meteor/client/ui/SegmentList/TakeLine.tsx | 4 +- .../SegmentScratchpad/SegmentScratchpad.tsx | 2 + .../SegmentStoryboard/SegmentStoryboard.scss | 194 ++++++++++++++---- .../SegmentStoryboard/SegmentStoryboard.tsx | 8 +- .../ui/SegmentStoryboard/StoryboardPart.tsx | 60 ++++-- .../Parts/SegmentTimelinePart.tsx | 4 +- .../ui/SegmentTimeline/SegmentTimeline.scss | 1 + 11 files changed, 240 insertions(+), 73 deletions(-) diff --git a/meteor/client/styles/rundownView.scss b/meteor/client/styles/rundownView.scss index 4b18a9ed0a..79ab0b833b 100644 --- a/meteor/client/styles/rundownView.scss +++ b/meteor/client/styles/rundownView.scss @@ -1088,6 +1088,7 @@ svg.icon { $output-layer-group-collapse-animation-duration margin-bottom ($output-layer-group-collapse-animation-duration * 1/2); margin-bottom: 1.5em; + margin-left: 2px; } .segment-timeline__timeline-grid { @@ -1101,6 +1102,7 @@ svg.icon { min-width: 0; overflow: hidden; margin-bottom: 1.15em; + margin-left: 2px; opacity: 1; visibility: visible; @@ -1394,15 +1396,17 @@ svg.icon { } &.quickloop-start { - left: 0px; + left: -2px; &::before, &::after { z-index: 1; margin-left: 5px; + border-left-color: white; } .segment-timeline__part__nextline__label { - margin-left: 5px; + z-index: 5; + left: 5px; } } @@ -1483,8 +1487,6 @@ svg.icon { } } - &:first-child - .segment-timeline__part__invalid-cover { position: absolute; top: 0; @@ -1805,7 +1807,7 @@ svg.icon { } } - .segment-timeline__part__quickloop-line { + .segment-timeline__part__bottom-line { min-height: $segment-layer-height; font-weight: 400; display: flex; @@ -1813,7 +1815,8 @@ svg.icon { align-items: flex-end; position: absolute; bottom: 0; - width: 100%; + left: 1px; + right: 1px; overflow: hidden; .segment-timeline__part__quickloop-background { @@ -1824,7 +1827,7 @@ svg.icon { background-position: center left; background-size: 10px 2px; background-repeat: repeat-x; - left: 0; + left: 6px; right: 0; } @@ -1833,6 +1836,7 @@ svg.icon { padding: 0 0.3em; margin-bottom: -2px; white-space: nowrap; + color: white; } .segment-timeline__part__quickloop-start { padding-left: 0.6em; diff --git a/meteor/client/ui/SegmentList/LinePart.tsx b/meteor/client/ui/SegmentList/LinePart.tsx index 9ff3d45168..7f600fca04 100644 --- a/meteor/client/ui/SegmentList/LinePart.tsx +++ b/meteor/client/ui/SegmentList/LinePart.tsx @@ -136,6 +136,7 @@ export const LinePart = withTiming((props: IProps) => { 'segment-opl__part--live': isLivePart, 'segment-opl__part--has-played': hasAlreadyPlayed && (!isPlaylistLooping || !isInQuickLoop), 'segment-opl__part--out-of-the-loop': isPlaylistLooping && !isInQuickLoop && !isNextPart && !hasAlreadyPlayed, + 'segment-opl__part--quickloop-start': isQuickLoopStart, 'segment-opl__part--invalid': part.instance.part.invalid, 'segment-opl__part--timing-sibling': isPreceededByTimingGroupSibling, }), diff --git a/meteor/client/ui/SegmentList/LinePartTimeline.tsx b/meteor/client/ui/SegmentList/LinePartTimeline.tsx index a9d14a5c3c..178d215769 100644 --- a/meteor/client/ui/SegmentList/LinePartTimeline.tsx +++ b/meteor/client/ui/SegmentList/LinePartTimeline.tsx @@ -143,7 +143,9 @@ export const LinePartTimeline: React.FC = function LinePartTimeline({ {part.instance.part.invalid && !part.instance.part.gap && ( )} - {!isLive && !isInvalid && } + {!isLive && !isInvalid && ( + + )} {isQuickLoopStart &&
} {transitionPiece && } {!willAutoNextOut && !isInvalid && ( diff --git a/meteor/client/ui/SegmentList/SegmentList.scss b/meteor/client/ui/SegmentList/SegmentList.scss index 80649c33c8..4d8126a670 100644 --- a/meteor/client/ui/SegmentList/SegmentList.scss +++ b/meteor/client/ui/SegmentList/SegmentList.scss @@ -306,6 +306,18 @@ $identifier-area-width: 3em; @include take-arrow(); z-index: 1; } + + &.quickloop-start { + &::before, &::after { + left: 6px; + } + + &:not(.next) { + &::before, &::after { + border-left-color: white; + } + } + } } &.next { @@ -554,6 +566,11 @@ $identifier-area-width: 3em; } } } + &--next { + > .segment-opl__part-timeline > .segment-opl__take-line__quickloop-start { + border-color: $general-next-color; + } + } > .segment-opl__piece-indicators { background: #141414; diff --git a/meteor/client/ui/SegmentList/TakeLine.tsx b/meteor/client/ui/SegmentList/TakeLine.tsx index d58c86b199..a766ee31fe 100644 --- a/meteor/client/ui/SegmentList/TakeLine.tsx +++ b/meteor/client/ui/SegmentList/TakeLine.tsx @@ -5,9 +5,10 @@ import { useTranslation } from 'react-i18next' interface IProps { isNext: boolean autoNext: boolean + isQuickLoopStart: boolean } -export const TakeLine: React.FC = function TakeLine({ isNext, autoNext }) { +export const TakeLine: React.FC = function TakeLine({ isNext, autoNext, isQuickLoopStart }) { const { t } = useTranslation() return ( @@ -15,6 +16,7 @@ export const TakeLine: React.FC = function TakeLine({ isNext, autoNext } className={classNames('segment-opl__timeline-flag', 'segment-opl__take-line', { next: isNext, auto: autoNext, + 'quickloop-start': isQuickLoopStart, })} > {isNext && ( diff --git a/meteor/client/ui/SegmentScratchpad/SegmentScratchpad.tsx b/meteor/client/ui/SegmentScratchpad/SegmentScratchpad.tsx index a2b75cc30d..c2712989e4 100644 --- a/meteor/client/ui/SegmentScratchpad/SegmentScratchpad.tsx +++ b/meteor/client/ui/SegmentScratchpad/SegmentScratchpad.tsx @@ -153,6 +153,8 @@ export const SegmentScratchpad = React.memo( isLastPartInSegment={part.instance._id === lastValidPartId} isLastSegment={props.isLastSegment} isPlaylistLooping={playlistIsLooping} + isQuickLoopStart={false} + isQuickLoopEnd={false} doesPlaylistHaveNextPart={playlistHasNextPart} displayLiveLineCounter={props.displayLiveLineCounter} inHold={!!(props.playlist.holdState && props.playlist.holdState !== RundownHoldState.COMPLETE)} diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss index 9aa8c8c860..b947d9d615 100644 --- a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss +++ b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss @@ -95,6 +95,8 @@ $break-width: 35rem; } > .segment-storyboard__part-list__container { + padding-left: 2px; + > .segment-storyboard__part-list { position: relative; display: flex; @@ -205,7 +207,7 @@ $break-width: 35rem; bottom: 0; width: 0; border-left: 2px solid $part-start-color; - z-index: 2; + z-index: 3; &--autonext { top: 7px; @@ -232,6 +234,10 @@ $break-width: 35rem; right: -2px; } + &--quickloop-start { + border: none; + } + &:not(.segment-storyboard__part__next-line--autonext):not(.segment-storyboard__part__next-line--live):not( .segment-storyboard__part__next-line--invalid ) { @@ -248,6 +254,15 @@ $break-width: 35rem; bottom: -1px; } + &.segment-storyboard__part__next-line--quickloop-start { + border: none; + + &::before, &::after { + left: 3px; + border-color: transparent transparent transparent white; + } + } + &.segment-storyboard__part__next-line--next { &:not(.segment-storyboard__part__next-line--opposite) { &::before { @@ -294,7 +309,7 @@ $break-width: 35rem; word-wrap: none; color: $part-start-color; padding: 3px 0.3em 1px; - z-index: 2; + z-index: 5; &.segment-storyboard__part__next-line-label--live { background: $general-live-color; @@ -346,6 +361,14 @@ $break-width: 35rem; font-size: 0.9em; } + &:not(.segment-storyboard__part__next-line-label--next).segment-storyboard__part__next-line-label--quickloop-start { + margin-left: 2px; + } + + &.segment-storyboard__part__next-line-label--next.segment-storyboard__part__next-line-label--quickloop-start { + margin-left: -2px; + } + &.segment-storyboard__part__next-line-label--opposite { left: 100% !important; @@ -364,56 +387,143 @@ $break-width: 35rem; } } - > .segment-storyboard__identifier { + > .segment-storyboard__part__quickloop-start { + border-left: 3px solid white; + border-right: 1px solid white; + display: block; position: absolute; + top: 7px; bottom: 0; - left: 0; - z-index: -1; - padding: 0 4px 0 12px; - font-size: 0.85rem; - box-sizing: border-box; - background-color: $part-identifier; - border-radius: 0 10px 10px 0; - color: $part-identifier-text; + left: -2px; + width: 6px; + z-index: 5; + background: $segment-background-color; + } + + &--next > .segment-storyboard__part__quickloop-start { + border-color: $general-next-color; } - > .segment-storyboard__part-timer { + > .segment-storyboard__part__quickloop-end { + border-left: 1px solid white; + border-right: 3px solid white; + display: block; position: absolute; + top: 7px; bottom: 0; - right: $part-left-padding; - text-align: right; - z-index: 2; - white-space: nowrap; + right: -4px; + width: 6px; + z-index: 4; background: $segment-background-color; - color: #888; - font-size: 0.9em; + } + + > .segment-storyboard__part__bottom-left { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + flex-direction: row; + align-items: flex-end; - opacity: 1; - visibility: visible; + > .segment-storyboard__part__quickloop-background { + position: absolute; + z-index: -2; + min-height: 1em; + background-image: linear-gradient(to right, white 20%, rgba(255, 255, 255, 0) 0%); + background-position: center left; + background-size: 10px 2px; + background-repeat: repeat-x; + left: $part-left-padding; + right: $part-left-padding; + bottom: 1px; + } + + > .segment-storyboard__identifier { + z-index: -1; + padding: 0 4px 0 12px; + font-size: 0.85rem; + box-sizing: border-box; + background-color: $part-identifier; + border-radius: 0 10px 10px 0; + color: $part-identifier-text; + } + } - > .rundown-view__part__icon.rundown-view__part__icon--auto-next { - top: -0.2em; - margin-right: 0.2em; + .segment-storyboard__part__quickloop-start-icon, .segment-storyboard__part__quickloop-end-icon { + background: $segment-background-color; + padding: 0 0.3em; + white-space: nowrap; + color: white; + margin-bottom: -1px; + } + + &--quickloop-start { + .segment-storyboard__part__quickloop-start-icon:first-child { + padding-left: 0.6em; } + } - @keyframes segment-storyboard__part-timer--fadeIn { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } + &--quickloop-end + & { + > .segment-storyboard__part__next-line { + margin-left: 3px; + z-index: 6; } + > .segment-storyboard__part__next-line-label { + margin-left: 3px; + } + } - &--live { - font-size: 1.2em; - line-height: 1em; - font-weight: 500; - color: $liveline-timecode-color; - opacity: 1; + .segment-storyboard__part__quickloop-end-icon { + padding-right: 3px; + } + + > .segment-storyboard__part__bottom-right { + display: flex; + flex-direction: row; + margin-left: auto; + align-items: flex-end; + position: absolute; + bottom: 0; + right: 0; - > .overtime { - color: $general-late-color; + > .segment-storyboard__part-timer { + margin-right: $part-left-padding; + padding-left: 3px; + text-align: right; + z-index: 2; + white-space: nowrap; + background: $segment-background-color; + color: #888; + font-size: 0.9em; + + opacity: 1; + visibility: visible; + + > .rundown-view__part__icon.rundown-view__part__icon--auto-next { + top: -0.2em; + margin-right: 0.2em; + } + + @keyframes segment-storyboard__part-timer--fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } + + &--live { + font-size: 1.2em; + line-height: 1em; + font-weight: 500; + color: $liveline-timecode-color; + opacity: 1; + + > .overtime { + color: $general-late-color; + } } } } @@ -535,7 +645,7 @@ $break-width: 35rem; filter: brightness(0.3); } - > .segment-storyboard__identifier, + > .segment-storyboard__part__bottom-line > .segment-storyboard__identifier, > .segment-storyboard__part__thumbnail, > .segment-storyboard__part__secondary-pieces > .segment-storyboard__part__output-group @@ -544,14 +654,14 @@ $break-width: 35rem; box-shadow: $squished-shadow; } - > .segment-storyboard__part-timer { + > .segment-storyboard__part__bottom-line .segment-storyboard__part-timer { visibility: hidden; opacity: 0; } /* Only child */ &:first-child:last-child { - > .segment-storyboard__part-timer { + > .segment-storyboard__part__bottom-line .segment-storyboard__part-timer { visibility: visible; opacity: 1; } @@ -574,7 +684,7 @@ $break-width: 35rem; transition: filter 0s; z-index: 3; - > .segment-storyboard__part-timer { + > .segment-storyboard__part__bottom-line .segment-storyboard__part-timer { visibility: visible; opacity: 1; } diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.tsx b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.tsx index cbcabb7288..4603f783d9 100644 --- a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.tsx +++ b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.tsx @@ -37,7 +37,7 @@ import { RundownHoldState } from '@sofie-automation/corelib/dist/dataModel/Rundo import { CalculateTimingsPiece } from '@sofie-automation/corelib/dist/playout/timings' import { SegmentTimeAnchorTime } from '../RundownView/RundownTiming/SegmentTimeAnchorTime' import { logger } from '../../../lib/logging' -import { isEndOfLoopingShow, isLoopRunning } from '../../../lib/Rundown' +import * as RundownLib from '../../../lib/Rundown' interface IProps { id: string @@ -198,7 +198,7 @@ export const SegmentStoryboard = React.memo( squishedPartsNum > 1 ? Math.max(4, (spaceLeft - PART_WIDTH) / (squishedPartsNum - 1)) : null const playlistHasNextPart = !!props.playlist.nextPartInfo - const playlistIsLooping = isLoopRunning(props.playlist) + const playlistIsLooping = RundownLib.isLoopRunning(props.playlist) renderedParts.forEach((part, index) => { const isLivePart = part.instance._id === props.playlist.currentPartInfo?.partInstanceId @@ -219,12 +219,14 @@ export const SegmentStoryboard = React.memo( isNextPart={isNextPart} isLastPartInSegment={part.instance._id === lastValidPartId} isLastSegment={props.isLastSegment} - isEndOfLoopingShow={isEndOfLoopingShow( + isEndOfLoopingShow={RundownLib.isEndOfLoopingShow( props.playlist, props.isLastSegment, part.instance._id === lastValidPartId, part.instance.part )} + isQuickLoopStart={RundownLib.isQuickLoopStart(part.partId, props.playlist)} + isQuickLoopEnd={RundownLib.isQuickLoopEnd(part.partId, props.playlist)} isPlaylistLooping={playlistIsLooping} doesPlaylistHaveNextPart={playlistHasNextPart} displayLiveLineCounter={props.displayLiveLineCounter} diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx b/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx index 7520dd24dd..dd82a1d572 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx +++ b/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx @@ -22,6 +22,7 @@ import { SegmentEnd } from '../../lib/ui/icons/segment' import { AutoNextStatus } from '../RundownView/RundownTiming/AutoNextStatus' import { RundownTimingContext, getPartInstanceTimingId } from '../../lib/rundownTiming' import { TimingDataResolution, TimingTickResolution, withTiming } from '../RundownView/RundownTiming/withTiming' +import { LoopingIcon } from '../../lib/ui/icons/looping' interface IProps { className?: string @@ -34,6 +35,8 @@ interface IProps { isLastPartInSegment?: boolean isPlaylistLooping?: boolean isEndOfLoopingShow?: boolean + isQuickLoopStart: boolean + isQuickLoopEnd: boolean doesPlaylistHaveNextPart?: boolean inHold: boolean currentPartWillAutonext: boolean @@ -65,6 +68,8 @@ export const StoryboardPart = withTiming((props: IProps) => { isLastSegment, isPlaylistLooping, isEndOfLoopingShow, + isQuickLoopStart, + isQuickLoopEnd, doesPlaylistHaveNextPart, currentPartWillAutonext, outputLayers, @@ -142,6 +147,8 @@ export const StoryboardPart = withTiming((props: IProps) => { 'segment-storyboard__part--live': isLivePart, 'segment-storyboard__part--invalid': part.instance.part.invalid, 'segment-storyboard__part--out-of-the-loop': !isPartInQuickLoop && isPlaylistLooping && !isNextPart, + 'segment-storyboard__part--quickloop-start': isQuickLoopStart, + 'segment-storyboard__part--quickloop-end': isQuickLoopEnd, }, className ), @@ -159,7 +166,6 @@ export const StoryboardPart = withTiming((props: IProps) => { collect={getPartContext} > {isLivePart ?
: null} -
{part.instance.part.identifier}
{subscriptionsReady ? ( <> @@ -180,12 +186,14 @@ export const StoryboardPart = withTiming((props: IProps) => { ) : null} {isFloated ?
: null}
{part.instance.part.title}
+ {isQuickLoopStart &&
}
((props: IProps) => { 'segment-storyboard__part__next-line-label--autonext': willBeAutoNextedInto, 'segment-storyboard__part__next-line-label--next': isNextPart, 'segment-storyboard__part__next-line-label--live': isLivePart, + 'segment-storyboard__part__next-line-label--quickloop-start': isQuickLoopStart, })} > {isLivePart ? t('On Air') : willBeAutoNextedInto ? t('Auto') : isNextPart ? t('Next') : null} @@ -254,21 +263,40 @@ export const StoryboardPart = withTiming((props: IProps) => { )}
)} - {isLivePart && displayLiveLineCounter ? ( -
- - -
- ) : displayLiveLineCounter ? ( -
- -
- ) : null} +
+ {part.instance.part.identifier && ( +
{part.instance.part.identifier}
+ )} + {isQuickLoopStart ? ( +
+ +
+ ) : null} + {isPartInQuickLoop &&
} +
+
+ {isQuickLoopEnd ? ( +
+ +
+ ) : null} + {isLivePart && displayLiveLineCounter ? ( +
+ + +
+ ) : displayLiveLineCounter ? ( +
+ +
+ ) : null} +
+ {isQuickLoopEnd &&
} ) }) diff --git a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index 1986ecadec..c08dc2b257 100644 --- a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -781,21 +781,19 @@ export class SegmentTimelinePartClass extends React.Component} {this.props.isAfterLastValidInSegmentAndItsLive && isPlaylistLooping && }
-
+
{this.state.isInQuickLoop &&
} {!this.props.isPreview && this.props.part.instance.part.identifier && (
{this.props.part.instance.part.identifier}
)} {isQuickLoopStart ? (
- START
) : null} {isQuickLoopEnd ? (
- END
) : null}
diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimeline.scss b/meteor/client/ui/SegmentTimeline/SegmentTimeline.scss index c778af78d8..fd9621b8ef 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimeline.scss +++ b/meteor/client/ui/SegmentTimeline/SegmentTimeline.scss @@ -61,6 +61,7 @@ $timeline-layer-height: 1em; bottom: 0; width: 100%; + padding-left: 2px; contain: layout style; } From adf027a1d7b6f33727cc063b31c6a168202d061a Mon Sep 17 00:00:00 2001 From: ianshade Date: Fri, 22 Dec 2023 09:14:28 +0100 Subject: [PATCH 007/276] wip(SOFIE-69): appearance tweaks --- meteor/client/styles/rundownView.scss | 2 +- .../ui/SegmentStoryboard/SegmentStoryboard.scss | 2 +- .../client/ui/SegmentTimeline/SegmentTimeline.tsx | 15 +++++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/meteor/client/styles/rundownView.scss b/meteor/client/styles/rundownView.scss index 79ab0b833b..fb33b5072c 100644 --- a/meteor/client/styles/rundownView.scss +++ b/meteor/client/styles/rundownView.scss @@ -35,7 +35,7 @@ $previewline-color: rgb(38, 137, 186); /** The top padding of the timeline has to be equal to the header-height, so that the functional part of the timeline starts at the same level as the segment title */ -$segment-timeline-padding: $segment-header-height 0 1.5em; +$segment-timeline-padding: $segment-header-height 0 1.5em 2px; $segment-layer-height: 1.5em; $segment-layer-separator-color: transparentize(#000, 0.5); diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss index b947d9d615..6b7d97f52d 100644 --- a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss +++ b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss @@ -464,7 +464,7 @@ $break-width: 35rem; } } - &--quickloop-end + & { + &--quickloop-end + &, &--quickloop-end + .segment-storyboard__part-list--squished-parts > &:last-child { > .segment-storyboard__part__next-line { margin-left: 3px; z-index: 6; diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx index ef49d876e5..4c30f4b960 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx @@ -52,6 +52,7 @@ import { } from '../RundownView/RundownTiming/withTiming' import { SegmentTimeAnchorTime } from '../RundownView/RundownTiming/SegmentTimeAnchorTime' import { logger } from '../../../lib/logging' +import * as RundownLib from '../../../lib/Rundown' interface IProps { id: string @@ -667,12 +668,14 @@ export class SegmentTimelineClass extends React.Component -
+ {!RundownLib.isLoopRunning(this.props.playlist) && ( +
+ )}
Date: Fri, 22 Dec 2023 09:21:58 +0100 Subject: [PATCH 008/276] wip(SOFIE-69): fix dots when loop start and end order is reversed --- .../RundownTiming/RundownTimingProvider.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index c01847abba..e8a1734986 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -369,16 +369,22 @@ function findPartInstancesInQuickLoop( let previousPartInstance: MinimalPartInstance | undefined = undefined for (const partInstance of sortedPartInstances) { if ( - isInQuickLoop && + previousPartInstance && ((playlist.quickLoop?.end?.type === QuickLoopMarkerType.PART && - playlist.quickLoop.end.id === previousPartInstance!.part._id) || + playlist.quickLoop.end.id === previousPartInstance.part._id) || (playlist.quickLoop?.end?.type === QuickLoopMarkerType.SEGMENT && - playlist.quickLoop.end.id === previousPartInstance!.segmentId) || + playlist.quickLoop.end.id === previousPartInstance.segmentId) || (playlist.quickLoop?.end?.type === QuickLoopMarkerType.RUNDOWN && - playlist.quickLoop.end.id === previousPartInstance!.rundownId)) + playlist.quickLoop.end.id === previousPartInstance.rundownId)) ) { isInQuickLoop = false - // a `break` should be here, but it can't because when looping over a single part we need to include the three instances of that part shown at once + if ( + playlist.quickLoop.start?.type !== QuickLoopMarkerType.PART || + playlist.quickLoop.start?.id !== playlist.quickLoop.end?.id + ) { + // when looping over a single part we need to include the three instances of that part shown at once, otherwise, we can break + break + } } if ( !isInQuickLoop && From 49a648738b1136fbffceb7b359f6408b6b272d85 Mon Sep 17 00:00:00 2001 From: ianshade Date: Fri, 5 Jan 2024 13:01:58 +0100 Subject: [PATCH 009/276] wip(SOFIE-69): legacy looping to new looping --- .../corelib/src/dataModel/RundownPlaylist.ts | 2 -- .../job-worker/src/blueprints/context/lib.ts | 6 ++++-- .../job-worker/src/playout/lookahead/util.ts | 18 +++--------------- .../job-worker/src/playout/selectNextPart.ts | 8 +------- packages/job-worker/src/playout/take.ts | 11 +++++++---- .../src/playout/timings/partPlayback.ts | 4 ++-- packages/job-worker/src/rundownPlaylists.ts | 19 ++++++++++++++++--- 7 files changed, 33 insertions(+), 35 deletions(-) diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index fcb60d912e..ab027bf548 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -109,8 +109,6 @@ export interface DBRundownPlaylist { activationId?: RundownPlaylistActivationId /** Timestamp when the playlist was last reset. Used to silence a few errors upon reset.*/ resetTime?: Time - /** Should the playlist loop at the end */ - loop?: boolean /** Marker indicating if unplayed parts behind the onAir part, should be treated as "still to be played" or "skipped" in terms of timing calculations */ outOfOrderTiming?: boolean /** Should time-of-day clocks be used instead of countdowns by default */ diff --git a/packages/job-worker/src/blueprints/context/lib.ts b/packages/job-worker/src/blueprints/context/lib.ts index 8d4935388c..c79cd8a5e9 100644 --- a/packages/job-worker/src/blueprints/context/lib.ts +++ b/packages/job-worker/src/blueprints/context/lib.ts @@ -40,7 +40,7 @@ import { RundownPlaylistTiming, } from '@sofie-automation/blueprints-integration' import { JobContext, ProcessedShowStyleBase, ProcessedShowStyleVariant } from '../../jobs' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBRundownPlaylist, QuickLoopMarkerType } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' /** * Convert an object to have all the values of all keys (including optionals) be 'true' @@ -380,7 +380,9 @@ export function convertRundownPlaylistToBlueprints( timing: clone(playlist.timing), outOfOrderTiming: playlist.outOfOrderTiming, - loop: playlist.loop, + loop: + playlist.quickLoop?.start?.type === QuickLoopMarkerType.PLAYLIST && + playlist.quickLoop.end?.type === QuickLoopMarkerType.PLAYLIST, timeOfDayCountdowns: playlist.timeOfDayCountdowns, metaData: clone(playlist.metaData), diff --git a/packages/job-worker/src/playout/lookahead/util.ts b/packages/job-worker/src/playout/lookahead/util.ts index 0733698c04..d87f9d7e2d 100644 --- a/packages/job-worker/src/playout/lookahead/util.ts +++ b/packages/job-worker/src/playout/lookahead/util.ts @@ -57,7 +57,6 @@ export function getOrderedPartsAfterPlayhead( const strippedPlaylist = { queuedSegmentId: alreadyConsumedQueuedSegmentId ? undefined : playlist.queuedSegmentId, - loop: playlist.loop, quickLoop: playlist.quickLoop, } const nextNextPart = selectNextPart( @@ -98,18 +97,7 @@ export function getOrderedPartsAfterPlayhead( res.push(...playablePartsSlice) } - if (res.length < partCount && playlist.loop) { - // The rundown would loop here, so lets run with that - const playableParts = orderedParts.filter((p) => isPartPlayable(p)) - // Note: We only add it once, as lookahead is unlikely to show anything new in a second pass - res.push(...playableParts) - - if (span) span.end() - // Final trim to ensure it is within bounds - return res.slice(0, partCount) - } else { - if (span) span.end() - // We reached the target or ran out of parts - return res.slice(0, partCount) - } + if (span) span.end() + // We reached the target or ran out of parts + return res.slice(0, partCount) } diff --git a/packages/job-worker/src/playout/selectNextPart.ts b/packages/job-worker/src/playout/selectNextPart.ts index c17ed0e3ca..ae973eaa91 100644 --- a/packages/job-worker/src/playout/selectNextPart.ts +++ b/packages/job-worker/src/playout/selectNextPart.ts @@ -33,7 +33,7 @@ export interface SelectNextPartResult { export function selectNextPart( context: JobContext, - rundownPlaylist: Pick, + rundownPlaylist: Pick, previousPartInstance: ReadonlyDeep | null, currentlySelectedPartInstance: ReadonlyDeep | null, segments: readonly PlayoutSegmentModel[], @@ -176,12 +176,6 @@ export function selectNextPart( } } - // // if playlist should loop, check from 0 to currentPart - // if (rundownPlaylist.loop && !nextPart && previousPartInstance) { - // // Search up until the current part - // nextPart = findFirstPlayablePart(0, undefined, searchFromIndex - 1) - // } - // TODO: check how this used to behave when you queue dynamic parts after the last one in a looping playlist if ( !ignoreQuickLoop && diff --git a/packages/job-worker/src/playout/take.ts b/packages/job-worker/src/playout/take.ts index 092ebd6503..fa523b255d 100644 --- a/packages/job-worker/src/playout/take.ts +++ b/packages/job-worker/src/playout/take.ts @@ -231,6 +231,7 @@ export async function performTakeToNextedPart( ) playoutModel.cycleSelectedPartInstances() + const wasLooping = playoutModel.playlist.quickLoop?.running playoutModel.updateQuickLoopState() const nextPart = selectNextPart( @@ -246,7 +247,9 @@ export async function performTakeToNextedPart( takePartInstance.setTaken(now, timeOffset) - resetPreviousSegment(playoutModel) + if (wasLooping) { + resetPreviousSegmentIfLooping(playoutModel) + } // Once everything is synced, we can choose the next part await setNextPart(context, playoutModel, nextPart, false) @@ -290,17 +293,17 @@ export function clearQueuedSegmentId( } /** - * Reset the Segment of the previousPartInstance, if playback has left that Segment and the Rundown is looping + * Reset the Segment of the previousPartInstance, if playback has left that Segment and the Playlist is looping * @param playoutModel Cache for the active Playlist */ -export function resetPreviousSegment(playoutModel: PlayoutModel): void { +export function resetPreviousSegmentIfLooping(playoutModel: PlayoutModel): void { const previousPartInstance = playoutModel.previousPartInstance const currentPartInstance = playoutModel.currentPartInstance // If the playlist is looping and // If the previous and current part are not in the same segment, then we have just left a segment if ( - playoutModel.playlist.loop && + playoutModel.playlist.quickLoop?.running && previousPartInstance && previousPartInstance.partInstance.segmentId !== currentPartInstance?.partInstance?.segmentId ) { diff --git a/packages/job-worker/src/playout/timings/partPlayback.ts b/packages/job-worker/src/playout/timings/partPlayback.ts index ff920c5484..6e67b933b3 100644 --- a/packages/job-worker/src/playout/timings/partPlayback.ts +++ b/packages/job-worker/src/playout/timings/partPlayback.ts @@ -7,7 +7,7 @@ import { selectNextPart } from '../selectNextPart' import { setNextPart } from '../setNext' import { updateTimeline } from '../timeline/generate' import { getCurrentTime } from '../../lib' -import { afterTake, clearQueuedSegmentId, resetPreviousSegment, updatePartInstanceOnTake } from '../take' +import { afterTake, clearQueuedSegmentId, resetPreviousSegmentIfLooping, updatePartInstanceOnTake } from '../take' import { INCORRECT_PLAYING_PART_DEBOUNCE, RESET_IGNORE_ERRORS } from '../constants' import { Time } from '@sofie-automation/blueprints-integration' @@ -82,7 +82,7 @@ export async function onPartPlaybackStarted( ) clearQueuedSegmentId(playoutModel, playingPartInstance.partInstance, playlist.nextPartInfo) - resetPreviousSegment(playoutModel) + resetPreviousSegmentIfLooping(playoutModel) // Note: rare edgecase of auto-nexting into a loop causing reset of a segment outside of the loop; is it worth fixing? // Update the next partinstance const nextPart = selectNextPart( diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index 5b8029ca85..cfbd78de95 100644 --- a/packages/job-worker/src/rundownPlaylists.ts +++ b/packages/job-worker/src/rundownPlaylists.ts @@ -1,6 +1,10 @@ import { RundownPlaylistId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBRundown, Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { + DBRundownPlaylist, + ForceQuickLoopAutoNext, + QuickLoopMarkerType, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { clone, getHash, getRandomString, normalizeArrayToMap } from '@sofie-automation/corelib/dist/lib' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' @@ -211,14 +215,23 @@ export function produceRundownPlaylistInfoFromRundown( name: playlistInfo.playlist.name, timing: playlistInfo.playlist.timing, - loop: playlistInfo.playlist.loop, - outOfOrderTiming: playlistInfo.playlist.outOfOrderTiming, timeOfDayCountdowns: playlistInfo.playlist.timeOfDayCountdowns, metaData: playlistInfo.playlist.metaData, modified: getCurrentTime(), } + if (playlistInfo.playlist.loop) { + newPlaylist.quickLoop = { + start: { type: QuickLoopMarkerType.PLAYLIST }, + end: { type: QuickLoopMarkerType.PLAYLIST }, + locked: true, + forceAutoNext: context.studio.settings.forceQuickLoopAutoNext ?? ForceQuickLoopAutoNext.DISABLED, + running: existingPlaylist?.quickLoop?.running ?? false, + } + } else if (existingPlaylist?.quickLoop?.locked) { + delete newPlaylist.quickLoop + } } else { newPlaylist = { ...defaultPlaylistForRundown(rundownsInDefaultOrder[0], context.studio, existingPlaylist), From e9364795fdb6239e3228c20143db02da4dedd76b Mon Sep 17 00:00:00 2001 From: ianshade Date: Fri, 5 Jan 2024 14:55:42 +0100 Subject: [PATCH 010/276] wip(SOFIE-69): alow extending loop by dynamically inserting parts --- .../client/ui/Shelf/PlaylistEndTimerPanel.tsx | 3 ++- .../corelib/src/dataModel/RundownPlaylist.ts | 3 +++ packages/job-worker/src/playout/adlibUtils.ts | 21 +++++++++++++++++++ .../model/implementation/PlayoutModelImpl.ts | 10 +++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/meteor/client/ui/Shelf/PlaylistEndTimerPanel.tsx b/meteor/client/ui/Shelf/PlaylistEndTimerPanel.tsx index 05c48ce586..9f086cd7a9 100644 --- a/meteor/client/ui/Shelf/PlaylistEndTimerPanel.tsx +++ b/meteor/client/ui/Shelf/PlaylistEndTimerPanel.tsx @@ -13,6 +13,7 @@ import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/Rund import { withTranslation } from 'react-i18next' import { PlaylistEndTiming } from '../RundownView/RundownTiming/PlaylistEndTiming' import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' +import { isLoopRunning } from '../../../lib/Rundown' interface IPlaylistEndTimerPanelProps { visible?: boolean @@ -39,7 +40,7 @@ export class PlaylistEndTimerPanelInner extends MeteorReactComponent Date: Mon, 8 Jan 2024 15:45:50 +0100 Subject: [PATCH 011/276] wip(SOFIE-69): fix extending a single-part loop with dynamically inserted parts --- packages/job-worker/src/playout/selectNextPart.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/job-worker/src/playout/selectNextPart.ts b/packages/job-worker/src/playout/selectNextPart.ts index ae973eaa91..15f7b0d4a3 100644 --- a/packages/job-worker/src/playout/selectNextPart.ts +++ b/packages/job-worker/src/playout/selectNextPart.ts @@ -88,7 +88,8 @@ export function selectNextPart( const currentIndex = parts.findIndex((p) => p._id === previousPartInstance.part._id) if ( rundownPlaylist.quickLoop?.end?.type === QuickLoopMarkerType.PART && - previousPartInstance.part._id === rundownPlaylist.quickLoop.end.id + (previousPartInstance.part._id === rundownPlaylist.quickLoop.end.id || + previousPartInstance.part._id === rundownPlaylist.quickLoop.end.overridenId) ) { return findQuickLoopStartPart(currentIndex + 1) ?? null } else if ( From 68f790e8dab92a38749e21291df41ae1fcfe05ef Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 9 Jan 2024 18:37:38 +0100 Subject: [PATCH 012/276] wip(SOFIE-69): reset pieceInstances alongside partInstances when leaving a segment while looping --- packages/job-worker/src/playout/take.ts | 12 ++++-------- .../job-worker/src/playout/timings/partPlayback.ts | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/job-worker/src/playout/take.ts b/packages/job-worker/src/playout/take.ts index fa523b255d..b7f4eb46d5 100644 --- a/packages/job-worker/src/playout/take.ts +++ b/packages/job-worker/src/playout/take.ts @@ -10,7 +10,7 @@ import { logger } from '../logging' import { JobContext, ProcessedShowStyleCompound } from '../jobs' import { PlayoutModel } from './model/PlayoutModel' import { PlayoutPartInstanceModel } from './model/PlayoutPartInstanceModel' -import { isTooCloseToAutonext } from './lib' +import { isTooCloseToAutonext, resetPartInstancesWithPieceInstances } from './lib' import { selectNextPart } from './selectNextPart' import { setNextPart } from './setNext' import { getCurrentTime } from '../lib' @@ -248,7 +248,7 @@ export async function performTakeToNextedPart( takePartInstance.setTaken(now, timeOffset) if (wasLooping) { - resetPreviousSegmentIfLooping(playoutModel) + resetPreviousSegmentIfLooping(context, playoutModel) } // Once everything is synced, we can choose the next part @@ -296,7 +296,7 @@ export function clearQueuedSegmentId( * Reset the Segment of the previousPartInstance, if playback has left that Segment and the Playlist is looping * @param playoutModel Cache for the active Playlist */ -export function resetPreviousSegmentIfLooping(playoutModel: PlayoutModel): void { +export function resetPreviousSegmentIfLooping(context: JobContext, playoutModel: PlayoutModel): void { const previousPartInstance = playoutModel.previousPartInstance const currentPartInstance = playoutModel.currentPartInstance @@ -309,11 +309,7 @@ export function resetPreviousSegmentIfLooping(playoutModel: PlayoutModel): void ) { // Reset the old segment const segmentId = previousPartInstance.partInstance.segmentId - for (const partInstance of playoutModel.loadedPartInstances) { - if (partInstance.partInstance.segmentId === segmentId) { - partInstance.markAsReset() - } - } + resetPartInstancesWithPieceInstances(context, playoutModel, { segmentId }) } } diff --git a/packages/job-worker/src/playout/timings/partPlayback.ts b/packages/job-worker/src/playout/timings/partPlayback.ts index 6e67b933b3..0ea3b32795 100644 --- a/packages/job-worker/src/playout/timings/partPlayback.ts +++ b/packages/job-worker/src/playout/timings/partPlayback.ts @@ -82,7 +82,7 @@ export async function onPartPlaybackStarted( ) clearQueuedSegmentId(playoutModel, playingPartInstance.partInstance, playlist.nextPartInfo) - resetPreviousSegmentIfLooping(playoutModel) // Note: rare edgecase of auto-nexting into a loop causing reset of a segment outside of the loop; is it worth fixing? + resetPreviousSegmentIfLooping(context, playoutModel) // Note: rare edgecase of auto-nexting into a loop causing reset of a segment outside of the loop; is it worth fixing? // Update the next partinstance const nextPart = selectNextPart( From dfb8d27ffd072934b35e1e21ea8f24b40c901735 Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 9 Jan 2024 19:14:18 +0100 Subject: [PATCH 013/276] wip(SOFIE-69): reset part overrides when clearing the loop --- .../src/documents/part.ts | 1 + packages/corelib/src/dataModel/Part.ts | 10 ++++ .../playout/model/PlayoutPartInstanceModel.ts | 14 +++++ .../model/implementation/PlayoutModelImpl.ts | 19 +++++-- .../PlayoutPartInstanceModelImpl.ts | 56 ++++++++++++++++++- .../src/playout/quickLoopMarkers.ts | 3 + 6 files changed, 98 insertions(+), 5 deletions(-) diff --git a/packages/blueprints-integration/src/documents/part.ts b/packages/blueprints-integration/src/documents/part.ts index 1097e6ec0e..a5df474dca 100644 --- a/packages/blueprints-integration/src/documents/part.ts +++ b/packages/blueprints-integration/src/documents/part.ts @@ -151,6 +151,7 @@ export interface IBlueprintPart extends IBlueprintMutatable /** When this part is just a filler to fill space in a segment. Generally, used with invalid: true */ gap?: boolean } + /** The Part sent from Core */ export interface IBlueprintPartDB extends IBlueprintPart { _id: string diff --git a/packages/corelib/src/dataModel/Part.ts b/packages/corelib/src/dataModel/Part.ts index 1ba160af5e..bae7abf8c3 100644 --- a/packages/corelib/src/dataModel/Part.ts +++ b/packages/corelib/src/dataModel/Part.ts @@ -11,6 +11,10 @@ export interface PartInvalidReason { color?: string } +type NullableProps = { + [K in keyof T]: T[K] | null +} + /** A "Line" in NRK Lingo. */ export interface DBPart extends ProtectedStringProperties { _id: PartId @@ -32,6 +36,12 @@ export interface DBPart extends ProtectedStringProperties> } export function isPartPlayable(part: Pick, 'invalid' | 'floated'>): boolean { diff --git a/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts b/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts index 865e6e815b..1d43fa54c7 100644 --- a/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts +++ b/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts @@ -209,6 +209,20 @@ export interface PlayoutPartInstanceModel { */ updatePartProps(props: Partial): boolean + /** + * Update some properties for the wrapped Part in a way that can be reverted + * @param props New properties for the Part being wrapped + * @returns True if any valid properties were provided + */ + overridePartProps(props: Partial): boolean + + /** + * Reverts overriden Part props + * @param props New properties for the Part being wrapped + * @returns True if properties were reverted + */ + revertOverridenPartProps(): boolean + /** * Ensure that this PartInstance is setup correctly for being in the Scratchpad Segment */ diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index b31bac84b6..94e3d6a9e5 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -544,17 +544,18 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.playlistImpl.quickLoop.running = this.playlistImpl.quickLoop.start != null && this.playlistImpl.quickLoop.end != null && - isCurrentBetweenMarkers // && + isCurrentBetweenMarkers } if (this.currentPartInstance && this.playlistImpl.quickLoop.running) { updatePartOverrides(this.currentPartInstance, this.playlistImpl.quickLoop.forceAutoNext) } else if (this.currentPartInstance && wasLoopRunning) { - // TODO: revert next overrides + revertPartOverrides(this.currentPartInstance) + // TODO: this may need updating the timeline } if (this.nextPartInstance && !isNextBetweenMarkers) { - // TODO: revert next overrides + revertPartOverrides(this.nextPartInstance) } if (wasLoopRunning && !this.playlistImpl.quickLoop.running) { @@ -579,10 +580,20 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou partPropsToUpdate.autoNext = true } if (Object.keys(partPropsToUpdate).length) { - partInstance.updatePartProps(partPropsToUpdate) + partInstance.overridePartProps(partPropsToUpdate) if (partPropsToUpdate.expectedDuration) partInstance.recalculateExpectedDurationWithPreroll() } } + + function revertPartOverrides(partInstance: PlayoutPartInstanceModel) { + const overridenProperties = partInstance.partInstance.part.overridenProperties + if (overridenProperties) { + partInstance.revertOverridenPartProps() + if (overridenProperties.expectedDuration) { + partInstance.recalculateExpectedDurationWithPreroll() + } + } + } } deactivatePlaylist(): void { diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts index 7332618717..4e3e50d306 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts @@ -507,7 +507,7 @@ export class PlayoutPartInstanceModelImpl implements PlayoutPartInstanceModel { // Future: this could do some better validation // filter the submission to the allowed ones - const trimmedProps: Partial = _.pick(props, [...IBlueprintMutatablePartSampleKeys]) + const trimmedProps: Partial = filterPropsToAllowed(props) if (Object.keys(trimmedProps).length === 0) return false this.#compareAndSetPartInstanceValue( @@ -522,6 +522,54 @@ export class PlayoutPartInstanceModelImpl implements PlayoutPartInstanceModel { return true } + overridePartProps(props: Partial): boolean { + const trimmedProps: Partial = filterPropsToAllowed(props) + const keys = Object.keys(trimmedProps) as Array> + if (keys.length === 0) return false + + const overridenProperties: DBPart['overridenProperties'] = _.pick(this.partInstanceImpl.part, keys) + keys.forEach((key) => { + if (overridenProperties[key] === undefined) { + overridenProperties[key] = null + } + }) + + this.#compareAndSetPartInstanceValue( + 'part', + { + ...this.partInstanceImpl.part, + ...trimmedProps, + overridenProperties, + }, + true + ) + + return true + } + + revertOverridenPartProps(): boolean { + const overridenProperties = { ...this.partInstanceImpl.part.overridenProperties } + if (!overridenProperties) return false + const keys = Object.keys(overridenProperties) as Array> + + keys.forEach((key) => { + if (overridenProperties[key] === null) { + overridenProperties[key] = undefined + } + }) + + this.#compareAndSetPartInstanceValue( + 'part', + { + ...this.partInstanceImpl.part, + ...(overridenProperties as Partial), + }, + true + ) + + return true + } + validateScratchpadSegmentProperties(): void { this.#compareAndSetPartInstanceValue('orphaned', 'adlib-part') @@ -532,3 +580,9 @@ export class PlayoutPartInstanceModelImpl implements PlayoutPartInstanceModel { this.#compareAndSetPartValue('untimed', true) } } + +function filterPropsToAllowed( + props: Partial> +): Partial> { + return _.pick(props, [...IBlueprintMutatablePartSampleKeys]) +} diff --git a/packages/job-worker/src/playout/quickLoopMarkers.ts b/packages/job-worker/src/playout/quickLoopMarkers.ts index 0ebd8e441a..b9011b6dd3 100644 --- a/packages/job-worker/src/playout/quickLoopMarkers.ts +++ b/packages/job-worker/src/playout/quickLoopMarkers.ts @@ -2,6 +2,7 @@ import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/erro import { SetQuickLoopMarkerProps } from '@sofie-automation/corelib/dist/worker/studio' import { JobContext } from '../jobs' import { runJobWithPlayoutModel } from './lock' +import { updateTimeline } from './timeline/generate' export async function handleSetQuickLoopMarker(context: JobContext, data: SetQuickLoopMarkerProps): Promise { return runJobWithPlayoutModel( @@ -16,6 +17,8 @@ export async function handleSetQuickLoopMarker(context: JobContext, data: SetQui if (!playlist.activationId) throw new Error(`Playlist has no activationId!`) playoutModel.setQuickLoopMarker(data.type, data.marker) + // TODO: this needs to set Next if we're clearing while on the next part + await updateTimeline(context, playoutModel) // TODO: does this need a condition } ) } From c03f3baf15fc8e412305950a3292c268e0f88a4a Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 15 Jan 2024 10:49:35 +0100 Subject: [PATCH 014/276] wip(SOFIE-69): hide quickloop context menu when locked --- .../ui/SegmentTimeline/SegmentContextMenu.tsx | 67 +++++++++++-------- meteor/lib/Rundown.ts | 4 ++ 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx b/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx index 212f9762c6..f069ce8b2f 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx @@ -14,6 +14,7 @@ import { IContextMenuContext } from '../RundownView' import { PartUi, SegmentUi } from './SegmentTimelineContainer' import { SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' +import * as RundownLib from '../../../lib/Rundown' interface IProps { onSetNext: (part: DBPart | undefined, e: any, offset?: number, take?: boolean) => void @@ -105,35 +106,43 @@ export const SegmentContextMenu = withTranslation()( ) : null} - {this.props.playlist?.quickLoop?.start?.type === QuickLoopMarkerType.PART && - this.props.playlist.quickLoop.start.id === part.partId ? ( - this.props.onSetQuickLoopStart(null, e)}> - {t('Clear QuickLoop Start')} - - ) : ( - - this.props.onSetQuickLoopStart({ type: QuickLoopMarkerType.PART, id: part.instance.part._id }, e) - } - disabled={!!part.instance.orphaned || !canSetAsNext} - > - {t('Set as QuickLoop Start')} - - )} - {this.props.playlist?.quickLoop?.end?.type === QuickLoopMarkerType.PART && - this.props.playlist.quickLoop.end.id === part.partId ? ( - this.props.onSetQuickLoopEnd(null, e)}> - {t('Clear QuickLoop End')} - - ) : ( - - this.props.onSetQuickLoopEnd({ type: QuickLoopMarkerType.PART, id: part.instance.part._id }, e) - } - disabled={!!part.instance.orphaned || !canSetAsNext} - > - {t('Set as QuickLoop End')} - + {!RundownLib.isLoopLocked(this.props.playlist) && ( + <> + {RundownLib.isQuickLoopStart(part.partId, this.props.playlist) ? ( + this.props.onSetQuickLoopStart(null, e)}> + {t('Clear QuickLoop Start')} + + ) : ( + + this.props.onSetQuickLoopStart( + { type: QuickLoopMarkerType.PART, id: part.instance.part._id }, + e + ) + } + disabled={!!part.instance.orphaned || !canSetAsNext} + > + {t('Set as QuickLoop Start')} + + )} + {RundownLib.isQuickLoopEnd(part.partId, this.props.playlist) ? ( + this.props.onSetQuickLoopEnd(null, e)}> + {t('Clear QuickLoop End')} + + ) : ( + + this.props.onSetQuickLoopEnd( + { type: QuickLoopMarkerType.PART, id: part.instance.part._id }, + e + ) + } + disabled={!!part.instance.orphaned || !canSetAsNext} + > + {t('Set as QuickLoop End')} + + )} + )} )} diff --git a/meteor/lib/Rundown.ts b/meteor/lib/Rundown.ts index 7316b60fc6..a8207d6453 100644 --- a/meteor/lib/Rundown.ts +++ b/meteor/lib/Rundown.ts @@ -415,6 +415,10 @@ export function isLoopRunning(playlist: DBRundownPlaylist | undefined): boolean return !!playlist?.quickLoop?.running } +export function isLoopLocked(playlist: DBRundownPlaylist | undefined): boolean { + return !!playlist?.quickLoop?.locked +} + export function isQuickLoopStart(partId: PartId, playlist: DBRundownPlaylist | undefined): boolean { return playlist?.quickLoop?.start?.type === QuickLoopMarkerType.PART && playlist.quickLoop.start.id === partId } From a904c5997756b28e26383ee3b502066aef467b30 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 15 Jan 2024 10:54:52 +0100 Subject: [PATCH 015/276] wip(SOFIE-69): rename css class --- meteor/client/ui/SegmentList/LinePart.tsx | 3 ++- meteor/client/ui/SegmentList/SegmentList.scss | 2 +- meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss | 2 +- meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx | 2 +- meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/meteor/client/ui/SegmentList/LinePart.tsx b/meteor/client/ui/SegmentList/LinePart.tsx index 7f600fca04..3db51e115f 100644 --- a/meteor/client/ui/SegmentList/LinePart.tsx +++ b/meteor/client/ui/SegmentList/LinePart.tsx @@ -135,7 +135,8 @@ export const LinePart = withTiming((props: IProps) => { 'segment-opl__part--next': isNextPart, 'segment-opl__part--live': isLivePart, 'segment-opl__part--has-played': hasAlreadyPlayed && (!isPlaylistLooping || !isInQuickLoop), - 'segment-opl__part--out-of-the-loop': isPlaylistLooping && !isInQuickLoop && !isNextPart && !hasAlreadyPlayed, + 'segment-opl__part--outside-quickloop': + isPlaylistLooping && !isInQuickLoop && !isNextPart && !hasAlreadyPlayed, 'segment-opl__part--quickloop-start': isQuickLoopStart, 'segment-opl__part--invalid': part.instance.part.invalid, 'segment-opl__part--timing-sibling': isPreceededByTimingGroupSibling, diff --git a/meteor/client/ui/SegmentList/SegmentList.scss b/meteor/client/ui/SegmentList/SegmentList.scss index 4d8126a670..d52ccad341 100644 --- a/meteor/client/ui/SegmentList/SegmentList.scss +++ b/meteor/client/ui/SegmentList/SegmentList.scss @@ -231,7 +231,7 @@ $identifier-area-width: 3em; } } - &--out-of-the-loop { + &--outside-quickloop { &::before { content: ' '; display: block; diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss index 6b7d97f52d..d48c116579 100644 --- a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss +++ b/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss @@ -168,7 +168,7 @@ $break-width: 35rem; background: $segment-timeline-background-color; } - &--out-of-the-loop { + &--outside-quickloop { &::before { content: ' '; display: block; diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx b/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx index d45951c10a..08c345eb4e 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx +++ b/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx @@ -151,7 +151,7 @@ export const StoryboardPart = withTiming((props: IProps) => { 'segment-storyboard__part--next': isNextPart, 'segment-storyboard__part--live': isLivePart, 'segment-storyboard__part--invalid': part.instance.part.invalid, - 'segment-storyboard__part--out-of-the-loop': !isPartInQuickLoop && isPlaylistLooping && !isNextPart, + 'segment-storyboard__part--outside-quickloop': !isPartInQuickLoop && isPlaylistLooping && !isNextPart, 'segment-storyboard__part--quickloop-start': isQuickLoopStart, 'segment-storyboard__part--quickloop-end': isQuickLoopEnd, }, diff --git a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index ddb509ebc7..6af1a636db 100644 --- a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -682,7 +682,7 @@ export class SegmentTimelinePartClass extends React.Component Date: Mon, 15 Jan 2024 10:57:25 +0100 Subject: [PATCH 016/276] wip(SOFIE-69): metaData is now privateData --- meteor/server/publications/partsUI/reactiveContentCache.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/meteor/server/publications/partsUI/reactiveContentCache.ts b/meteor/server/publications/partsUI/reactiveContentCache.ts index 62de18307e..54fb83fe25 100644 --- a/meteor/server/publications/partsUI/reactiveContentCache.ts +++ b/meteor/server/publications/partsUI/reactiveContentCache.ts @@ -26,9 +26,9 @@ export const segmentFieldSpecifier = literal>>({ - metaData: 0, + privateData: 0, }) export interface ContentCache { From fb4608001cbebc4dfa55d84e91f176b4ac0864b5 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 15 Jan 2024 11:08:46 +0100 Subject: [PATCH 017/276] wip(SOFIE-69): rename css class --- meteor/client/styles/rundownView.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meteor/client/styles/rundownView.scss b/meteor/client/styles/rundownView.scss index fb33b5072c..4afc054906 100644 --- a/meteor/client/styles/rundownView.scss +++ b/meteor/client/styles/rundownView.scss @@ -1570,7 +1570,7 @@ svg.icon { z-index: 1; } - &.out-of-the-loop { + &.outside-quickloop { &::before { content: ' '; display: block; From 562ea2be603f1cef7251d799cbc3a9261c08a126 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 15 Jan 2024 11:09:45 +0100 Subject: [PATCH 018/276] wip(SOFIE-69): disable dots when looping entire playlist --- .../RundownView/RundownTiming/RundownTimingProvider.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index 15f46e2539..7635693973 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -18,7 +18,7 @@ import { PartId, PartInstanceId, SegmentId } from '@sofie-automation/corelib/dis import { RundownPlaylistCollectionUtil } from '../../../../lib/collections/rundownPlaylistUtil' import { sortPartInstancesInSortedSegments } from '@sofie-automation/corelib/dist/playout/playlist' import { CalculateTimingsPiece } from '@sofie-automation/corelib/dist/playout/timings' -import { isLoopDefined } from '../../../../lib/Rundown' +import { isLoopDefined, isLoopLocked } from '../../../../lib/Rundown' const TIMING_DEFAULT_REFRESH_INTERVAL = 1000 / 60 // the interval for high-resolution events (timeupdateHR) const LOW_RESOLUTION_TIMING_DECIMATOR = 15 @@ -361,7 +361,12 @@ function findPartInstancesInQuickLoop( sortedPartInstances: MinimalPartInstance[] ): Record { const partsInQuickLoop: Record = {} - if (!isLoopDefined(playlist)) return partsInQuickLoop + if ( + !isLoopDefined(playlist) || + isLoopLocked(playlist) // a crude way of disabling the dots when looping the entire playlist + ) { + return partsInQuickLoop + } let isInQuickLoop = playlist.quickLoop?.start?.type === QuickLoopMarkerType.PLAYLIST let previousPartInstance: MinimalPartInstance | undefined = undefined From 134938df0415e887f63dd6b2a56944a50e8d1435 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 15 Jan 2024 15:26:49 +0100 Subject: [PATCH 019/276] wip(SOFIE-69): invalid parts when forcing autonext and make quickloop optional --- meteor/client/ui/RundownView.tsx | 1 + .../ui/SegmentTimeline/SegmentContextMenu.tsx | 3 +- meteor/client/ui/Settings/Studio/Generic.tsx | 13 ++++- .../publications/partsUI/publication.ts | 48 ++++++++----------- packages/corelib/src/dataModel/Studio.ts | 3 ++ .../model/implementation/PlayoutModelImpl.ts | 3 +- .../PlayoutPartInstanceModelImpl.ts | 7 +-- .../job-worker/src/playout/selectNextPart.ts | 22 +++++++-- 8 files changed, 63 insertions(+), 37 deletions(-) diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 74514e6ac0..668bb025b7 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -3126,6 +3126,7 @@ const RundownViewContent = translateWithTracker diff --git a/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx b/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx index f069ce8b2f..cabc029a89 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx @@ -26,6 +26,7 @@ interface IProps { studioMode: boolean contextMenuContext: IContextMenuContext | null enablePlayFromAnywhere: boolean + enableQuickLoop: boolean } interface IState {} @@ -106,7 +107,7 @@ export const SegmentContextMenu = withTranslation()( ) : null} - {!RundownLib.isLoopLocked(this.props.playlist) && ( + {this.props.enableQuickLoop && !RundownLib.isLoopLocked(this.props.playlist) && ( <> {RundownLib.isQuickLoopStart(part.partId, this.props.playlist) ? ( this.props.onSetQuickLoopStart(null, e)}> diff --git a/meteor/client/ui/Settings/Studio/Generic.tsx b/meteor/client/ui/Settings/Studio/Generic.tsx index a0477b0cfa..47bc2f761e 100644 --- a/meteor/client/ui/Settings/Studio/Generic.tsx +++ b/meteor/client/ui/Settings/Studio/Generic.tsx @@ -256,6 +256,17 @@ export const StudioGenericProperties = withTranslation()( /> + + + + +
) diff --git a/meteor/lib/api/rest/v1/studios.ts b/meteor/lib/api/rest/v1/studios.ts index ce3e967ed8..eb00574875 100644 --- a/meteor/lib/api/rest/v1/studios.ts +++ b/meteor/lib/api/rest/v1/studios.ts @@ -182,5 +182,8 @@ export interface APIStudioSettings { multiGatewayNowSafeLatency?: number allowRundownResetOnAir?: boolean preserveOrphanedSegmentPositionInRundown?: boolean + enableQuickLoop?: boolean + forceQuickLoopAutoNext?: 'disabled' | 'enabled_when_valid_duration' | 'enabled_forcing_min_duration' minimumTakeSpan?: number + fallbackPartDuration?: number } diff --git a/meteor/server/api/rest/v1/typeConversion.ts b/meteor/server/api/rest/v1/typeConversion.ts index 6c2fc9128f..0cffded016 100644 --- a/meteor/server/api/rest/v1/typeConversion.ts +++ b/meteor/server/api/rest/v1/typeConversion.ts @@ -33,8 +33,12 @@ import { import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' import { Blueprints, ShowStyleBases, Studios } from '../../../collections' -import { DEFAULT_MINIMUM_TAKE_SPAN } from '@sofie-automation/shared-lib/dist/core/constants' +import { + DEFAULT_FALLBACK_PART_DURATION, + DEFAULT_MINIMUM_TAKE_SPAN, +} from '@sofie-automation/shared-lib/dist/core/constants' import { Bucket } from '../../../../lib/collections/Buckets' +import { ForceQuickLoopAutoNext } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' /* This file contains functions that convert between the internal Sofie-Core types and types exposed to the external API. @@ -308,6 +312,9 @@ export function studioSettingsFrom(apiStudioSettings: APIStudioSettings): IStudi allowRundownResetOnAir: apiStudioSettings.allowRundownResetOnAir, preserveOrphanedSegmentPositionInRundown: apiStudioSettings.preserveOrphanedSegmentPositionInRundown, minimumTakeSpan: apiStudioSettings.minimumTakeSpan ?? DEFAULT_MINIMUM_TAKE_SPAN, + enableQuickLoop: apiStudioSettings.enableQuickLoop, + forceQuickLoopAutoNext: forceQuickLoopAutoNextFrom(apiStudioSettings.forceQuickLoopAutoNext), + fallbackPartDuration: apiStudioSettings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION, } } @@ -324,6 +331,42 @@ export function APIStudioSettingsFrom(settings: IStudioSettings): APIStudioSetti allowRundownResetOnAir: settings.allowRundownResetOnAir, preserveOrphanedSegmentPositionInRundown: settings.preserveOrphanedSegmentPositionInRundown, minimumTakeSpan: settings.minimumTakeSpan, + enableQuickLoop: settings.enableQuickLoop, + forceQuickLoopAutoNext: APIForceQuickLoopAutoNextFrom(settings.forceQuickLoopAutoNext), + fallbackPartDuration: settings.fallbackPartDuration, + } +} + +export function forceQuickLoopAutoNextFrom( + forceQuickLoopAutoNext: APIStudioSettings['forceQuickLoopAutoNext'] +): ForceQuickLoopAutoNext | undefined { + if (!forceQuickLoopAutoNext) return undefined + switch (forceQuickLoopAutoNext) { + case 'disabled': + return ForceQuickLoopAutoNext.DISABLED + case 'enabled_forcing_min_duration': + return ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION + case 'enabled_when_valid_duration': + return ForceQuickLoopAutoNext.ENABLED_WHEN_VALID_DURATION + default: + assertNever(forceQuickLoopAutoNext) + return undefined + } +} + +export function APIForceQuickLoopAutoNextFrom( + forceQuickLoopAutoNext: ForceQuickLoopAutoNext | undefined +): APIStudioSettings['forceQuickLoopAutoNext'] { + if (!forceQuickLoopAutoNext) return undefined + switch (forceQuickLoopAutoNext) { + case ForceQuickLoopAutoNext.DISABLED: + return 'disabled' + case ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION: + return 'enabled_forcing_min_duration' + case ForceQuickLoopAutoNext.ENABLED_WHEN_VALID_DURATION: + return 'enabled_when_valid_duration' + default: + assertNever(forceQuickLoopAutoNext) } } diff --git a/meteor/server/publications/partsUI/publication.ts b/meteor/server/publications/partsUI/publication.ts index dc825ed8a4..d3f7482de2 100644 --- a/meteor/server/publications/partsUI/publication.ts +++ b/meteor/server/publications/partsUI/publication.ts @@ -29,6 +29,8 @@ import { RundownContentObserver } from './rundownContentObserver' import { ProtectedString, protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep' import { generateTranslation } from '../../../lib/lib' +import { DEFAULT_FALLBACK_PART_DURATION } from '@sofie-automation/shared-lib/dist/core/constants' +import { MarkerPosition, compareMarkerPositions } from '@sofie-automation/corelib/dist/playout/playlist' interface UIPartsArgs { readonly playlistId: RundownPlaylistId @@ -74,7 +76,7 @@ async function setupUIPartsPublicationObservers( // Push update triggerUpdate({ newCache: cache }) - const obs1 = new RundownContentObserver(playlist._id, rundownIds, cache) + const obs1 = new RundownContentObserver(playlist.studioId, playlist._id, rundownIds, cache) const innerQueries = [ cache.Segments.find({}).observeChanges({ @@ -92,6 +94,11 @@ async function setupUIPartsPublicationObservers( changed: () => triggerUpdate({ invalidateQuickLoop: true }), removed: () => triggerUpdate({ invalidateQuickLoop: true }), }), + cache.Studios.find({}).observeChanges({ + added: () => triggerUpdate({ invalidateQuickLoop: true }), + changed: () => triggerUpdate({ invalidateQuickLoop: true }), + removed: () => triggerUpdate({ invalidateQuickLoop: true }), + }), ] return () => { @@ -132,6 +139,9 @@ export async function manipulateUIPartsPublicationData( const playlist = state.contentCache.RundownPlaylists.find({}).fetch()[0] if (!playlist) return + const studio = state.contentCache.Studios.find({}).fetch()[0] + if (!studio) return + if (!playlist.quickLoop?.start || !playlist.quickLoop?.end) { collection.remove(null) state.contentCache.Parts.find({}).forEach((part) => { @@ -147,26 +157,27 @@ export async function manipulateUIPartsPublicationData( const quickLoopStartPosition = playlist.quickLoop?.start && - extractMarkerPosition(playlist.quickLoop.start, -Infinity, state.contentCache, rundownRanks) + findMarkerPosition(playlist.quickLoop.start, -Infinity, state.contentCache, rundownRanks) const quickLoopEndPosition = playlist.quickLoop?.end && - extractMarkerPosition(playlist.quickLoop.end, Infinity, state.contentCache, rundownRanks) + findMarkerPosition(playlist.quickLoop.end, Infinity, state.contentCache, rundownRanks) const isLoopDefined = playlist.quickLoop?.start && playlist.quickLoop?.end && quickLoopStartPosition && quickLoopEndPosition function modifyPartForQuickLoop(part: DBPart) { - const partPosition = extractPartPosition(part, segmentRanks, rundownRanks) + const partPosition = findPartPosition(part, segmentRanks, rundownRanks) const isLoopingOverriden = isLoopDefined && playlist.quickLoop?.forceAutoNext !== ForceQuickLoopAutoNext.DISABLED && - comparePositions(quickLoopStartPosition, partPosition) >= 0 && - comparePositions(partPosition, quickLoopEndPosition) >= 0 + compareMarkerPositions(quickLoopStartPosition, partPosition) >= 0 && + compareMarkerPositions(partPosition, quickLoopEndPosition) >= 0 if (isLoopingOverriden && (part.expectedDuration ?? 0) <= 0) { if (playlist.quickLoop?.forceAutoNext === ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION) { - part.expectedDuration = 3000 // TODO: use settings - part.expectedDurationWithPreroll = 3000 + part.expectedDuration = studio.settings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION + part.expectedDurationWithPreroll = + studio.settings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION } else if (playlist.quickLoop?.forceAutoNext === ForceQuickLoopAutoNext.ENABLED_WHEN_VALID_DURATION) { part.invalid = true part.invalidReason = { @@ -183,47 +194,41 @@ export async function manipulateUIPartsPublicationData( }) } -const comparePositions = (a: [number, number, number], b: [number, number, number]): number => { - if (a[0] > b[0]) return -1 - if (a[0] < b[0]) return 1 - if (a[1] > b[1]) return -1 - if (a[1] < b[1]) return 1 - if (a[2] > b[2]) return -1 - if (a[2] < b[2]) return 1 - return 0 -} - -function extractMarkerPosition( +function findMarkerPosition( marker: QuickLoopMarker, fallback: number, contentCache: ReadonlyObjectDeep, rundownRanks: Record -): [number, number, number] { - const startPart = marker.type === QuickLoopMarkerType.PART ? contentCache.Parts.findOne(marker.id) : undefined - const startPartRank = startPart?._rank ?? fallback +): MarkerPosition { + const part = marker.type === QuickLoopMarkerType.PART ? contentCache.Parts.findOne(marker.id) : undefined + const partRank = part?._rank ?? fallback - const startSegmentId = marker.type === QuickLoopMarkerType.SEGMENT ? marker.id : startPart?.segmentId - const startSegment = startSegmentId && contentCache.Segments.findOne(startSegmentId) - const startSegmentRank = startSegment?._rank ?? fallback + const segmentId = marker.type === QuickLoopMarkerType.SEGMENT ? marker.id : part?.segmentId + const segment = segmentId && contentCache.Segments.findOne(segmentId) + const segmentRank = segment?._rank ?? fallback - const startRundownId = marker.type === QuickLoopMarkerType.RUNDOWN ? marker.id : startSegment?.rundownId - let startRundownRank = startRundownId ? rundownRanks[unprotectString(startRundownId)] : fallback + const rundownId = marker.type === QuickLoopMarkerType.RUNDOWN ? marker.id : segment?.rundownId + let rundownRank = rundownId ? rundownRanks[unprotectString(rundownId)] : fallback - if (marker.type === QuickLoopMarkerType.PLAYLIST) startRundownRank = fallback + if (marker.type === QuickLoopMarkerType.PLAYLIST) rundownRank = fallback - return [startRundownRank, startSegmentRank, startPartRank] + return { + rundownRank: rundownRank, + segmentRank: segmentRank, + partRank: partRank, + } } -function extractPartPosition( +function findPartPosition( part: DBPart, segmentRanks: Record, rundownRanks: Record -): [number, number, number] { - return [ - rundownRanks[part.rundownId as unknown as string] ?? 0, - segmentRanks[part.segmentId as unknown as string] ?? 0, - part._rank, - ] +): MarkerPosition { + return { + rundownRank: rundownRanks[part.rundownId as unknown as string] ?? 0, + segmentRank: segmentRanks[part.segmentId as unknown as string] ?? 0, + partRank: part._rank, + } } function stringsToIndexLookup(strings: string[]): Record { diff --git a/meteor/server/publications/partsUI/reactiveContentCache.ts b/meteor/server/publications/partsUI/reactiveContentCache.ts index 112ae12780..3d2e0afcc6 100644 --- a/meteor/server/publications/partsUI/reactiveContentCache.ts +++ b/meteor/server/publications/partsUI/reactiveContentCache.ts @@ -4,6 +4,7 @@ import { ReactiveCacheCollection } from '../lib/ReactiveCacheCollection' import { literal } from '@sofie-automation/corelib/dist/lib' import { MongoFieldSpecifierOnesStrict, MongoFieldSpecifierZeroes } from '@sofie-automation/corelib/dist/mongo' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' export type RundownPlaylistCompact = Pick export const rundownPlaylistFieldSpecifier = literal>({ @@ -25,7 +26,14 @@ export const partFieldSpecifier = literal>>({ + _id: 1, + settings: 1, +}) + export interface ContentCache { + Studios: ReactiveCacheCollection> Segments: ReactiveCacheCollection> Parts: ReactiveCacheCollection> RundownPlaylists: ReactiveCacheCollection @@ -33,6 +41,7 @@ export interface ContentCache { export function createReactiveContentCache(): ContentCache { const cache: ContentCache = { + Studios: new ReactiveCacheCollection>('rundownPlaylists'), Segments: new ReactiveCacheCollection>('segments'), Parts: new ReactiveCacheCollection>('parts'), RundownPlaylists: new ReactiveCacheCollection('rundownPlaylists'), diff --git a/meteor/server/publications/partsUI/rundownContentObserver.ts b/meteor/server/publications/partsUI/rundownContentObserver.ts index 46322c622b..e9de9dd780 100644 --- a/meteor/server/publications/partsUI/rundownContentObserver.ts +++ b/meteor/server/publications/partsUI/rundownContentObserver.ts @@ -1,23 +1,33 @@ import { Meteor } from 'meteor/meteor' -import { RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { RundownId, RundownPlaylistId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { logger } from '../../logging' import { ContentCache, partFieldSpecifier, rundownPlaylistFieldSpecifier, segmentFieldSpecifier, + studioFieldSpecifier, } from './reactiveContentCache' -import { Parts, RundownPlaylists, Segments } from '../../collections' +import { Parts, RundownPlaylists, Segments, Studios } from '../../collections' export class RundownContentObserver { #observers: Meteor.LiveQueryHandle[] = [] #cache: ContentCache - constructor(playlistId: RundownPlaylistId, rundownIds: RundownId[], cache: ContentCache) { + constructor(studioId: StudioId, playlistId: RundownPlaylistId, rundownIds: RundownId[], cache: ContentCache) { logger.silly(`Creating RundownContentObserver for rundowns "${rundownIds.join(',')}"`) this.#cache = cache this.#observers = [ + Studios.observeChanges( + { + _id: studioId, + }, + cache.Studios.link(), + { + fields: studioFieldSpecifier, + } + ), RundownPlaylists.observeChanges( { _id: playlistId, diff --git a/packages/corelib/src/dataModel/Studio.ts b/packages/corelib/src/dataModel/Studio.ts index 2f39fe2cf2..e001b7d1eb 100644 --- a/packages/corelib/src/dataModel/Studio.ts +++ b/packages/corelib/src/dataModel/Studio.ts @@ -52,11 +52,17 @@ export interface IStudioSettings { /** Whether to allow scratchpad mode, before a Part is playing in a Playlist */ allowScratchpad?: boolean - /** Should QuickLoop context menu options be available to the users */ - enableQuickLoop?: ForceQuickLoopAutoNext + /** Should QuickLoop context menu options be available to the users. It does not affect Playlist loop enabled by the NRCS. */ + enableQuickLoop?: boolean /** If and how to force auto-nexting in a looping Playlist */ forceQuickLoopAutoNext?: ForceQuickLoopAutoNext + + /** + * The duration to apply on too short Parts Within QuickLoop when ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION is selected + * Default: 3000 + */ + fallbackPartDuration?: number } export type StudioLight = Omit diff --git a/packages/corelib/src/playout/playlist.ts b/packages/corelib/src/playout/playlist.ts index dd53fa50f6..8c7368cfbf 100644 --- a/packages/corelib/src/playout/playlist.ts +++ b/packages/corelib/src/playout/playlist.ts @@ -89,3 +89,19 @@ export function sortRundownIDsInPlaylist( return [...sortedVerifiedExisting, ...missingIds] } + +export type MarkerPosition = { + partRank: number + segmentRank: number + rundownRank: number +} + +export function compareMarkerPositions(a: MarkerPosition, b: MarkerPosition): number { + if (a.rundownRank > b.rundownRank) return -1 + if (a.rundownRank < b.rundownRank) return 1 + if (a.segmentRank > b.segmentRank) return -1 + if (a.segmentRank < b.segmentRank) return 1 + if (a.partRank > b.partRank) return -1 + if (a.partRank < b.partRank) return 1 + return 0 +} diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 4d9ed3518c..bf85e0ba6d 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -40,7 +40,11 @@ import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { PlaylistLock } from '../../../jobs/lock' import { logger } from '../../../logging' import { clone, getRandomId, literal, normalizeArrayToMapFunc, sleep } from '@sofie-automation/corelib/dist/lib' -import { sortRundownIDsInPlaylist } from '@sofie-automation/corelib/dist/playout/playlist' +import { + MarkerPosition, + compareMarkerPositions, + sortRundownIDsInPlaylist, +} from '@sofie-automation/corelib/dist/playout/playlist' import { PlayoutRundownModel } from '../PlayoutRundownModel' import { PlayoutRundownModelImpl } from './PlayoutRundownModelImpl' import { PlayoutSegmentModel } from '../PlayoutSegmentModel' @@ -61,6 +65,7 @@ import { EventsJobs } from '@sofie-automation/corelib/dist/worker/events' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep' import { IBlueprintMutatablePart } from '@sofie-automation/blueprints-integration' +import { DEFAULT_FALLBACK_PART_DURATION } from '@sofie-automation/shared-lib/dist/core/constants' export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { public readonly playlistId: RundownPlaylistId @@ -465,80 +470,34 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou const orderedParts = this.getAllOrderedParts() const rundownIds = this.getRundownIds() - type Positions = { - partRank: number - segmentRank: number - rundownRank: number - } - const findMarkerPosition = (marker: QuickLoopMarker, type: 'start' | 'end'): Positions => { - let part: ReadonlyObjectDeep | undefined - let segment: ReadonlyObjectDeep | undefined - let rundownRank - if (marker.type === QuickLoopMarkerType.PART) { - const partId = marker.id - const partIndex = orderedParts.findIndex((part) => part._id === partId) - part = orderedParts[partIndex] - } - if (marker.type === QuickLoopMarkerType.SEGMENT) { - segment = this.findSegment(marker.id)?.segment - } else if (part != null) { - segment = this.findSegment(part.segmentId)?.segment - } - if (marker.type === QuickLoopMarkerType.RUNDOWN) { - rundownRank = rundownIds.findIndex((id) => id === marker.id) - } else if (part ?? segment != null) { - rundownRank = rundownIds.findIndex((id) => id === (part ?? segment)?.rundownId) - } - const fallback = type === 'start' ? -Infinity : Infinity - return { - partRank: part?._rank ?? fallback, - segmentRank: segment?._rank ?? fallback, - rundownRank: rundownRank ?? fallback, - } - } - const startPosition = findMarkerPosition(this.playlistImpl.quickLoop.start, 'start') - const endPosition = findMarkerPosition(this.playlistImpl.quickLoop.end, 'end') - - const comparePositions = (a: Positions, b: Positions): number => { - if (a.rundownRank > b.rundownRank) return -1 - if (a.rundownRank < b.rundownRank) return 1 - if (a.segmentRank > b.segmentRank) return -1 - if (a.segmentRank < b.segmentRank) return 1 - if (a.partRank > b.partRank) return -1 - if (a.partRank < b.partRank) return 1 - return 0 - } - - const extractPartPosition = (partInstance: PlayoutPartInstanceModel) => { - const currentSegment = this.findSegment(partInstance.partInstance.segmentId)?.segment - const currentRundownIndex = rundownIds.findIndex((id) => id === partInstance.partInstance.rundownId) - - return { - partRank: partInstance.partInstance.part._rank, - segmentRank: currentSegment?._rank ?? 0, - rundownRank: currentRundownIndex ?? 0, - } - } + const startPosition = this.findQuickLoopMarkerPosition( + this.playlistImpl.quickLoop.start, + 'start', + orderedParts, + rundownIds + ) + const endPosition = this.findQuickLoopMarkerPosition( + this.playlistImpl.quickLoop.end, + 'end', + orderedParts, + rundownIds + ) - const currentPartPosition: Positions | undefined = this.currentPartInstance - ? extractPartPosition(this.currentPartInstance) - : undefined - const nextPartPosition: Positions | undefined = this.nextPartInstance - ? extractPartPosition(this.nextPartInstance) - : undefined + const currentPartPosition = this.findPartPosition(this.currentPartInstance, rundownIds) + const nextPartPosition = this.findPartPosition(this.nextPartInstance, rundownIds) const isCurrentBetweenMarkers = currentPartPosition - ? comparePositions(startPosition, currentPartPosition) >= 0 && - comparePositions(currentPartPosition, endPosition) >= 0 + ? compareMarkerPositions(startPosition, currentPartPosition) >= 0 && + compareMarkerPositions(currentPartPosition, endPosition) >= 0 : false isNextBetweenMarkers = nextPartPosition - ? comparePositions(startPosition, nextPartPosition) >= 0 && - comparePositions(nextPartPosition, endPosition) >= 0 + ? compareMarkerPositions(startPosition, nextPartPosition) >= 0 && + compareMarkerPositions(nextPartPosition, endPosition) >= 0 : false if (this.nextPartInstance && isNextBetweenMarkers) { - updatePartOverrides(this.nextPartInstance, this.playlistImpl.quickLoop.forceAutoNext) + this.updateQuickLoopPartOverrides(this.nextPartInstance, this.playlistImpl.quickLoop.forceAutoNext) } this.playlistImpl.quickLoop.running = @@ -548,13 +507,13 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou } if (this.currentPartInstance && this.playlistImpl.quickLoop.running) { - updatePartOverrides(this.currentPartInstance, this.playlistImpl.quickLoop.forceAutoNext) + this.updateQuickLoopPartOverrides(this.currentPartInstance, this.playlistImpl.quickLoop.forceAutoNext) } else if (this.currentPartInstance && wasLoopRunning) { - revertPartOverrides(this.currentPartInstance) + this.revertQuickLoopPartOverrides(this.currentPartInstance) } if (this.nextPartInstance && !isNextBetweenMarkers) { - revertPartOverrides(this.nextPartInstance) + this.revertQuickLoopPartOverrides(this.nextPartInstance) } if (wasLoopRunning && !this.playlistImpl.quickLoop.running) { @@ -562,39 +521,90 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou delete this.playlistImpl.quickLoop } this.#playlistHasChanged = true + } - function updatePartOverrides(partInstance: PlayoutPartInstanceModel, forceAutoNext: ForceQuickLoopAutoNext) { - const partPropsToUpdate: Partial = {} - if ( - !partInstance.partInstance.part.expectedDuration && - forceAutoNext === ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION - ) { - partPropsToUpdate.expectedDuration = 3000 // TODO: where to take the default duration from? - } - if ( - (partInstance.partInstance.part.expectedDuration || partPropsToUpdate.expectedDuration) && - forceAutoNext !== ForceQuickLoopAutoNext.DISABLED && - !partInstance.partInstance.part.autoNext - ) { - partPropsToUpdate.autoNext = true - } - if (Object.keys(partPropsToUpdate).length) { - partInstance.overridePartProps(partPropsToUpdate) - if (partPropsToUpdate.expectedDuration) partInstance.recalculateExpectedDurationWithPreroll() - } + private findQuickLoopMarkerPosition( + marker: QuickLoopMarker, + type: 'start' | 'end', + orderedParts: ReadonlyObjectDeep[], + rundownIds: RundownId[] + ): MarkerPosition { + let part: ReadonlyObjectDeep | undefined + let segment: ReadonlyObjectDeep | undefined + let rundownRank + if (marker.type === QuickLoopMarkerType.PART) { + const partId = marker.id + const partIndex = orderedParts.findIndex((part) => part._id === partId) + part = orderedParts[partIndex] + } + if (marker.type === QuickLoopMarkerType.SEGMENT) { + segment = this.findSegment(marker.id)?.segment + } else if (part != null) { + segment = this.findSegment(part.segmentId)?.segment + } + if (marker.type === QuickLoopMarkerType.RUNDOWN) { + rundownRank = rundownIds.findIndex((id) => id === marker.id) + } else if (part ?? segment != null) { + rundownRank = rundownIds.findIndex((id) => id === (part ?? segment)?.rundownId) + } + const fallback = type === 'start' ? -Infinity : Infinity + return { + partRank: part?._rank ?? fallback, + segmentRank: segment?._rank ?? fallback, + rundownRank: rundownRank ?? fallback, + } + } + + private updateQuickLoopPartOverrides( + partInstance: PlayoutPartInstanceModel, + forceAutoNext: ForceQuickLoopAutoNext + ): void { + const partPropsToUpdate: Partial = {} + if ( + !partInstance.partInstance.part.expectedDuration && + forceAutoNext === ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION + ) { + partPropsToUpdate.expectedDuration = + this.context.studio.settings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION + } + if ( + (partInstance.partInstance.part.expectedDuration || partPropsToUpdate.expectedDuration) && + forceAutoNext !== ForceQuickLoopAutoNext.DISABLED && + !partInstance.partInstance.part.autoNext + ) { + partPropsToUpdate.autoNext = true } + if (Object.keys(partPropsToUpdate).length) { + partInstance.overridePartProps(partPropsToUpdate) + if (partPropsToUpdate.expectedDuration) partInstance.recalculateExpectedDurationWithPreroll() + } + } - function revertPartOverrides(partInstance: PlayoutPartInstanceModel) { - const overridenProperties = partInstance.partInstance.part.overridenProperties - if (overridenProperties) { - partInstance.revertOverridenPartProps() - if (overridenProperties.expectedDuration) { - partInstance.recalculateExpectedDurationWithPreroll() - } + private revertQuickLoopPartOverrides(partInstance: PlayoutPartInstanceModel) { + const overridenProperties = partInstance.partInstance.part.overridenProperties + if (overridenProperties) { + partInstance.revertOverridenPartProps() + if (overridenProperties.expectedDuration) { + partInstance.recalculateExpectedDurationWithPreroll() } } } + private findPartPosition( + partInstance: PlayoutPartInstanceModel | null, + rundownIds: RundownId[] + ): MarkerPosition | undefined { + if (partInstance == null) return undefined + const currentSegment = this.findSegment(partInstance.partInstance.segmentId)?.segment + const currentRundownIndex = rundownIds.findIndex((id) => id === partInstance.partInstance.rundownId) + + return { + partRank: partInstance.partInstance.part._rank, + segmentRank: currentSegment?._rank ?? 0, + rundownRank: currentRundownIndex ?? 0, + } + } + deactivatePlaylist(): void { delete this.playlistImpl.activationId diff --git a/packages/openapi/api/definitions/studios.yaml b/packages/openapi/api/definitions/studios.yaml index 1c707808ca..8b70c2e7e5 100644 --- a/packages/openapi/api/definitions/studios.yaml +++ b/packages/openapi/api/definitions/studios.yaml @@ -466,6 +466,17 @@ components: preserveOrphanedSegmentPositionInRundown: type: boolean description: Preserve unsynced segments psoition in the rundown, relative to the other segments + enableQuickLoop: + type: boolean + description: Should QuickLoop context menu options be available to the users + forceQuickLoopAutoNext: + type: string + enum: [disabled, enabled_when_valid_duration, enabled_forcing_min_duration] + description: If and how to force auto-nexting in a looping Playlist + fallbackPartDuration: + type: number + description: The duration to apply on too short Parts Within QuickLoop when forceQuickLoopAutoNext is set to `enabled_forcing_min_duration` + required: - frameRate - mediaPreviewsUrl diff --git a/packages/shared-lib/src/core/constants.ts b/packages/shared-lib/src/core/constants.ts index 494e2eb7f7..d36e413229 100644 --- a/packages/shared-lib/src/core/constants.ts +++ b/packages/shared-lib/src/core/constants.ts @@ -19,5 +19,8 @@ export const DEFAULT_TSR_ACTION_TIMEOUT_TIME = 5 * 1000 /** How much time must pass, in milliseconds, after a take before another take is allowed */ export const DEFAULT_MINIMUM_TAKE_SPAN = 1000 +/** The duration to apply on too short Parts Within QuickLoop when ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION is selected */ +export const DEFAULT_FALLBACK_PART_DURATION = 3000 + /** The expected time it takes from an ingest operation to receiving a new timeline in the playout-gateway */ export const EXPECTED_INGEST_TO_PLAYOUT_TIME = 500 From 81fb057495e0d3e1093bb655e067f6c226a9c3b1 Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 1 Feb 2024 12:28:20 +0100 Subject: [PATCH 026/276] wip(SOFIE-69): changing next part when moving markers --- .../src/playout/quickLoopMarkers.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/job-worker/src/playout/quickLoopMarkers.ts b/packages/job-worker/src/playout/quickLoopMarkers.ts index b9011b6dd3..7026f6fdeb 100644 --- a/packages/job-worker/src/playout/quickLoopMarkers.ts +++ b/packages/job-worker/src/playout/quickLoopMarkers.ts @@ -3,6 +3,8 @@ import { SetQuickLoopMarkerProps } from '@sofie-automation/corelib/dist/worker/s import { JobContext } from '../jobs' import { runJobWithPlayoutModel } from './lock' import { updateTimeline } from './timeline/generate' +import { selectNextPart } from './selectNextPart' +import { setNextPart } from './setNext' export async function handleSetQuickLoopMarker(context: JobContext, data: SetQuickLoopMarkerProps): Promise { return runJobWithPlayoutModel( @@ -16,9 +18,27 @@ export async function handleSetQuickLoopMarker(context: JobContext, data: SetQui const playlist = playoutModel.playlist if (!playlist.activationId) throw new Error(`Playlist has no activationId!`) + const wasCurrentPartAutoNexting = playoutModel.currentPartInstance?.partInstance.part.autoNext playoutModel.setQuickLoopMarker(data.type, data.marker) - // TODO: this needs to set Next if we're clearing while on the next part - await updateTimeline(context, playoutModel) // TODO: does this need a condition + + const nextPart = selectNextPart( + context, + playoutModel.playlist, + null, + null, + playoutModel.getAllOrderedSegments(), + playoutModel.getAllOrderedParts(), + false, + false + ) + if (nextPart?.part._id !== playoutModel.nextPartInstance?.partInstance.part._id) { + await setNextPart(context, playoutModel, nextPart, false) + } + const isCurrentPartAutoNexting = playoutModel.currentPartInstance?.partInstance.part.autoNext + + if (wasCurrentPartAutoNexting !== isCurrentPartAutoNexting) { + await updateTimeline(context, playoutModel) + } } ) } From 1af9cfd65c7e42883a79be0733655a14afbe0200 Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 1 Feb 2024 12:52:31 +0100 Subject: [PATCH 027/276] wip(SOFIE-69): fix changing next when moving markers --- .../src/playout/quickLoopMarkers.ts | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/job-worker/src/playout/quickLoopMarkers.ts b/packages/job-worker/src/playout/quickLoopMarkers.ts index 7026f6fdeb..c74fa1d09a 100644 --- a/packages/job-worker/src/playout/quickLoopMarkers.ts +++ b/packages/job-worker/src/playout/quickLoopMarkers.ts @@ -17,28 +17,25 @@ export async function handleSetQuickLoopMarker(context: JobContext, data: SetQui async (playoutModel) => { const playlist = playoutModel.playlist if (!playlist.activationId) throw new Error(`Playlist has no activationId!`) - - const wasCurrentPartAutoNexting = playoutModel.currentPartInstance?.partInstance.part.autoNext + const wasQuickLoopRunning = playoutModel.playlist.quickLoop?.running playoutModel.setQuickLoopMarker(data.type, data.marker) - const nextPart = selectNextPart( - context, - playoutModel.playlist, - null, - null, - playoutModel.getAllOrderedSegments(), - playoutModel.getAllOrderedParts(), - false, - false - ) - if (nextPart?.part._id !== playoutModel.nextPartInstance?.partInstance.part._id) { - await setNextPart(context, playoutModel, nextPart, false) - } - const isCurrentPartAutoNexting = playoutModel.currentPartInstance?.partInstance.part.autoNext - - if (wasCurrentPartAutoNexting !== isCurrentPartAutoNexting) { - await updateTimeline(context, playoutModel) + if (wasQuickLoopRunning) { + const nextPart = selectNextPart( + context, + playoutModel.playlist, + playoutModel.currentPartInstance?.partInstance ?? null, + playoutModel.nextPartInstance?.partInstance ?? null, + playoutModel.getAllOrderedSegments(), + playoutModel.getAllOrderedParts(), + false, + false + ) + if (nextPart?.part._id !== playoutModel.nextPartInstance?.partInstance.part._id) { + await setNextPart(context, playoutModel, nextPart, false) + } } + await updateTimeline(context, playoutModel) } ) } From 951a79c20d6cbd878ec5f1967109e98912e2f4d0 Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 1 Feb 2024 17:19:47 +0100 Subject: [PATCH 028/276] wip(SOFIE-69): solve wrong marker order --- .../model/implementation/PlayoutModelImpl.ts | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index bf85e0ba6d..6003e04a10 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -449,7 +449,7 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } - updateQuickLoopState(): void { + updateQuickLoopState(hasJustSetMarker?: 'start' | 'end'): void { if (this.playlistImpl.quickLoop == null) return const wasLoopRunning = this.playlistImpl.quickLoop.running @@ -484,20 +484,30 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou rundownIds ) - const currentPartPosition = this.findPartPosition(this.currentPartInstance, rundownIds) - const nextPartPosition = this.findPartPosition(this.nextPartInstance, rundownIds) + let isCurrentBetweenMarkers = false - const isCurrentBetweenMarkers = currentPartPosition - ? compareMarkerPositions(startPosition, currentPartPosition) >= 0 && - compareMarkerPositions(currentPartPosition, endPosition) >= 0 - : false - isNextBetweenMarkers = nextPartPosition - ? compareMarkerPositions(startPosition, nextPartPosition) >= 0 && - compareMarkerPositions(nextPartPosition, endPosition) >= 0 - : false - - if (this.nextPartInstance && isNextBetweenMarkers) { - this.updateQuickLoopPartOverrides(this.nextPartInstance, this.playlistImpl.quickLoop.forceAutoNext) + if (compareMarkerPositions(startPosition, endPosition) < 0) { + if (hasJustSetMarker === 'start') { + delete this.playlistImpl.quickLoop.end + } else if (hasJustSetMarker === 'end') { + delete this.playlistImpl.quickLoop.start + } + } else { + const currentPartPosition = this.findPartPosition(this.currentPartInstance, rundownIds) + const nextPartPosition = this.findPartPosition(this.nextPartInstance, rundownIds) + + isCurrentBetweenMarkers = currentPartPosition + ? compareMarkerPositions(startPosition, currentPartPosition) >= 0 && + compareMarkerPositions(currentPartPosition, endPosition) >= 0 + : false + isNextBetweenMarkers = nextPartPosition + ? compareMarkerPositions(startPosition, nextPartPosition) >= 0 && + compareMarkerPositions(nextPartPosition, endPosition) >= 0 + : false + + if (this.nextPartInstance && isNextBetweenMarkers) { + this.updateQuickLoopPartOverrides(this.nextPartInstance, this.playlistImpl.quickLoop.forceAutoNext) + } } this.playlistImpl.quickLoop.running = @@ -886,7 +896,7 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou } } - this.updateQuickLoopState() + this.updateQuickLoopState(type) this.#playlistHasChanged = true } From faaf2f96b139128edce1f565532a060fa1942284 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Fri, 16 Feb 2024 15:00:55 +0000 Subject: [PATCH 029/276] Update DEVELOPER.md --- DEVELOPER.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index 6f71b339b4..7177a94d65 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -18,11 +18,14 @@ Follow these instructions to start up Sofie Core in development mode. (For produ ### Prerequisites -- Install [Node.js](https://nodejs.org) 18 (14 should also work) (using [nvm](https://github.com/nvm-sh/nvm) or [nvm-windows](https://github.com/coreybutler/nvm-windows) is the recommended way to install Node.js) -- If on Windows: `npm install --global windows-build-tools` +- Install [Node.js](https://nodejs.org) 14 (using [nvm](https://github.com/nvm-sh/nvm) or [nvm-windows](https://github.com/coreybutler/nvm-windows) is the recommended way to install Node.js) - Install [Meteor](https://www.meteor.com/install) (`npm install --global meteor`) +- Install [Node.js](https://nodejs.org) 18 (using the same method you used above, you can uninstall node 14 if needed) +- Install an older version of corepack (`npm --global i corepack@0.15.3`) - Enable [corepack](https://nodejs.org/api/corepack.html#corepack) (`corepack enable`) as administrator/root. If `corepack` is not found, you may need to install it first with `npm install --global corepack` +- If on Windows, you may need to `npm install --global windows-build-tools` but this is not always necessary + ### Quick Start ```bash From 27fbb3978ad7e896c1e0b981ee5042ad4d8dde26 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Fri, 16 Feb 2024 15:06:02 +0000 Subject: [PATCH 030/276] Update DEVELOPER.md --- DEVELOPER.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index 7177a94d65..289ee1ada5 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -21,7 +21,7 @@ Follow these instructions to start up Sofie Core in development mode. (For produ - Install [Node.js](https://nodejs.org) 14 (using [nvm](https://github.com/nvm-sh/nvm) or [nvm-windows](https://github.com/coreybutler/nvm-windows) is the recommended way to install Node.js) - Install [Meteor](https://www.meteor.com/install) (`npm install --global meteor`) - Install [Node.js](https://nodejs.org) 18 (using the same method you used above, you can uninstall node 14 if needed) -- Install an older version of corepack (`npm --global i corepack@0.15.3`) +- Install an older version of corepack (`npm install --global corepack@0.15.3`) - Enable [corepack](https://nodejs.org/api/corepack.html#corepack) (`corepack enable`) as administrator/root. If `corepack` is not found, you may need to install it first with `npm install --global corepack` - If on Windows, you may need to `npm install --global windows-build-tools` but this is not always necessary From 9b53aa2f5b57a717e15d06c707b863d14c44f2e7 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 26 Feb 2024 15:16:35 +0100 Subject: [PATCH 031/276] refactor(SOFIE-69): code improvements from PR comments --- meteor/client/lib/__tests__/rundownTiming.test.ts | 15 +++++---------- meteor/client/lib/rundownTiming.ts | 11 ++++------- meteor/client/ui/RundownView.tsx | 8 +++----- meteor/client/ui/SegmentList/LinePart.tsx | 10 +++++----- .../ui/SegmentStoryboard/StoryboardPart.tsx | 7 ++++--- .../SegmentTimeline/Parts/SegmentTimelinePart.tsx | 5 +++-- meteor/lib/Rundown.ts | 7 +++++++ meteor/lib/collections/libCollections.ts | 2 +- meteor/server/publications/partsUI/publication.ts | 10 +++++----- .../publications/partsUI/reactiveContentCache.ts | 2 +- packages/corelib/src/dataModel/RundownPlaylist.ts | 2 +- packages/job-worker/src/playout/adlibUtils.ts | 15 +++++++++------ 12 files changed, 48 insertions(+), 46 deletions(-) diff --git a/meteor/client/lib/__tests__/rundownTiming.test.ts b/meteor/client/lib/__tests__/rundownTiming.test.ts index 7da7274f02..27350beef8 100644 --- a/meteor/client/lib/__tests__/rundownTiming.test.ts +++ b/meteor/client/lib/__tests__/rundownTiming.test.ts @@ -2456,7 +2456,7 @@ describe('findPartInstancesInQuickLoop', () => { type: QuickLoopMarkerType.PART, id: parts[3]._id, }, - running: false, + running: true, forceAutoNext: ForceQuickLoopAutoNext.DISABLED, locked: false, } @@ -2470,8 +2470,9 @@ describe('findPartInstancesInQuickLoop', () => { }) }) - it('Returns all parts between QuickLoop Playlist Markers', () => { - const { parts, partInstances } = makeMockPartsForQuickLoopTest() + it('Returns no parts when the entire Playlist is looping', () => { + // this may need to change if setting other than Part markers is allowed by the users + const { partInstances } = makeMockPartsForQuickLoopTest() const playlist = makeMockPlaylist() playlist.quickLoop = { @@ -2488,13 +2489,7 @@ describe('findPartInstancesInQuickLoop', () => { const result = findPartInstancesInQuickLoop(playlist, partInstances) - expect(result).toEqual({ - [unprotectString(parts[0]._id)]: true, - [unprotectString(parts[1]._id)]: true, - [unprotectString(parts[2]._id)]: true, - [unprotectString(parts[3]._id)]: true, - [unprotectString(parts[4]._id)]: true, - }) + expect(result).toEqual({}) }) it('Returns no parts when QuickLoop Part Markers are in the wrong order', () => { diff --git a/meteor/client/lib/rundownTiming.ts b/meteor/client/lib/rundownTiming.ts index bb4ef3de36..23fbd095d9 100644 --- a/meteor/client/lib/rundownTiming.ts +++ b/meteor/client/lib/rundownTiming.ts @@ -26,7 +26,7 @@ import { getCurrentTime, objectFromEntries } from '../../lib/lib' import { Settings } from '../../lib/Settings' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { isLoopDefined, isLoopLocked, isLoopRunning } from '../../lib/Rundown' +import { isLoopDefined, isEntirePlaylistLooping, isLoopRunning } from '../../lib/Rundown' // Minimum duration that a part can be assigned. Used by gap parts to allow them to "compress" to indicate time running out. const MINIMAL_NONZERO_DURATION = 1 @@ -927,14 +927,11 @@ export function findPartInstancesInQuickLoop( playlist: DBRundownPlaylist, sortedPartInstances: MinimalPartInstance[] ): Record { - const partsInQuickLoop: Record = {} - if ( - !isLoopDefined(playlist) || - isLoopLocked(playlist) // a crude way of disabling the dots when looping the entire playlist - ) { - return partsInQuickLoop + if (!isLoopDefined(playlist) || isEntirePlaylistLooping(playlist)) { + return {} } + const partsInQuickLoop: Record = {} let isInQuickLoop = playlist.quickLoop?.start?.type === QuickLoopMarkerType.PLAYLIST let previousPartInstance: MinimalPartInstance | undefined = undefined for (const partInstance of sortedPartInstances) { diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index fd6b1c572d..d5fa212df3 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -25,7 +25,6 @@ import { NavLink, Route, Prompt } from 'react-router-dom' import { DBRundownPlaylist, QuickLoopMarker, - QuickLoopMarkerType, RundownHoldState, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' @@ -158,7 +157,7 @@ import { logger } from '../../lib/logging' import { isTranslatableMessage, translateMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' import { i18nTranslator } from './i18n' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { isLoopDefined, isLoopRunning } from '../../lib/Rundown' +import { isEntirePlaylistLooping, isLoopRunning } from '../../lib/Rundown' import { useRundownAndShowStyleIdsForPlaylist } from './util/useRundownAndShowStyleIdsForPlaylist' export const MAGIC_TIME_SCALE_FACTOR = 0.03 @@ -2689,16 +2688,15 @@ const RundownViewContent = translateWithTracker ) } - const isPlaylistLooping = isLoopDefined(this.props.playlist) return ( - {isPlaylistLooping && this.props.playlist.quickLoop?.start?.type === QuickLoopMarkerType.PLAYLIST && ( + {isEntirePlaylistLooping(this.props.playlist) && ( 1} /> )}
{this.renderSegments()}
- {isPlaylistLooping && this.props.playlist.quickLoop?.end?.type === QuickLoopMarkerType.PLAYLIST && ( + {isEntirePlaylistLooping(this.props.playlist) && ( 1} diff --git a/meteor/client/ui/SegmentList/LinePart.tsx b/meteor/client/ui/SegmentList/LinePart.tsx index 3db51e115f..3940faa991 100644 --- a/meteor/client/ui/SegmentList/LinePart.tsx +++ b/meteor/client/ui/SegmentList/LinePart.tsx @@ -78,7 +78,8 @@ export const LinePart = withTiming((props: IProps) => { const [highlight] = useState(false) const timingId = getPartInstanceTimingId(part.instance) - const isInQuickLoop = (timingDurations.partsInQuickLoop || {})[timingId] + const isInsideQuickLoop = (timingDurations.partsInQuickLoop || {})[timingId] + const isOutsideActiveQuickLoop = isPlaylistLooping && !isInsideQuickLoop && !isNextPart && !hasAlreadyPlayed const getPartContext = useCallback(() => { const partElement = document.querySelector('#' + SegmentTimelinePartElementId + part.instance._id) @@ -134,9 +135,8 @@ export const LinePart = withTiming((props: IProps) => { 'invert-flash': highlight, 'segment-opl__part--next': isNextPart, 'segment-opl__part--live': isLivePart, - 'segment-opl__part--has-played': hasAlreadyPlayed && (!isPlaylistLooping || !isInQuickLoop), - 'segment-opl__part--outside-quickloop': - isPlaylistLooping && !isInQuickLoop && !isNextPart && !hasAlreadyPlayed, + 'segment-opl__part--has-played': hasAlreadyPlayed && (!isPlaylistLooping || !isInsideQuickLoop), + 'segment-opl__part--outside-quickloop': isOutsideActiveQuickLoop, 'segment-opl__part--quickloop-start': isQuickLoopStart, 'segment-opl__part--invalid': part.instance.part.invalid, 'segment-opl__part--timing-sibling': isPreceededByTimingGroupSibling, @@ -179,7 +179,7 @@ export const LinePart = withTiming((props: IProps) => {
)} - {isInQuickLoop &&
} + {isInsideQuickLoop &&
}
((props: IProps) => { const isInvalid = part.instance.part.invalid const isFloated = part.instance.part.floated - const isPartInQuickLoop = timingDurations.partsInQuickLoop?.[getPartInstanceTimingId(part.instance)] ?? false + const isInsideQuickLoop = timingDurations.partsInQuickLoop?.[getPartInstanceTimingId(part.instance)] ?? false + const isOutsideActiveQuickLoop = !isInsideQuickLoop && isPlaylistLooping && !isNextPart return ( ((props: IProps) => { 'segment-storyboard__part--next': isNextPart, 'segment-storyboard__part--live': isLivePart, 'segment-storyboard__part--invalid': part.instance.part.invalid, - 'segment-storyboard__part--outside-quickloop': !isPartInQuickLoop && isPlaylistLooping && !isNextPart, + 'segment-storyboard__part--outside-quickloop': isOutsideActiveQuickLoop, 'segment-storyboard__part--quickloop-start': isQuickLoopStart, 'segment-storyboard__part--quickloop-end': isQuickLoopEnd, }, @@ -277,7 +278,7 @@ export const StoryboardPart = withTiming((props: IProps) => {
) : null} - {isPartInQuickLoop &&
} + {isInsideQuickLoop &&
}
{isQuickLoopEnd ? ( diff --git a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index 6af1a636db..7c0f788507 100644 --- a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -660,6 +660,8 @@ export class SegmentTimelinePartClass extends React.Component(CollectionName.Pa /** * A playout UI version of Parts. */ -export const UIParts = createSyncCustomPublicationMongoCollection(CustomCollectionName.UIParts) // TODO +export const UIParts = createSyncCustomPublicationMongoCollection(CustomCollectionName.UIParts) export const RundownBaselineAdLibActions = createSyncReadOnlyMongoCollection( CollectionName.RundownBaselineAdLibActions diff --git a/meteor/server/publications/partsUI/publication.ts b/meteor/server/publications/partsUI/publication.ts index d3f7482de2..55c09cc6f2 100644 --- a/meteor/server/publications/partsUI/publication.ts +++ b/meteor/server/publications/partsUI/publication.ts @@ -136,10 +136,10 @@ export async function manipulateUIPartsPublicationData( return } - const playlist = state.contentCache.RundownPlaylists.find({}).fetch()[0] + const playlist = state.contentCache.RundownPlaylists.findOne({}) if (!playlist) return - const studio = state.contentCache.Studios.find({}).fetch()[0] + const studio = state.contentCache.Studios.findOne({}) if (!studio) return if (!playlist.quickLoop?.start || !playlist.quickLoop?.end) { @@ -152,8 +152,8 @@ export async function manipulateUIPartsPublicationData( collection.remove(null) - const rundownRanks = stringsToIndexLookup(playlist.rundownIdsInOrder as unknown as string[]) // TODO: optimize by storing in state? - const segmentRanks = extractRanks(state.contentCache.Segments.find({}).fetch()) // TODO: optimize by storing in state? + const rundownRanks = stringsToIndexLookup(playlist.rundownIdsInOrder as unknown as string[]) + const segmentRanks = extractRanks(state.contentCache.Segments.find({}).fetch()) const quickLoopStartPosition = playlist.quickLoop?.start && @@ -165,7 +165,7 @@ export async function manipulateUIPartsPublicationData( const isLoopDefined = playlist.quickLoop?.start && playlist.quickLoop?.end && quickLoopStartPosition && quickLoopEndPosition - function modifyPartForQuickLoop(part: DBPart) { + const modifyPartForQuickLoop = (part: DBPart) => { const partPosition = findPartPosition(part, segmentRanks, rundownRanks) const isLoopingOverriden = isLoopDefined && diff --git a/meteor/server/publications/partsUI/reactiveContentCache.ts b/meteor/server/publications/partsUI/reactiveContentCache.ts index 3d2e0afcc6..13361fd51c 100644 --- a/meteor/server/publications/partsUI/reactiveContentCache.ts +++ b/meteor/server/publications/partsUI/reactiveContentCache.ts @@ -41,7 +41,7 @@ export interface ContentCache { export function createReactiveContentCache(): ContentCache { const cache: ContentCache = { - Studios: new ReactiveCacheCollection>('rundownPlaylists'), + Studios: new ReactiveCacheCollection>('studios'), Segments: new ReactiveCacheCollection>('segments'), Parts: new ReactiveCacheCollection>('parts'), RundownPlaylists: new ReactiveCacheCollection('rundownPlaylists'), diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 66492dab60..a132f1a3b7 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -53,7 +53,7 @@ interface QuickLoopPartMarker { type: QuickLoopMarkerType.PART id: PartId - /** When a part is dynamically inserted after the marker, it keeps the old Id */ + /** When a part is dynamically inserted after the marker, the user selected id gets persisted here for the next iteration */ overridenId?: PartId } diff --git a/packages/job-worker/src/playout/adlibUtils.ts b/packages/job-worker/src/playout/adlibUtils.ts index 44ecf14356..0357c19b52 100644 --- a/packages/job-worker/src/playout/adlibUtils.ts +++ b/packages/job-worker/src/playout/adlibUtils.ts @@ -257,27 +257,30 @@ export async function insertQueuedPartWithPieces( await setNextPart(context, playoutModel, newPartInstance, false) - handleQuickLoop(playoutModel, currentPartInstance, newPartInstance) + temporarilyExtendQuickLoop(playoutModel, currentPartInstance, newPartInstance) if (span) span.end() return newPartInstance } -function handleQuickLoop( +function temporarilyExtendQuickLoop( playoutModel: PlayoutModel, currentPartInstance: PlayoutPartInstanceModel, newPartInstance: PlayoutPartInstanceModel ) { + const existingQuickLoopEnd = playoutModel.playlist.quickLoop?.end + if (!existingQuickLoopEnd) return + if ( - playoutModel.playlist.quickLoop?.end?.type === QuickLoopMarkerType.PART && - (currentPartInstance.partInstance.part._id === playoutModel.playlist.quickLoop?.end?.id || - currentPartInstance.partInstance.part._id === playoutModel.playlist.quickLoop?.end?.overridenId) + existingQuickLoopEnd.type === QuickLoopMarkerType.PART && + (currentPartInstance.partInstance.part._id === existingQuickLoopEnd.id || + currentPartInstance.partInstance.part._id === existingQuickLoopEnd.overridenId) ) { playoutModel.setQuickLoopMarker('end', { type: QuickLoopMarkerType.PART, id: newPartInstance.partInstance.part._id, - overridenId: playoutModel.playlist.quickLoop?.end?.overridenId ?? playoutModel.playlist.quickLoop?.end?.id, + overridenId: existingQuickLoopEnd.overridenId ?? existingQuickLoopEnd.id, }) } } From 5fbea60255c33a8207777893c548cda459e47930 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 26 Feb 2024 17:41:44 +0100 Subject: [PATCH 032/276] fix(SOFIE-69): wrong defaults in calls to selectNextPart --- packages/job-worker/src/ingest/updateNext.ts | 6 ++--- .../playout/__tests__/selectNextPart.test.ts | 3 +-- .../src/playout/activePlaylistActions.ts | 3 +-- packages/job-worker/src/playout/adlibUtils.ts | 6 +++-- packages/job-worker/src/playout/lib.ts | 3 +-- .../job-worker/src/playout/lookahead/util.ts | 3 +-- .../src/playout/quickLoopMarkers.ts | 3 +-- .../job-worker/src/playout/selectNextPart.ts | 27 ++++++++++--------- packages/job-worker/src/playout/take.ts | 3 +-- .../src/playout/timings/partPlayback.ts | 3 +-- 10 files changed, 28 insertions(+), 32 deletions(-) diff --git a/packages/job-worker/src/ingest/updateNext.ts b/packages/job-worker/src/ingest/updateNext.ts index d9abcd70f9..abcb85e545 100644 --- a/packages/job-worker/src/ingest/updateNext.ts +++ b/packages/job-worker/src/ingest/updateNext.ts @@ -50,8 +50,7 @@ export async function ensureNextPartIsValid(context: JobContext, playoutModel: P nextPartInstance.partInstance, orderedSegments, orderedParts, - true, - false + { ignoreUnplayable: true, ignoreQuickLoop: false } ) if ( @@ -76,8 +75,7 @@ export async function ensureNextPartIsValid(context: JobContext, playoutModel: P nextPartInstance?.partInstance ?? null, orderedSegments, orderedParts, - true, - false + { ignoreUnplayable: true, ignoreQuickLoop: false } ) await setNextPart(context, playoutModel, newNextPart ?? null, false) diff --git a/packages/job-worker/src/playout/__tests__/selectNextPart.test.ts b/packages/job-worker/src/playout/__tests__/selectNextPart.test.ts index 6a74b416fc..2d5f84d4be 100644 --- a/packages/job-worker/src/playout/__tests__/selectNextPart.test.ts +++ b/packages/job-worker/src/playout/__tests__/selectNextPart.test.ts @@ -89,8 +89,7 @@ describe('selectNextPart', () => { currentlySelectedPartInstance, segments, parts, - ignoreUnplayable, - ignoreQuickLoop + { ignoreUnplayable, ignoreQuickLoop } ) } diff --git a/packages/job-worker/src/playout/activePlaylistActions.ts b/packages/job-worker/src/playout/activePlaylistActions.ts index 8cde6311b5..8e59843cca 100644 --- a/packages/job-worker/src/playout/activePlaylistActions.ts +++ b/packages/job-worker/src/playout/activePlaylistActions.ts @@ -58,8 +58,7 @@ export async function activateRundownPlaylist( null, playoutModel.getAllOrderedSegments(), playoutModel.getAllOrderedParts(), - false, - false + { ignoreUnplayable: true, ignoreQuickLoop: false } ) await setNextPart(context, playoutModel, firstPart, false) diff --git a/packages/job-worker/src/playout/adlibUtils.ts b/packages/job-worker/src/playout/adlibUtils.ts index 0357c19b52..43d7818388 100644 --- a/packages/job-worker/src/playout/adlibUtils.ts +++ b/packages/job-worker/src/playout/adlibUtils.ts @@ -204,8 +204,10 @@ function updateRankForAdlibbedPartInstance( null, playoutModel.getAllOrderedSegments(), playoutModel.getAllOrderedParts(), - false, // We want to insert it before any trailing invalid piece - true // We want to insert it at the end of the loop + { + ignoreUnplayable: false, // We want to insert it before any trailing invalid piece + ignoreQuickLoop: true, // We may want to insert it right after the end of the loop + } ) newPartInstance.setRank( getRank( diff --git a/packages/job-worker/src/playout/lib.ts b/packages/job-worker/src/playout/lib.ts index 4323974242..66d97b76c3 100644 --- a/packages/job-worker/src/playout/lib.ts +++ b/packages/job-worker/src/playout/lib.ts @@ -42,8 +42,7 @@ export async function resetRundownPlaylist(context: JobContext, playoutModel: Pl null, playoutModel.getAllOrderedSegments(), playoutModel.getAllOrderedParts(), - false, - false + { ignoreUnplayable: true, ignoreQuickLoop: false } ) await setNextPart(context, playoutModel, firstPart, false) } else { diff --git a/packages/job-worker/src/playout/lookahead/util.ts b/packages/job-worker/src/playout/lookahead/util.ts index d87f9d7e2d..1300ae6f69 100644 --- a/packages/job-worker/src/playout/lookahead/util.ts +++ b/packages/job-worker/src/playout/lookahead/util.ts @@ -66,8 +66,7 @@ export function getOrderedPartsAfterPlayhead( null, orderedSegments, orderedParts, - false, - false + { ignoreUnplayable: true, ignoreQuickLoop: false } ) if (!nextNextPart) { // We don't know where to begin searching, so we can't do anything diff --git a/packages/job-worker/src/playout/quickLoopMarkers.ts b/packages/job-worker/src/playout/quickLoopMarkers.ts index c74fa1d09a..67e3558822 100644 --- a/packages/job-worker/src/playout/quickLoopMarkers.ts +++ b/packages/job-worker/src/playout/quickLoopMarkers.ts @@ -28,8 +28,7 @@ export async function handleSetQuickLoopMarker(context: JobContext, data: SetQui playoutModel.nextPartInstance?.partInstance ?? null, playoutModel.getAllOrderedSegments(), playoutModel.getAllOrderedParts(), - false, - false + { ignoreUnplayable: true, ignoreQuickLoop: false } ) if (nextPart?.part._id !== playoutModel.nextPartInstance?.partInstance.part._id) { await setNextPart(context, playoutModel, nextPart, false) diff --git a/packages/job-worker/src/playout/selectNextPart.ts b/packages/job-worker/src/playout/selectNextPart.ts index ee01219554..d07abbf446 100644 --- a/packages/job-worker/src/playout/selectNextPart.ts +++ b/packages/job-worker/src/playout/selectNextPart.ts @@ -42,8 +42,7 @@ export function selectNextPart( currentlySelectedPartInstance: ReadonlyDeep | null, segments: readonly PlayoutSegmentModel[], parts0: ReadonlyDeep[], - ignoreUnplayable: boolean, - ignoreQuickLoop: boolean // TODO: this should be refactored + options: { ignoreUnplayable: boolean; ignoreQuickLoop: boolean } ): SelectNextPartResult | null { const span = context.startSpan('selectNextPart') @@ -66,17 +65,21 @@ export function selectNextPart( // Filter to after and find the first playabale for (let index = offset; index < (length || parts.length); index++) { const part = parts[index] + if (options.ignoreUnplayable && !isPartPlayable(part)) { + continue + } if ( - (!ignoreUnplayable || isPartPlayable(part)) && - (ignoreQuickLoop || - !rundownPlaylist.quickLoop?.running || - context.studio.settings.forceQuickLoopAutoNext !== - ForceQuickLoopAutoNext.ENABLED_WHEN_VALID_DURATION || - isPartPlayableInQuickLoop(part)) && - (!condition || condition(part)) + !options.ignoreQuickLoop && + rundownPlaylist.quickLoop?.running && + context.studio.settings.forceQuickLoopAutoNext === ForceQuickLoopAutoNext.ENABLED_WHEN_VALID_DURATION && + !isPartPlayableInQuickLoop(part) ) { - return { part, index, consumesQueuedSegmentId: false } + continue + } + if (condition && !condition(part)) { + continue } + return { part, index, consumesQueuedSegmentId: false } } return undefined } @@ -96,7 +99,7 @@ export function selectNextPart( return undefined } - if (!ignoreQuickLoop && rundownPlaylist.quickLoop?.running && previousPartInstance) { + if (!options.ignoreQuickLoop && rundownPlaylist.quickLoop?.running && previousPartInstance) { const currentIndex = parts.findIndex((p) => p._id === previousPartInstance.part._id) if ( rundownPlaylist.quickLoop?.end?.type === QuickLoopMarkerType.PART && @@ -190,7 +193,7 @@ export function selectNextPart( } if ( - !ignoreQuickLoop && + !options.ignoreQuickLoop && rundownPlaylist.quickLoop?.end?.type === QuickLoopMarkerType.PLAYLIST && !nextPart && previousPartInstance diff --git a/packages/job-worker/src/playout/take.ts b/packages/job-worker/src/playout/take.ts index 829cbb39e8..b6a43f0a8d 100644 --- a/packages/job-worker/src/playout/take.ts +++ b/packages/job-worker/src/playout/take.ts @@ -256,8 +256,7 @@ export async function performTakeToNextedPart( null, playoutModel.getAllOrderedSegments(), playoutModel.getAllOrderedParts(), - false, - false + { ignoreUnplayable: true, ignoreQuickLoop: false } ) takePartInstance.setTaken(now, timeOffset) diff --git a/packages/job-worker/src/playout/timings/partPlayback.ts b/packages/job-worker/src/playout/timings/partPlayback.ts index 387fcdf3bd..793f2f0381 100644 --- a/packages/job-worker/src/playout/timings/partPlayback.ts +++ b/packages/job-worker/src/playout/timings/partPlayback.ts @@ -92,8 +92,7 @@ export async function onPartPlaybackStarted( null, playoutModel.getAllOrderedSegments(), playoutModel.getAllOrderedParts(), - false, - false + { ignoreUnplayable: true, ignoreQuickLoop: false } ) await setNextPart(context, playoutModel, nextPart, false) playoutModel.updateQuickLoopState() From 31ddb9259e45a398c3aa042744844be9424914d4 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 26 Feb 2024 17:43:13 +0100 Subject: [PATCH 033/276] refactor(SOFIE-69): overridenProperties type, based on PR review comments --- packages/corelib/src/dataModel/Part.ts | 2 +- .../src/playout/model/PlayoutPartInstanceModel.ts | 3 ++- .../src/playout/model/implementation/PlayoutModelImpl.ts | 3 +-- .../model/implementation/PlayoutPartInstanceModelImpl.ts | 8 +++----- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/corelib/src/dataModel/Part.ts b/packages/corelib/src/dataModel/Part.ts index 10756b955c..7d4b60e806 100644 --- a/packages/corelib/src/dataModel/Part.ts +++ b/packages/corelib/src/dataModel/Part.ts @@ -40,7 +40,7 @@ export interface DBPart extends IBlueprintPart { * Original values of properties overriden by some features. * Currently this only supports the QuickLoop */ - overridenProperties?: Partial> + overridenProperties?: Partial> } export function isPartPlayable(part: Pick, 'invalid' | 'floated'>): boolean { diff --git a/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts b/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts index 5f10678494..f007893014 100644 --- a/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts +++ b/packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts @@ -6,6 +6,7 @@ import { PartNote } from '@sofie-automation/corelib/dist/dataModel/Notes' import { IBlueprintMutatablePart, PieceLifespan, Time } from '@sofie-automation/blueprints-integration' import { PartCalculatedTimings } from '@sofie-automation/corelib/dist/playout/timings' import { PlayoutPieceInstanceModel } from './PlayoutPieceInstanceModel' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' /** * Token returned when making a backup copy of a PlayoutPartInstanceModel @@ -214,7 +215,7 @@ export interface PlayoutPartInstanceModel { * @param props New properties for the Part being wrapped * @returns True if any valid properties were provided */ - overridePartProps(props: Partial): boolean + overridePartProps(props: Partial): boolean /** * Reverts overriden Part props diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 6003e04a10..4f6cb32019 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -64,7 +64,6 @@ import { StudioBaselineHelper } from '../../../studio/model/StudioBaselineHelper import { EventsJobs } from '@sofie-automation/corelib/dist/worker/events' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep' -import { IBlueprintMutatablePart } from '@sofie-automation/blueprints-integration' import { DEFAULT_FALLBACK_PART_DURATION } from '@sofie-automation/shared-lib/dist/core/constants' export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { @@ -569,7 +568,7 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou partInstance: PlayoutPartInstanceModel, forceAutoNext: ForceQuickLoopAutoNext ): void { - const partPropsToUpdate: Partial = {} + const partPropsToUpdate: Partial = {} if ( !partInstance.partInstance.part.expectedDuration && forceAutoNext === ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts index cd166dac36..8c8d043303 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts @@ -17,7 +17,6 @@ import { import { PartNote } from '@sofie-automation/corelib/dist/dataModel/Notes' import { IBlueprintMutatablePart, - IBlueprintPart, IBlueprintPieceType, PieceLifespan, Time, @@ -523,9 +522,8 @@ export class PlayoutPartInstanceModelImpl implements PlayoutPartInstanceModel { return true } - overridePartProps(props: Partial): boolean { - const trimmedProps: Partial = filterPropsToAllowed(props) - const keys = Object.keys(trimmedProps) as Array> + overridePartProps(props: Partial): boolean { + const keys = Object.keys(props) as Array> if (keys.length === 0) return false const overridenProperties: DBPart['overridenProperties'] = _.pick(this.partInstanceImpl.part, keys) @@ -539,7 +537,7 @@ export class PlayoutPartInstanceModelImpl implements PlayoutPartInstanceModel { 'part', { ...this.partInstanceImpl.part, - ...trimmedProps, + ...props, overridenProperties, }, true From f6cc8d0eda1e342278937221eca74fd11de18597 Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 26 Feb 2024 23:31:58 +0100 Subject: [PATCH 034/276] refactor(SOFIE-69): extract quickLoop methods from PlayoutModelImpl --- .../corelib/src/dataModel/RundownPlaylist.ts | 27 ++- .../model/implementation/PlayoutModelImpl.ts | 223 +---------------- .../model/services/QuickLoopService.ts | 228 ++++++++++++++++++ 3 files changed, 256 insertions(+), 222 deletions(-) create mode 100644 packages/job-worker/src/playout/model/services/QuickLoopService.ts diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index a132f1a3b7..10506e127e 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -85,6 +85,20 @@ export enum ForceQuickLoopAutoNext { /** All parts will auto-next. If expected duration is undefined or low, the default display duration will be used */ ENABLED_FORCING_MIN_DURATION = 'enabled_forcing_min_duration', } + +export interface QuickLoopProps { + /** The Start marker */ + start?: QuickLoopMarker + /** The End marker */ + end?: QuickLoopMarker + /** Whether the user is allowed to make alterations to the Start/End markers */ + locked: boolean + /** Whether the loop has two valid markers and is currently running (the current Part is within the loop) */ + running: boolean + /** Whether the loop has autoNext should force auto-next on contained Parts */ + forceAutoNext: ForceQuickLoopAutoNext +} + export interface DBRundownPlaylist { _id: RundownPlaylistId /** External ID (source) of the playlist */ @@ -136,18 +150,7 @@ export interface DBRundownPlaylist { */ queuedSegmentId?: SegmentId - quickLoop?: { - /** The Start marker */ - start?: QuickLoopMarker - /** The End marker */ - end?: QuickLoopMarker - /** Whether the user is allowed to make alterations to the Start/End markers */ - locked: boolean - /** Whether the loop has two valid markers and is currently running (the current Part is within the loop) */ - running: boolean - /** Whether the loop has autoNext should force auto-next on contained Parts */ - forceAutoNext: ForceQuickLoopAutoNext - } + quickLoop?: QuickLoopProps /** Actual time of playback starting */ startedPlayback?: Time diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 4f6cb32019..7a9057592c 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -14,9 +14,7 @@ import { ABSessionAssignments, ABSessionInfo, DBRundownPlaylist, - ForceQuickLoopAutoNext, QuickLoopMarker, - QuickLoopMarkerType, RundownHoldState, SelectedPartInstance, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' @@ -40,11 +38,7 @@ import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { PlaylistLock } from '../../../jobs/lock' import { logger } from '../../../logging' import { clone, getRandomId, literal, normalizeArrayToMapFunc, sleep } from '@sofie-automation/corelib/dist/lib' -import { - MarkerPosition, - compareMarkerPositions, - sortRundownIDsInPlaylist, -} from '@sofie-automation/corelib/dist/playout/playlist' +import { sortRundownIDsInPlaylist } from '@sofie-automation/corelib/dist/playout/playlist' import { PlayoutRundownModel } from '../PlayoutRundownModel' import { PlayoutRundownModelImpl } from './PlayoutRundownModelImpl' import { PlayoutSegmentModel } from '../PlayoutSegmentModel' @@ -62,9 +56,7 @@ import { ExpectedPackageDBFromStudioBaselineObjects } from '@sofie-automation/co import { ExpectedPlayoutItemStudio } from '@sofie-automation/corelib/dist/dataModel/ExpectedPlayoutItem' import { StudioBaselineHelper } from '../../../studio/model/StudioBaselineHelper' import { EventsJobs } from '@sofie-automation/corelib/dist/worker/events' -import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep' -import { DEFAULT_FALLBACK_PART_DURATION } from '@sofie-automation/shared-lib/dist/core/constants' +import { QuickLoopService } from '../services/QuickLoopService' export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { public readonly playlistId: RundownPlaylistId @@ -90,6 +82,8 @@ export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { protected allPartInstances: Map + protected quickLoopService: QuickLoopService + public constructor( protected readonly context: JobContext, playlistLock: PlaylistLock, @@ -111,6 +105,8 @@ export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { this.timelineImpl = timeline ?? null this.allPartInstances = normalizeArrayToMapFunc(partInstances, (p) => p.partInstance._id) + + this.quickLoopService = new QuickLoopService(context, this) } public get olderPartInstances(): PlayoutPartInstanceModel[] { @@ -300,16 +296,6 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } - clearQuickLoopMarkers(): void { - if (!this.playlistImpl.quickLoop || this.playlistImpl.quickLoop.locked) return - - this.playlistImpl.quickLoop.start = undefined - this.playlistImpl.quickLoop.end = undefined - this.playlistImpl.quickLoop.running = false - - this.#playlistHasChanged = true - } - #fixupPieceInstancesForPartInstance(partInstance: DBPartInstance, pieceInstances: PieceInstance[]): void { for (const pieceInstance of pieceInstances) { // Future: should these be PieceInstance already, or should that be handled here? @@ -448,177 +434,11 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } - updateQuickLoopState(hasJustSetMarker?: 'start' | 'end'): void { - if (this.playlistImpl.quickLoop == null) return - const wasLoopRunning = this.playlistImpl.quickLoop.running - - if ( - this.playlistImpl.quickLoop.end?.type === QuickLoopMarkerType.PART && - this.playlistImpl.quickLoop.end?.overridenId && - this.playlistImpl.quickLoop.end?.id !== this.currentPartInstance?.partInstance.part._id && - this.playlistImpl.quickLoop.end?.id !== this.nextPartInstance?.partInstance.part._id - ) { - this.playlistImpl.quickLoop.end.id = this.playlistImpl.quickLoop.end.overridenId - delete this.playlistImpl.quickLoop.end.overridenId - } - - let isNextBetweenMarkers = false - if (this.playlistImpl.quickLoop.start == null || this.playlistImpl.quickLoop.end == null) { - this.playlistImpl.quickLoop.running = false - } else { - const orderedParts = this.getAllOrderedParts() - - const rundownIds = this.getRundownIds() - - const startPosition = this.findQuickLoopMarkerPosition( - this.playlistImpl.quickLoop.start, - 'start', - orderedParts, - rundownIds - ) - const endPosition = this.findQuickLoopMarkerPosition( - this.playlistImpl.quickLoop.end, - 'end', - orderedParts, - rundownIds - ) - - let isCurrentBetweenMarkers = false - - if (compareMarkerPositions(startPosition, endPosition) < 0) { - if (hasJustSetMarker === 'start') { - delete this.playlistImpl.quickLoop.end - } else if (hasJustSetMarker === 'end') { - delete this.playlistImpl.quickLoop.start - } - } else { - const currentPartPosition = this.findPartPosition(this.currentPartInstance, rundownIds) - const nextPartPosition = this.findPartPosition(this.nextPartInstance, rundownIds) - - isCurrentBetweenMarkers = currentPartPosition - ? compareMarkerPositions(startPosition, currentPartPosition) >= 0 && - compareMarkerPositions(currentPartPosition, endPosition) >= 0 - : false - isNextBetweenMarkers = nextPartPosition - ? compareMarkerPositions(startPosition, nextPartPosition) >= 0 && - compareMarkerPositions(nextPartPosition, endPosition) >= 0 - : false - - if (this.nextPartInstance && isNextBetweenMarkers) { - this.updateQuickLoopPartOverrides(this.nextPartInstance, this.playlistImpl.quickLoop.forceAutoNext) - } - } - - this.playlistImpl.quickLoop.running = - this.playlistImpl.quickLoop.start != null && - this.playlistImpl.quickLoop.end != null && - isCurrentBetweenMarkers - } - - if (this.currentPartInstance && this.playlistImpl.quickLoop.running) { - this.updateQuickLoopPartOverrides(this.currentPartInstance, this.playlistImpl.quickLoop.forceAutoNext) - } else if (this.currentPartInstance && wasLoopRunning) { - this.revertQuickLoopPartOverrides(this.currentPartInstance) - } - - if (this.nextPartInstance && !isNextBetweenMarkers) { - this.revertQuickLoopPartOverrides(this.nextPartInstance) - } - - if (wasLoopRunning && !this.playlistImpl.quickLoop.running) { - // clears the loop markers after leaving the loop, as per the requirements, but perhaps it should be optional - delete this.playlistImpl.quickLoop - } - this.#playlistHasChanged = true - } - - private findQuickLoopMarkerPosition( - marker: QuickLoopMarker, - type: 'start' | 'end', - orderedParts: ReadonlyObjectDeep[], - rundownIds: RundownId[] - ): MarkerPosition { - let part: ReadonlyObjectDeep | undefined - let segment: ReadonlyObjectDeep | undefined - let rundownRank - if (marker.type === QuickLoopMarkerType.PART) { - const partId = marker.id - const partIndex = orderedParts.findIndex((part) => part._id === partId) - part = orderedParts[partIndex] - } - if (marker.type === QuickLoopMarkerType.SEGMENT) { - segment = this.findSegment(marker.id)?.segment - } else if (part != null) { - segment = this.findSegment(part.segmentId)?.segment - } - if (marker.type === QuickLoopMarkerType.RUNDOWN) { - rundownRank = rundownIds.findIndex((id) => id === marker.id) - } else if (part ?? segment != null) { - rundownRank = rundownIds.findIndex((id) => id === (part ?? segment)?.rundownId) - } - const fallback = type === 'start' ? -Infinity : Infinity - return { - partRank: part?._rank ?? fallback, - segmentRank: segment?._rank ?? fallback, - rundownRank: rundownRank ?? fallback, - } - } - - private updateQuickLoopPartOverrides( - partInstance: PlayoutPartInstanceModel, - forceAutoNext: ForceQuickLoopAutoNext - ): void { - const partPropsToUpdate: Partial = {} - if ( - !partInstance.partInstance.part.expectedDuration && - forceAutoNext === ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION - ) { - partPropsToUpdate.expectedDuration = - this.context.studio.settings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION - } - if ( - (partInstance.partInstance.part.expectedDuration || partPropsToUpdate.expectedDuration) && - forceAutoNext !== ForceQuickLoopAutoNext.DISABLED && - !partInstance.partInstance.part.autoNext - ) { - partPropsToUpdate.autoNext = true - } - if (Object.keys(partPropsToUpdate).length) { - partInstance.overridePartProps(partPropsToUpdate) - if (partPropsToUpdate.expectedDuration) partInstance.recalculateExpectedDurationWithPreroll() - } - } - - private revertQuickLoopPartOverrides(partInstance: PlayoutPartInstanceModel) { - const overridenProperties = partInstance.partInstance.part.overridenProperties - if (overridenProperties) { - partInstance.revertOverridenPartProps() - if (overridenProperties.expectedDuration) { - partInstance.recalculateExpectedDurationWithPreroll() - } - } - } - - private findPartPosition( - partInstance: PlayoutPartInstanceModel | null, - rundownIds: RundownId[] - ): MarkerPosition | undefined { - if (partInstance == null) return undefined - const currentSegment = this.findSegment(partInstance.partInstance.segmentId)?.segment - const currentRundownIndex = rundownIds.findIndex((id) => id === partInstance.partInstance.rundownId) - - return { - partRank: partInstance.partInstance.part._rank, - segmentRank: currentSegment?._rank ?? 0, - rundownRank: currentRundownIndex ?? 0, - } - } - deactivatePlaylist(): void { delete this.playlistImpl.activationId this.clearSelectedPartInstances() - this.clearQuickLoopMarkers() + this.playlistImpl.quickLoop = this.quickLoopService.getUpdatedPropsByClearingMarkers() this.#playlistHasChanged = true } @@ -872,30 +692,13 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou } setQuickLoopMarker(type: 'start' | 'end', marker: QuickLoopMarker | null): void { - if (this.playlistImpl.quickLoop?.locked) { - throw new Error('Looping is locked') - } - this.playlistImpl.quickLoop = { - running: false, - locked: false, - ...this.playlistImpl.quickLoop, - forceAutoNext: this.context.studio.settings.forceQuickLoopAutoNext ?? ForceQuickLoopAutoNext.DISABLED, - } - if (type === 'start') { - if (marker == null) { - delete this.playlistImpl.quickLoop.start - } else { - this.playlistImpl.quickLoop.start = marker - } - } else { - if (marker == null) { - delete this.playlistImpl.quickLoop.end - } else { - this.playlistImpl.quickLoop.end = marker - } - } + this.playlistImpl.quickLoop = this.quickLoopService.getUpdatedPropsBySettingAMarker(type, marker) + this.playlistImpl.quickLoop = this.quickLoopService.getUpdatedProps(type) + this.#playlistHasChanged = true + } - this.updateQuickLoopState(type) + updateQuickLoopState(): void { + this.playlistImpl.quickLoop = this.quickLoopService.getUpdatedProps() this.#playlistHasChanged = true } diff --git a/packages/job-worker/src/playout/model/services/QuickLoopService.ts b/packages/job-worker/src/playout/model/services/QuickLoopService.ts new file mode 100644 index 0000000000..401bda5a78 --- /dev/null +++ b/packages/job-worker/src/playout/model/services/QuickLoopService.ts @@ -0,0 +1,228 @@ +import { MarkerPosition, compareMarkerPositions } from '@sofie-automation/corelib/dist/playout/playlist' +import { PlayoutModelReadonly } from '../PlayoutModel' +import { + ForceQuickLoopAutoNext, + QuickLoopMarker, + QuickLoopMarkerType, + QuickLoopProps, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { PlayoutPartInstanceModel } from '../PlayoutPartInstanceModel' +import { JobContext } from '../../../jobs' +import { DEFAULT_FALLBACK_PART_DURATION } from '@sofie-automation/shared-lib/dist/core/constants' +import { clone } from '@sofie-automation/corelib/dist/lib' + +export class QuickLoopService { + constructor(private readonly context: JobContext, private readonly playoutModel: PlayoutModelReadonly) {} + + getUpdatedProps(hasJustSetMarker?: 'start' | 'end'): QuickLoopProps | undefined { + if (this.playoutModel.playlist.quickLoop == null) return undefined + const quickLoopProps = clone(this.playoutModel.playlist.quickLoop) + const wasLoopRunning = quickLoopProps.running + + this.resetDynamicallyInsertedPartOverrideIfNoLongerNeeded(quickLoopProps) + + let isNextBetweenMarkers = false + if (quickLoopProps.start == null || quickLoopProps.end == null) { + quickLoopProps.running = false + } else { + const orderedParts = this.playoutModel.getAllOrderedParts() + + const rundownIds = this.playoutModel.getRundownIds() + + const startPosition = this.findQuickLoopMarkerPosition( + quickLoopProps.start, + 'start', + orderedParts, + rundownIds + ) + const endPosition = this.findQuickLoopMarkerPosition(quickLoopProps.end, 'end', orderedParts, rundownIds) + + let isCurrentBetweenMarkers = false + + if (this.areMarkersFlipped(startPosition, endPosition)) { + if (hasJustSetMarker === 'start') { + delete quickLoopProps.end + } else if (hasJustSetMarker === 'end') { + delete quickLoopProps.start + } + } else { + const currentPartPosition = this.findPartPosition(this.playoutModel.currentPartInstance, rundownIds) + const nextPartPosition = this.findPartPosition(this.playoutModel.nextPartInstance, rundownIds) + + isCurrentBetweenMarkers = currentPartPosition + ? compareMarkerPositions(startPosition, currentPartPosition) >= 0 && + compareMarkerPositions(currentPartPosition, endPosition) >= 0 + : false + isNextBetweenMarkers = nextPartPosition + ? compareMarkerPositions(startPosition, nextPartPosition) >= 0 && + compareMarkerPositions(nextPartPosition, endPosition) >= 0 + : false + + if (this.playoutModel.nextPartInstance && isNextBetweenMarkers) { + this.updateQuickLoopPartOverrides(this.playoutModel.nextPartInstance, quickLoopProps.forceAutoNext) + } + } + + quickLoopProps.running = + quickLoopProps.start != null && quickLoopProps.end != null && isCurrentBetweenMarkers + } + + if (this.playoutModel.currentPartInstance && quickLoopProps.running) { + this.updateQuickLoopPartOverrides(this.playoutModel.currentPartInstance, quickLoopProps.forceAutoNext) + } else if (this.playoutModel.currentPartInstance && wasLoopRunning) { + this.revertQuickLoopPartOverrides(this.playoutModel.currentPartInstance) + } + + if (this.playoutModel.nextPartInstance && !isNextBetweenMarkers) { + this.revertQuickLoopPartOverrides(this.playoutModel.nextPartInstance) + } + + if (wasLoopRunning && !quickLoopProps.running) { + // clears the loop markers after leaving the loop, as per the requirements, but perhaps it should be optional + return undefined + } + + return quickLoopProps + } + + getUpdatedPropsBySettingAMarker(type: 'start' | 'end', marker: QuickLoopMarker | null): QuickLoopProps | undefined { + if (this.playoutModel.playlist.quickLoop?.locked) { + throw new Error('Looping is locked') + } + const quickLoopProps = { + running: false, + locked: false, + ...clone(this.playoutModel.playlist.quickLoop), + forceAutoNext: this.context.studio.settings.forceQuickLoopAutoNext ?? ForceQuickLoopAutoNext.DISABLED, + } + if (type === 'start') { + if (marker == null) { + delete quickLoopProps.start + } else { + quickLoopProps.start = marker + } + } else { + if (marker == null) { + delete quickLoopProps.end + } else { + quickLoopProps.end = marker + } + } + + return quickLoopProps + } + + getUpdatedPropsByClearingMarkers(): QuickLoopProps | undefined { + if (!this.playoutModel.playlist.quickLoop || this.playoutModel.playlist.quickLoop.locked) return undefined + + const quickLoopProps = clone(this.playoutModel.playlist.quickLoop) + delete quickLoopProps.start + delete quickLoopProps.end + quickLoopProps.running = false + + return quickLoopProps + } + + private areMarkersFlipped(startPosition: MarkerPosition, endPosition: MarkerPosition) { + return compareMarkerPositions(startPosition, endPosition) < 0 + } + + private resetDynamicallyInsertedPartOverrideIfNoLongerNeeded(quickLoopProps: QuickLoopProps) { + const endMarker = quickLoopProps.end + if ( + endMarker?.type === QuickLoopMarkerType.PART && + endMarker.overridenId && + endMarker.id !== this.playoutModel.currentPartInstance?.partInstance.part._id && + endMarker.id !== this.playoutModel.nextPartInstance?.partInstance.part._id + ) { + endMarker.id = endMarker.overridenId + delete endMarker.overridenId + } + } + + private findQuickLoopMarkerPosition( + marker: QuickLoopMarker, + type: 'start' | 'end', + orderedParts: ReadonlyObjectDeep[], + rundownIds: RundownId[] + ): MarkerPosition { + let part: ReadonlyObjectDeep | undefined + let segment: ReadonlyObjectDeep | undefined + let rundownRank + if (marker.type === QuickLoopMarkerType.PART) { + const partId = marker.id + const partIndex = orderedParts.findIndex((part) => part._id === partId) + part = orderedParts[partIndex] + } + if (marker.type === QuickLoopMarkerType.SEGMENT) { + segment = this.playoutModel.findSegment(marker.id)?.segment + } else if (part != null) { + segment = this.playoutModel.findSegment(part.segmentId)?.segment + } + if (marker.type === QuickLoopMarkerType.RUNDOWN) { + rundownRank = rundownIds.findIndex((id) => id === marker.id) + } else if (part ?? segment != null) { + rundownRank = rundownIds.findIndex((id) => id === (part ?? segment)?.rundownId) + } + const fallback = type === 'start' ? -Infinity : Infinity + return { + partRank: part?._rank ?? fallback, + segmentRank: segment?._rank ?? fallback, + rundownRank: rundownRank ?? fallback, + } + } + + private updateQuickLoopPartOverrides( + partInstance: PlayoutPartInstanceModel, + forceAutoNext: ForceQuickLoopAutoNext + ): void { + const partPropsToUpdate: Partial = {} + if ( + !partInstance.partInstance.part.expectedDuration && + forceAutoNext === ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION + ) { + partPropsToUpdate.expectedDuration = + this.context.studio.settings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION + } + if ( + (partInstance.partInstance.part.expectedDuration || partPropsToUpdate.expectedDuration) && + forceAutoNext !== ForceQuickLoopAutoNext.DISABLED && + !partInstance.partInstance.part.autoNext + ) { + partPropsToUpdate.autoNext = true + } + if (Object.keys(partPropsToUpdate).length) { + partInstance.overridePartProps(partPropsToUpdate) + if (partPropsToUpdate.expectedDuration) partInstance.recalculateExpectedDurationWithPreroll() + } + } + + private revertQuickLoopPartOverrides(partInstance: PlayoutPartInstanceModel) { + const overridenProperties = partInstance.partInstance.part.overridenProperties + if (overridenProperties) { + partInstance.revertOverridenPartProps() + if (overridenProperties.expectedDuration) { + partInstance.recalculateExpectedDurationWithPreroll() + } + } + } + + private findPartPosition( + partInstance: PlayoutPartInstanceModel | null, + rundownIds: RundownId[] + ): MarkerPosition | undefined { + if (partInstance == null) return undefined + const currentSegment = this.playoutModel.findSegment(partInstance.partInstance.segmentId)?.segment + const currentRundownIndex = rundownIds.findIndex((id) => id === partInstance.partInstance.rundownId) + + return { + partRank: partInstance.partInstance.part._rank, + segmentRank: currentSegment?._rank ?? 0, + rundownRank: currentRundownIndex ?? 0, + } + } +} From e0f52fa91df8032051f456b7624b1bf956486659 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Mar 2024 06:07:19 +0000 Subject: [PATCH 035/276] chore(deps): bump aquasecurity/trivy-action from 0.17.0 to 0.18.0 Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.17.0 to 0.18.0. - [Release notes](https://github.com/aquasecurity/trivy-action/releases) - [Commits](https://github.com/aquasecurity/trivy-action/compare/0.17.0...0.18.0) --- updated-dependencies: - dependency-name: aquasecurity/trivy-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/node.yaml | 4 ++-- .github/workflows/trivy.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index 23469b4102..3e0026a24c 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -231,7 +231,7 @@ jobs: echo "image=$image" >> $GITHUB_OUTPUT - name: Trivy scanning if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: aquasecurity/trivy-action@0.17.0 + uses: aquasecurity/trivy-action@0.18.0 with: image-ref: "${{ steps.trivy-image.outputs.image }}" format: "table" @@ -380,7 +380,7 @@ jobs: echo "image=$image" >> $GITHUB_OUTPUT - name: Trivy scanning if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: aquasecurity/trivy-action@0.17.0 + uses: aquasecurity/trivy-action@0.18.0 with: image-ref: "${{ steps.trivy-image.outputs.image }}" format: "table" diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 9462d9d985..30e84e5d5a 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -13,14 +13,14 @@ jobs: image: ["server-core", "playout-gateway", "mos-gateway"] steps: - name: Run Trivy vulnerability scanner (json) - uses: aquasecurity/trivy-action@0.17.0 + uses: aquasecurity/trivy-action@0.18.0 with: image-ref: ghcr.io/nrkno/sofie-core-${{ matrix.image }}:latest format: json output: '${{ matrix.image }}-trivy-scan-results.json' - name: Run Trivy vulnerability scanner (table) - uses: aquasecurity/trivy-action@0.17.0 + uses: aquasecurity/trivy-action@0.18.0 with: image-ref: ghcr.io/nrkno/sofie-core-${{ matrix.image }}:latest output: '${{ matrix.image }}-trivy-scan-results.txt' @@ -36,7 +36,7 @@ jobs: echo $CODE_BLOCK >> $GITHUB_STEP_SUMMARY - name: Run Trivy in GitHub SBOM mode and submit results to Dependency Graph - uses: aquasecurity/trivy-action@0.17.0 + uses: aquasecurity/trivy-action@0.18.0 with: format: 'github' output: 'dependency-results-${{ matrix.image }}.sbom.json' From 58b17133c86bd7c8c252e95302cce4f8ea1e5b1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 06:56:57 +0000 Subject: [PATCH 036/276] chore(deps): bump aquasecurity/trivy-action from 0.18.0 to 0.19.0 Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.18.0 to 0.19.0. - [Release notes](https://github.com/aquasecurity/trivy-action/releases) - [Commits](https://github.com/aquasecurity/trivy-action/compare/0.18.0...0.19.0) --- updated-dependencies: - dependency-name: aquasecurity/trivy-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/node.yaml | 4 ++-- .github/workflows/trivy.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index 3e0026a24c..b54c46f145 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -231,7 +231,7 @@ jobs: echo "image=$image" >> $GITHUB_OUTPUT - name: Trivy scanning if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: aquasecurity/trivy-action@0.18.0 + uses: aquasecurity/trivy-action@0.19.0 with: image-ref: "${{ steps.trivy-image.outputs.image }}" format: "table" @@ -380,7 +380,7 @@ jobs: echo "image=$image" >> $GITHUB_OUTPUT - name: Trivy scanning if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: aquasecurity/trivy-action@0.18.0 + uses: aquasecurity/trivy-action@0.19.0 with: image-ref: "${{ steps.trivy-image.outputs.image }}" format: "table" diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 30e84e5d5a..7d86efc702 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -13,14 +13,14 @@ jobs: image: ["server-core", "playout-gateway", "mos-gateway"] steps: - name: Run Trivy vulnerability scanner (json) - uses: aquasecurity/trivy-action@0.18.0 + uses: aquasecurity/trivy-action@0.19.0 with: image-ref: ghcr.io/nrkno/sofie-core-${{ matrix.image }}:latest format: json output: '${{ matrix.image }}-trivy-scan-results.json' - name: Run Trivy vulnerability scanner (table) - uses: aquasecurity/trivy-action@0.18.0 + uses: aquasecurity/trivy-action@0.19.0 with: image-ref: ghcr.io/nrkno/sofie-core-${{ matrix.image }}:latest output: '${{ matrix.image }}-trivy-scan-results.txt' @@ -36,7 +36,7 @@ jobs: echo $CODE_BLOCK >> $GITHUB_STEP_SUMMARY - name: Run Trivy in GitHub SBOM mode and submit results to Dependency Graph - uses: aquasecurity/trivy-action@0.18.0 + uses: aquasecurity/trivy-action@0.19.0 with: format: 'github' output: 'dependency-results-${{ matrix.image }}.sbom.json' From ebb154d6b59369588da400d8d921a00e41b84dc8 Mon Sep 17 00:00:00 2001 From: ianshade Date: Fri, 5 Apr 2024 20:29:34 +0200 Subject: [PATCH 037/276] fix: allow replacement in replaceInfinitesFromPreviousPlayhead --- .../PlayoutPartInstanceModelImpl.ts | 3 +- .../PlayoutPartInstanceModelImpl.spec.ts | 110 ++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/job-worker/src/playout/model/implementation/__tests__/PlayoutPartInstanceModelImpl.spec.ts diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts index 7332618717..4556335702 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts @@ -384,7 +384,8 @@ export class PlayoutPartInstanceModelImpl implements PlayoutPartInstanceModel { } for (const pieceInstance of pieceInstances) { - if (this.pieceInstancesImpl.has(pieceInstance._id)) + const existingPieceInstance = this.pieceInstancesImpl.get(pieceInstance._id) + if (existingPieceInstance) throw new Error( `Cannot replace infinite PieceInstance "${pieceInstance._id}" as it replaces a non-infinite` ) diff --git a/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutPartInstanceModelImpl.spec.ts b/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutPartInstanceModelImpl.spec.ts new file mode 100644 index 0000000000..e90a967e90 --- /dev/null +++ b/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutPartInstanceModelImpl.spec.ts @@ -0,0 +1,110 @@ +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { getRandomId, literal } from '@sofie-automation/corelib/dist/lib' +import { PlayoutPartInstanceModelImpl } from '../PlayoutPartInstanceModelImpl' +import { PieceInstance, PieceInstancePiece } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { IBlueprintPieceType, PieceLifespan } from '@sofie-automation/blueprints-integration' + +describe('PlayoutPartInstanceModelImpl', () => { + function createBasicDBPartInstance(): DBPartInstance { + return { + _id: getRandomId(), + rundownId: protectString(''), + segmentId: protectString(''), + playlistActivationId: protectString(''), + segmentPlayoutId: protectString(''), + rehearsal: false, + + takeCount: 0, + + part: { + _id: getRandomId(), + _rank: 0, + rundownId: protectString(''), + segmentId: protectString(''), + externalId: '', + title: '', + + expectedDurationWithPreroll: undefined, + }, + } + } + + function createPieceInstanceAsInfinite(id: string, fromPreviousPlayhead: boolean): PieceInstance { + return literal({ + _id: protectString(id), + rundownId: protectString(''), + partInstanceId: protectString(''), + playlistActivationId: protectString('active'), + piece: literal({ + _id: protectString(`${id}_p`), + externalId: '', + startPartId: protectString(''), + enable: { start: 0 }, + name: '', + lifespan: PieceLifespan.OutOnRundownChange, + sourceLayerId: '', + outputLayerId: '', + invalid: false, + content: {}, + timelineObjectsString: protectString(''), + pieceType: IBlueprintPieceType.Normal, + }), + infinite: { + infiniteInstanceId: protectString(`${id}_inf`), + infiniteInstanceIndex: 0, + infinitePieceId: protectString(`${id}_p`), + fromPreviousPart: false, + fromPreviousPlayhead, + }, + }) + } + + describe('replaceInfinitesFromPreviousPlayhead', () => { + it('works for an empty part', async () => { + const partInstance = createBasicDBPartInstance() + const model = new PlayoutPartInstanceModelImpl(partInstance, [], false) + + expect(() => model.replaceInfinitesFromPreviousPlayhead([])).not.toThrow() + expect(model.pieceInstances).toEqual([]) + }) + + it('keeps pieceInstance with fromPreviousPlayhead=false', async () => { + const partInstance = createBasicDBPartInstance() + const model = new PlayoutPartInstanceModelImpl( + partInstance, + [createPieceInstanceAsInfinite('p1', false)], + false + ) + + expect(() => model.replaceInfinitesFromPreviousPlayhead([])).not.toThrow() + expect(model.pieceInstances.map((p) => p.pieceInstance._id)).toEqual(['p1']) + }) + + it('deleted pieceInstance with fromPreviousPlayhead=true if no replacement provided', async () => { + const partInstance = createBasicDBPartInstance() + const model = new PlayoutPartInstanceModelImpl( + partInstance, + [createPieceInstanceAsInfinite('p1', true)], + false + ) + + expect(() => model.replaceInfinitesFromPreviousPlayhead([])).not.toThrow() + expect(model.pieceInstances.map((p) => p.pieceInstance._id)).toEqual([]) + }) + + it('replaces pieceInstance with fromPreviousPlayhead=true if replacement provided', async () => { + const partInstance = createBasicDBPartInstance() + const model = new PlayoutPartInstanceModelImpl( + partInstance, + [createPieceInstanceAsInfinite('p1', true)], + false + ) + + expect(() => + model.replaceInfinitesFromPreviousPlayhead([createPieceInstanceAsInfinite('p1', true)]) + ).not.toThrow() + expect(model.pieceInstances.map((p) => p.pieceInstance._id)).toEqual(['p1']) + }) + }) +}) From 6c4edee7f352bb542c8a29317d59c0bf9ac340ba Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Mon, 15 Apr 2024 08:18:47 +0200 Subject: [PATCH 038/276] chore: uppdate PR template This replaces the Testing instructions with a section on unit tests and another on "affected areas". (The previous test instructions didn't work well in our internal workflows.) --- .github/PULL-REQUEST-TEMPLATE.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/PULL-REQUEST-TEMPLATE.md b/.github/PULL-REQUEST-TEMPLATE.md index 7907a239cd..a014c12018 100644 --- a/.github/PULL-REQUEST-TEMPLATE.md +++ b/.github/PULL-REQUEST-TEMPLATE.md @@ -30,13 +30,27 @@ What is the new behavior? --> -## Testing Instructions +## Testing + +- [ ] I have added one or more unit tests for this PR +- [ ] I have updated the relevant unit tests +- [ ] No unit test changes are needed for this PR + +### Affected areas + + From 4b41445c179acaf5adf20fdb95cf9e40e598b49f Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Fri, 12 Apr 2024 14:23:15 +0200 Subject: [PATCH 039/276] chore: minor fixes after PR discussion (cherry picked from commit 7f57527571423e85254299051fbb3ad548a1299b) --- .../SegmentTimeline/Parts/SegmentTimelinePart.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index 7c0f788507..443cb3ae22 100644 --- a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -558,10 +558,7 @@ export class SegmentTimelinePartClass extends React.Component )} {isQuickLoopEnd &&
} - {this.renderEndOfSegment(t, innerPart, isEndOfShow, endOfLoopingShow)} + {this.renderEndOfSegment(t, innerPart, isEndOfShow, isPartEndOfLoopingShow)}
) } else { @@ -822,7 +819,9 @@ export class SegmentTimelinePartClass extends React.Component {/* render it empty, just to take up space */} - {this.state.isInsideViewport ? this.renderEndOfSegment(t, innerPart, isEndOfShow, endOfLoopingShow) : null} + {this.state.isInsideViewport + ? this.renderEndOfSegment(t, innerPart, isEndOfShow, isPartEndOfLoopingShow) + : null}
) } From 36dc3ce939b428f2382c1584a9eceb251be0967c Mon Sep 17 00:00:00 2001 From: ianshade Date: Mon, 22 Apr 2024 16:07:20 +0200 Subject: [PATCH 040/276] refactor: move UIParts to the client-side of meteor --- meteor/client/lib/__tests__/rundown.test.ts | 18 ++-- meteor/client/lib/rundown.ts | 86 +++++++++++++++- meteor/client/lib/rundownPlaylistUtil.ts | 99 +++++++++++++++++++ meteor/client/lib/viewPort.ts | 3 +- .../client/ui/ClockView/PresenterScreen.tsx | 3 +- .../ui/{Collections.tsx => Collections.ts} | 5 + meteor/client/ui/Collections/index.ts | 0 meteor/client/ui/MediaStatus/MediaStatus.tsx | 4 +- meteor/client/ui/Prompter/prompter.ts | 5 +- meteor/client/ui/RundownView.tsx | 3 +- .../RundownTiming/RundownTimingProvider.tsx | 3 +- .../SegmentContainer/withResolvedSegment.ts | 3 +- .../ui/SegmentList/SegmentListContainer.tsx | 3 +- .../SegmentScratchpadContainer.tsx | 3 +- .../SegmentStoryboardContainer.tsx | 3 +- .../SegmentTimelineContainer.tsx | 3 +- .../TriggeredActionsEditor.tsx | 3 +- meteor/client/ui/Shelf/AdLibPanel.tsx | 3 +- meteor/client/ui/Shelf/SegmentNamePanel.tsx | 3 +- meteor/client/ui/Shelf/SegmentTimingPanel.tsx | 15 +-- meteor/lib/Rundown.ts | 84 +--------------- meteor/lib/collections/libCollections.ts | 12 +-- meteor/lib/collections/rundownPlaylistUtil.ts | 90 +---------------- 23 files changed, 235 insertions(+), 219 deletions(-) create mode 100644 meteor/client/lib/rundownPlaylistUtil.ts rename meteor/client/ui/{Collections.tsx => Collections.ts} (93%) create mode 100644 meteor/client/ui/Collections/index.ts diff --git a/meteor/client/lib/__tests__/rundown.test.ts b/meteor/client/lib/__tests__/rundown.test.ts index d8666761f6..ebce7d1e78 100644 --- a/meteor/client/lib/__tests__/rundown.test.ts +++ b/meteor/client/lib/__tests__/rundown.test.ts @@ -17,6 +17,7 @@ import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { PartInstances, PieceInstances, Pieces, RundownPlaylists } from '../../collections' import { MongoMock } from '../../../__mocks__/mongo' import { RundownPlaylistCollectionUtil } from '../../../lib/collections/rundownPlaylistUtil' +import { RundownPlaylistClientUtil } from '../rundownPlaylistUtil' const mockRundownPlaylistsCollection = MongoMock.getInnerMockCollection(RundownPlaylists) const mockPartInstancesCollection = MongoMock.getInnerMockCollection(PartInstances) @@ -24,11 +25,12 @@ const mockPieceInstancesCollection = MongoMock.getInnerMockCollection(PieceInsta const mockPiecesCollection = MongoMock.getInnerMockCollection(Pieces) // This is a hack, the tests should be rewriten to not use methods unrelated to the testee -jest.mock('../../../lib/collections/libCollections', () => { - const mockCollections = jest.requireActual('../../../lib/collections/libCollections') +jest.mock('../../ui/Collections', () => { + const mockClientCollections = jest.requireActual('../../ui/Collections') + const mockLibCollections = jest.requireActual('../../../lib/collections/libCollections') return { - ...mockCollections, - UIParts: mockCollections.Parts, // for most purposes they're equivalent + ...mockClientCollections, + UIParts: mockLibCollections.Parts, // for most purposes they're equivalent } }) @@ -49,7 +51,7 @@ describe('client/lib/rundown', () => { RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) - const { parts, segments } = RundownPlaylistCollectionUtil.getSegmentsAndPartsSync(playlist) + const { parts, segments } = RundownPlaylistClientUtil.getSegmentsAndPartsSync(playlist) const rundown = rundowns[0] const segment = segments[0] @@ -93,7 +95,7 @@ describe('client/lib/rundown', () => { RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) - const { parts, segments } = RundownPlaylistCollectionUtil.getSegmentsAndPartsSync(playlist) + const { parts, segments } = RundownPlaylistClientUtil.getSegmentsAndPartsSync(playlist) const rundown = rundowns[0] const rundownId = rundown._id const segment = segments[1] @@ -166,7 +168,7 @@ describe('client/lib/rundown', () => { RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) - const { parts, segments } = RundownPlaylistCollectionUtil.getSegmentsAndPartsSync(playlist) + const { parts, segments } = RundownPlaylistClientUtil.getSegmentsAndPartsSync(playlist) const rundown = rundowns[0] const rundownId = rundown._id const segment = segments[1] @@ -264,7 +266,7 @@ describe('client/lib/rundown', () => { if (!playlist) throw new Error('Playlist not found') const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) - const { parts, segments } = RundownPlaylistCollectionUtil.getSegmentsAndPartsSync(playlist) + const { parts, segments } = RundownPlaylistClientUtil.getSegmentsAndPartsSync(playlist) const rundown = rundowns[0] const rundownId = rundown._id const segment = segments[1] diff --git a/meteor/client/lib/rundown.ts b/meteor/client/lib/rundown.ts index df163f40b7..c721198457 100644 --- a/meteor/client/lib/rundown.ts +++ b/meteor/client/lib/rundown.ts @@ -18,13 +18,12 @@ import { IOutputLayerExtended, ISourceLayerExtended, PartInstanceLimited, - getSegmentsWithPartInstances, isLoopRunning, } from '../../lib/Rundown' -import { PartInstance } from '../../lib/collections/PartInstances' +import { PartInstance, wrapPartToTemporaryInstance } from '../../lib/collections/PartInstances' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { literal, getCurrentTime } from '../../lib/lib' +import { literal, getCurrentTime, protectString, groupByToMap } from '../../lib/lib' import { processAndPrunePieceInstanceTimings, resolvePrunedPieceInstance, @@ -46,6 +45,10 @@ import { PartId, PieceId, RundownId, SegmentId, ShowStyleBaseId } from '@sofie-a import { PieceInstances, Segments } from '../collections' import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { assertNever } from '@sofie-automation/shared-lib/dist/lib/lib' +import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { RundownPlaylistCollectionUtil } from '../../lib/collections/rundownPlaylistUtil' +import { RundownPlaylistClientUtil } from './rundownPlaylistUtil' export namespace RundownUtils { export function padZeros(input: number, places?: number): string { @@ -801,4 +804,81 @@ export namespace RundownUtils { ) }) } + + /** + * Get all PartInstances (or temporary PartInstances) all segments in all rundowns in a playlist, using given queries + * to limit the data, in correct order. + * + * @export + * @param {DBRundownPlaylist} playlist + * @param {(MongoQuery)} [segmentsQuery] + * @param {(MongoQuery)} [partsQuery] + * @param {MongoQuery} [partInstancesQuery] + * @param {FindOptions} [segmentsOptions] + * @param {FindOptions} [partsOptions] + * @param {FindOptions} [partInstancesOptions] + * @return {*} {Array<{ segment: Segment; partInstances: PartInstance[] }>} + */ + export function getSegmentsWithPartInstances( + playlist: DBRundownPlaylist, + segmentsQuery?: MongoQuery, + partsQuery?: MongoQuery, + partInstancesQuery?: MongoQuery, + segmentsOptions?: FindOptions, + partsOptions?: FindOptions, + partInstancesOptions?: FindOptions + ): Array<{ segment: DBSegment; partInstances: PartInstance[] }> { + const { segments, parts: rawParts } = RundownPlaylistClientUtil.getSegmentsAndPartsSync( + playlist, + segmentsQuery, + partsQuery, + segmentsOptions, + partsOptions + ) + const rawPartInstances = RundownPlaylistCollectionUtil.getActivePartInstances( + playlist, + partInstancesQuery, + partInstancesOptions + ) + const playlistActivationId = playlist.activationId ?? protectString('') + + const partsBySegment = groupByToMap(rawParts, 'segmentId') + const partInstancesBySegment = groupByToMap(rawPartInstances, 'segmentId') + + return segments.map((segment) => { + const segmentParts = partsBySegment.get(segment._id) ?? [] + const segmentPartInstances = partInstancesBySegment.get(segment._id) ?? [] + + if (segmentPartInstances.length === 0) { + return { + segment, + partInstances: segmentParts.map((p) => wrapPartToTemporaryInstance(playlistActivationId, p)), + } + } else if (segmentParts.length === 0) { + return { + segment, + partInstances: segmentPartInstances.sort( + (a, b) => a.part._rank - b.part._rank || a.takeCount - b.takeCount + ), + } + } else { + const partIds: Set = new Set() + for (const partInstance of segmentPartInstances) { + partIds.add(partInstance.part._id) + } + for (const part of segmentParts) { + if (partIds.has(part._id)) continue + segmentPartInstances.push(wrapPartToTemporaryInstance(playlistActivationId, part)) + } + const allPartInstances = segmentPartInstances.sort( + (a, b) => a.part._rank - b.part._rank || a.takeCount - b.takeCount + ) + + return { + segment, + partInstances: allPartInstances, + } + } + }) + } } diff --git a/meteor/client/lib/rundownPlaylistUtil.ts b/meteor/client/lib/rundownPlaylistUtil.ts new file mode 100644 index 0000000000..70b9b1185f --- /dev/null +++ b/meteor/client/lib/rundownPlaylistUtil.ts @@ -0,0 +1,99 @@ +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { FindOptions, MongoQuery } from '@sofie-automation/corelib/dist/mongo' +import { RundownPlaylistCollectionUtil } from '../../lib/collections/rundownPlaylistUtil' +import { UIParts } from '../ui/Collections' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { Segments } from '../collections' + +export class RundownPlaylistClientUtil { + static getUnorderedParts( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): DBPart[] { + const rundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) + const parts = UIParts.find( + { + ...selector, + rundownId: { + $in: rundownIds, + }, + }, + { + ...options, + sort: { + rundownId: 1, + _rank: 1, + }, + } + ).fetch() + return parts + } + /** Synchronous version of getSegmentsAndParts, to be used client-side */ + static getSegmentsAndPartsSync( + playlist: Pick, + segmentsQuery?: MongoQuery, + partsQuery?: MongoQuery, + segmentsOptions?: Omit, 'projection'>, // We are mangling fields, so block projection + partsOptions?: Omit, 'projection'> // We are mangling fields, so block projection + ): { segments: DBSegment[]; parts: DBPart[] } { + const rundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) + const segments = Segments.find( + { + rundownId: { + $in: rundownIds, + }, + ...segmentsQuery, + }, + { + ...segmentsOptions, + //@ts-expect-error This is too clever for the compiler + fields: segmentsOptions?.fields + ? { + ...segmentsOptions?.fields, + _rank: 1, + rundownId: 1, + } + : undefined, + sort: { + ...segmentsOptions?.sort, + rundownId: 1, + _rank: 1, + }, + } + ).fetch() + + const parts = UIParts.find( + { + rundownId: { + $in: rundownIds, + }, + ...partsQuery, + }, + { + ...partsOptions, + //@ts-expect-error This is too clever for the compiler + fields: partsOptions?.fields + ? { + ...partsOptions?.fields, + rundownId: 1, + segmentId: 1, + _rank: 1, + } + : undefined, + sort: { + ...segmentsOptions?.sort, + rundownId: 1, + _rank: 1, + }, + } + ).fetch() + + const sortedSegments = RundownPlaylistCollectionUtil._sortSegments(segments, playlist) + return { + segments: sortedSegments, + parts: RundownPlaylistCollectionUtil._sortPartsInner(parts, sortedSegments), + } + } +} diff --git a/meteor/client/lib/viewPort.ts b/meteor/client/lib/viewPort.ts index 28cf8cf6ab..0a9e7f9a83 100644 --- a/meteor/client/lib/viewPort.ts +++ b/meteor/client/lib/viewPort.ts @@ -3,8 +3,9 @@ import { isProtectedString } from '../../lib/lib' import RundownViewEventBus, { RundownViewEvents } from '../../lib/api/triggers/RundownViewEventBus' import { Settings } from '../../lib/Settings' import { PartId, PartInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PartInstances, UIParts } from '../collections' +import { PartInstances } from '../collections' import { logger } from '../../lib/logging' +import { UIParts } from '../ui/Collections' const HEADER_MARGIN = 24 // TODOSYNC: TV2 uses 15. If it's needed to be different, it needs to be made generic somehow.. const FALLBACK_HEADER_HEIGHT = 65 diff --git a/meteor/client/ui/ClockView/PresenterScreen.tsx b/meteor/client/ui/ClockView/PresenterScreen.tsx index 5a3529083e..34f0328044 100644 --- a/meteor/client/ui/ClockView/PresenterScreen.tsx +++ b/meteor/client/ui/ClockView/PresenterScreen.tsx @@ -42,6 +42,7 @@ import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { useSetDocumentClass } from '../util/useSetDocumentClass' import { useRundownAndShowStyleIdsForPlaylist } from '../util/useRundownAndShowStyleIdsForPlaylist' +import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' interface SegmentUi extends DBSegment { items: Array @@ -198,7 +199,7 @@ export const getPresenterScreenReactive = (props: PresenterScreenProps): Present if (playlist) { rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) - const orderedSegmentsAndParts = RundownPlaylistCollectionUtil.getSegmentsAndPartsSync(playlist) + const orderedSegmentsAndParts = RundownPlaylistClientUtil.getSegmentsAndPartsSync(playlist) pieces = RundownPlaylistCollectionUtil.getPiecesForParts(orderedSegmentsAndParts.parts.map((p) => p._id)) rundownIds = rundowns.map((rundown) => rundown._id) const rundownsToShowstyles: Map = new Map() diff --git a/meteor/client/ui/Collections.tsx b/meteor/client/ui/Collections.ts similarity index 93% rename from meteor/client/ui/Collections.tsx rename to meteor/client/ui/Collections.ts index 2ced6bb5fd..9af81dfe73 100644 --- a/meteor/client/ui/Collections.tsx +++ b/meteor/client/ui/Collections.ts @@ -38,6 +38,11 @@ export const UIPieceContentStatuses = createSyncCustomPublicationMongoCollection CustomCollectionName.UIPieceContentStatuses ) +/** + * A playout UI version of Parts. + */ +export const UIParts = createSyncCustomPublicationMongoCollection(CustomCollectionName.UIParts) + /** * Pre-processed MediaObjectIssue for Adlibbs in a Bucket */ diff --git a/meteor/client/ui/Collections/index.ts b/meteor/client/ui/Collections/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/meteor/client/ui/MediaStatus/MediaStatus.tsx b/meteor/client/ui/MediaStatus/MediaStatus.tsx index f75904f10e..88913d77b6 100644 --- a/meteor/client/ui/MediaStatus/MediaStatus.tsx +++ b/meteor/client/ui/MediaStatus/MediaStatus.tsx @@ -1,7 +1,6 @@ import { useMemo, JSX } from 'react' import { useSubscription, useSubscriptions, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { MeteorPubSub } from '../../../lib/api/pubsub' -import { getSegmentsWithPartInstances } from '../../../lib/Rundown' import { AdLibActionId, PartId, @@ -36,6 +35,7 @@ import { UIPieceContentStatuses, UIShowStyleBases } from '../Collections' import { isTranslatableMessage, translateMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' import { i18nTranslator } from '../i18n' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { RundownUtils } from '../../lib/rundown' export function MediaStatus({ playlistIds, @@ -111,7 +111,7 @@ function useRundownPlaylists(playlistIds: RundownPlaylistId[]) { .sort(sortRundownPlaylists) .map((playlist) => ({ playlist, - segments: getSegmentsWithPartInstances( + segments: RundownUtils.getSegmentsWithPartInstances( playlist, undefined, undefined, diff --git a/meteor/client/ui/Prompter/prompter.ts b/meteor/client/ui/Prompter/prompter.ts index 8980859b73..347a81883f 100644 --- a/meteor/client/ui/Prompter/prompter.ts +++ b/meteor/client/ui/Prompter/prompter.ts @@ -3,7 +3,7 @@ import * as _ from 'underscore' import { ScriptContent, SourceLayerType } from '@sofie-automation/blueprints-integration' import { normalizeArrayToMap, protectString } from '../../../lib/lib' import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' -import { getPieceInstancesForPartInstance, getSegmentsWithPartInstances } from '../../../lib/Rundown' +import { getPieceInstancesForPartInstance } from '../../../lib/Rundown' import { FindOptions } from '../../../lib/collections/lib' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' @@ -22,6 +22,7 @@ import { RundownPlaylists, PieceInstances, Pieces, Segments } from '../../collec import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { RundownPlaylistCollectionUtil } from '../../../lib/collections/rundownPlaylistUtil' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { RundownUtils } from '../../lib/rundown' // export interface NewPrompterAPI { // getPrompterData (playlistId: RundownPlaylistId): Promise @@ -84,7 +85,7 @@ export namespace PrompterAPI { }) as Pick) : undefined - const groupedParts = getSegmentsWithPartInstances( + const groupedParts = RundownUtils.getSegmentsWithPartInstances( playlist, undefined, undefined, diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index d5fa212df3..469b1f5b8e 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -129,7 +129,7 @@ import { ExecuteActionResult } from '@sofie-automation/corelib/dist/worker/studi import { SegmentListContainer } from './SegmentList/SegmentListContainer' import { getNextMode as getNextSegmentViewMode } from './SegmentContainer/SwitchViewModeButton' import { IResolvedSegmentProps } from './SegmentContainer/withResolvedSegment' -import { UIShowStyleBases, UIStudios } from './Collections' +import { UIParts, UIShowStyleBases, UIStudios } from './Collections' import { UIStudio } from '../../lib/api/studios' import { PartId, @@ -147,7 +147,6 @@ import { RundownPlaylists, Rundowns, ShowStyleVariants, - UIParts, } from '../collections' import { UIShowStyleBase } from '../../lib/api/showStyles' import { RundownPlaylistCollectionUtil } from '../../lib/collections/rundownPlaylistUtil' diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index a4bf59294c..a7901223df 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -20,6 +20,7 @@ import { RundownPlaylistCollectionUtil } from '../../../../lib/collections/rundo import { sortPartInstancesInSortedSegments } from '@sofie-automation/corelib/dist/playout/playlist' import { CalculateTimingsPiece } from '@sofie-automation/corelib/dist/playout/timings' import { RundownUtils } from '../../../lib/rundown' +import { RundownPlaylistClientUtil } from '../../../lib/rundownPlaylistUtil' const TIMING_DEFAULT_REFRESH_INTERVAL = 1000 / 60 // the interval for high-resolution events (timeupdateHR) const LOW_RESOLUTION_TIMING_DECIMATOR = 15 @@ -91,7 +92,7 @@ export const RundownTimingProvider = withTracker< const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) const segments = RundownPlaylistCollectionUtil.getSegments(playlist) const segmentsMap = new Map(segments.map((segment) => [segment._id, segment])) - const unorderedParts = RundownPlaylistCollectionUtil.getUnorderedParts(playlist) + const unorderedParts = RundownPlaylistClientUtil.getUnorderedParts(playlist) const activePartInstances = RundownPlaylistCollectionUtil.getActivePartInstances(playlist, undefined, { projection: { _id: 1, diff --git a/meteor/client/ui/SegmentContainer/withResolvedSegment.ts b/meteor/client/ui/SegmentContainer/withResolvedSegment.ts index 95ecf27041..7c0b142a6c 100644 --- a/meteor/client/ui/SegmentContainer/withResolvedSegment.ts +++ b/meteor/client/ui/SegmentContainer/withResolvedSegment.ts @@ -39,6 +39,7 @@ import { CalculateTimingsPiece } from '@sofie-automation/corelib/dist/playout/ti import { ReadonlyDeep } from 'type-fest' import { PieceContentStatusObj } from '../../../lib/api/pieceContentStatus' import { SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' export interface SegmentUi extends SegmentExtended { /** Output layers available in the installation used by this segment */ @@ -175,7 +176,7 @@ export function withResolvedSegment ( - RundownPlaylistCollectionUtil.getSegmentsAndPartsSync( + RundownPlaylistClientUtil.getSegmentsAndPartsSync( props.playlist, undefined, undefined, diff --git a/meteor/client/ui/SegmentList/SegmentListContainer.tsx b/meteor/client/ui/SegmentList/SegmentListContainer.tsx index 5a98efb29c..195e026026 100644 --- a/meteor/client/ui/SegmentList/SegmentListContainer.tsx +++ b/meteor/client/ui/SegmentList/SegmentListContainer.tsx @@ -10,8 +10,9 @@ import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { SegmentList } from './SegmentList' import { unprotectString } from '../../../lib/lib' import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/SegmentTimelineContainer' -import { PartInstances, Segments, UIParts } from '../../collections' +import { PartInstances, Segments } from '../../collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { UIParts } from '../Collections' export const LIVELINE_HISTORY_SIZE = TIMELINE_LIVELINE_HISTORY_SIZE diff --git a/meteor/client/ui/SegmentScratchpad/SegmentScratchpadContainer.tsx b/meteor/client/ui/SegmentScratchpad/SegmentScratchpadContainer.tsx index 54b81b0c1b..14893f21fc 100644 --- a/meteor/client/ui/SegmentScratchpad/SegmentScratchpadContainer.tsx +++ b/meteor/client/ui/SegmentScratchpad/SegmentScratchpadContainer.tsx @@ -11,11 +11,12 @@ import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { SegmentScratchpad } from './SegmentScratchpad' import { unprotectString } from '../../../lib/lib' import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/SegmentTimelineContainer' -import { PartInstances, Segments, UIParts } from '../../collections' +import { PartInstances, Segments } from '../../collections' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' import { MongoFieldSpecifierOnes } from '@sofie-automation/corelib/dist/mongo' import { PartInstance } from '../../../lib/collections/PartInstances' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { UIParts } from '../Collections' export const LIVELINE_HISTORY_SIZE = TIMELINE_LIVELINE_HISTORY_SIZE diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx b/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx index 7aa56f1ac1..0ea2f2d01f 100644 --- a/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx +++ b/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx @@ -11,11 +11,12 @@ import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { SegmentStoryboard } from './SegmentStoryboard' import { unprotectString } from '../../../lib/lib' import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/SegmentTimelineContainer' -import { PartInstances, Segments, UIParts } from '../../collections' +import { PartInstances, Segments } from '../../collections' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' import { MongoFieldSpecifierOnes } from '@sofie-automation/corelib/dist/mongo' import { PartInstance } from '../../../lib/collections/PartInstances' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { UIParts } from '../Collections' export const LIVELINE_HISTORY_SIZE = TIMELINE_LIVELINE_HISTORY_SIZE diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx index 1447b491bf..dc6ac1e441 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx @@ -28,11 +28,12 @@ import { import { computeSegmentDuration, getPartInstanceTimingId, RundownTimingContext } from '../../lib/rundownTiming' import { RundownViewShelf } from '../RundownView/RundownViewShelf' import { PartInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PartInstances, UIParts } from '../../collections' +import { PartInstances } from '../../collections' import { catchError, useDebounce } from '../../lib/lib' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { logger } from '../../../lib/logging' +import { UIParts } from '../Collections' // Kept for backwards compatibility export { SegmentUi, PartUi, PieceUi, ISourceLayerUi, IOutputLayerUi } from '../SegmentContainer/withResolvedSegment' diff --git a/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx b/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx index 9ce3d1e5e8..e722c333bc 100644 --- a/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx +++ b/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx @@ -24,11 +24,12 @@ import { NotificationCenter, Notification, NoticeLevel } from '../../../../../li import { Meteor } from 'meteor/meteor' import { doModalDialog } from '../../../../lib/ModalDialog' import { PartId, RundownId, ShowStyleBaseId, TriggeredActionId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PartInstances, RundownPlaylists, Rundowns, TriggeredActions, UIParts } from '../../../../collections' +import { PartInstances, RundownPlaylists, Rundowns, TriggeredActions } from '../../../../collections' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { SourceLayers, OutputLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { RundownPlaylistCollectionUtil } from '../../../../../lib/collections/rundownPlaylistUtil' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { UIParts } from '../../../Collections' export interface PreviewContext { rundownPlaylist: DBRundownPlaylist | null diff --git a/meteor/client/ui/Shelf/AdLibPanel.tsx b/meteor/client/ui/Shelf/AdLibPanel.tsx index 8e60f4408a..4efcac2f43 100644 --- a/meteor/client/ui/Shelf/AdLibPanel.tsx +++ b/meteor/client/ui/Shelf/AdLibPanel.tsx @@ -59,6 +59,7 @@ import { RundownBaselineAdLibPieces, } from '../../collections' import { RundownPlaylistCollectionUtil } from '../../../lib/collections/rundownPlaylistUtil' +import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' export interface IAdLibPanelProps { // liveSegment: Segment | undefined @@ -272,7 +273,7 @@ export function fetchAndFilter(props: IFetchAndFilterProps): AdLibFetchAndFilter return segmentUi }) - RundownPlaylistCollectionUtil.getUnorderedParts(props.playlist, { + RundownPlaylistClientUtil.getUnorderedParts(props.playlist, { segmentId: { $in: Array.from(uiSegmentMap.keys()), }, diff --git a/meteor/client/ui/Shelf/SegmentNamePanel.tsx b/meteor/client/ui/Shelf/SegmentNamePanel.tsx index 999dd8ed17..7c63dbc051 100644 --- a/meteor/client/ui/Shelf/SegmentNamePanel.tsx +++ b/meteor/client/ui/Shelf/SegmentNamePanel.tsx @@ -12,6 +12,7 @@ import { Translated, translateWithTracker } from '../../lib/ReactMeteorData/Reac import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { PartInstance } from '../../../lib/collections/PartInstances' import { RundownPlaylistCollectionUtil } from '../../../lib/collections/rundownPlaylistUtil' +import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' interface ISegmentNamePanelProps { visible?: boolean @@ -84,7 +85,7 @@ function getSegmentName(selectedSegment: 'current' | 'next', playlist: DBRundown // Current and next part are same segment, or next is not set // Find next segment in order - const orderedSegmentsAndParts = RundownPlaylistCollectionUtil.getSegmentsAndPartsSync(playlist) + const orderedSegmentsAndParts = RundownPlaylistClientUtil.getSegmentsAndPartsSync(playlist) const segmentIndex = orderedSegmentsAndParts.segments.findIndex((s) => s._id === currentPartInstance.segmentId) if (segmentIndex === -1) return diff --git a/meteor/client/ui/Shelf/SegmentTimingPanel.tsx b/meteor/client/ui/Shelf/SegmentTimingPanel.tsx index 0b7fc44011..0e7c166dea 100644 --- a/meteor/client/ui/Shelf/SegmentTimingPanel.tsx +++ b/meteor/client/ui/Shelf/SegmentTimingPanel.tsx @@ -23,6 +23,7 @@ import { UIShowStyleBase } from '../../../lib/api/showStyles' import { PartId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { RundownPlaylistCollectionUtil } from '../../../lib/collections/rundownPlaylistUtil' import { CalculateTimingsPiece } from '@sofie-automation/corelib/dist/playout/timings' +import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' interface ISegmentTimingPanelProps { visible?: boolean @@ -102,15 +103,9 @@ export const SegmentTimingPanel = translateWithTracker< memoizedIsolatedAutorun( (_playlistId: RundownPlaylistId) => ( - RundownPlaylistCollectionUtil.getSegmentsAndPartsSync( - props.playlist, - undefined, - undefined, - undefined, - { - fields: { _id: 1 }, - } - ).parts as Pick[] + RundownPlaylistClientUtil.getSegmentsAndPartsSync(props.playlist, undefined, undefined, undefined, { + fields: { _id: 1 }, + }).parts as Pick[] ).map((part) => part._id), 'playlist.getAllOrderedParts', props.playlist._id @@ -133,7 +128,7 @@ export const SegmentTimingPanel = translateWithTracker< props.playlist.activationId === undefined ? 0 : Math.random() * 2000 + 500 ) - const orderedSegmentsAndParts = RundownPlaylistCollectionUtil.getSegmentsAndPartsSync(props.playlist) + const orderedSegmentsAndParts = RundownPlaylistClientUtil.getSegmentsAndPartsSync(props.playlist) const rundownOrder = RundownPlaylistCollectionUtil.getRundownOrderedIDs(props.playlist) const rundownIndex = rundownOrder.indexOf(liveSegment.rundownId) const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(props.playlist) diff --git a/meteor/lib/Rundown.ts b/meteor/lib/Rundown.ts index 8e49264b19..0da8a96fce 100644 --- a/meteor/lib/Rundown.ts +++ b/meteor/lib/Rundown.ts @@ -2,7 +2,7 @@ import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' import { IOutputLayer, ISourceLayer, ITranslatableMessage } from '@sofie-automation/blueprints-integration' import { DBSegment, SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { PartInstance, wrapPartToTemporaryInstance } from './collections/PartInstances' +import { PartInstance } from './collections/PartInstances' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { getPieceInstancesForPart, @@ -10,11 +10,11 @@ import { buildPastInfinitePiecesForThisPartQuery, } from '@sofie-automation/corelib/dist/playout/infinites' import { invalidateAfter } from '../lib/invalidatingTime' -import { getCurrentTime, groupByToMap, ProtectedString, protectString } from './lib' +import { getCurrentTime, ProtectedString, protectString } from './lib' import { DBRundownPlaylist, QuickLoopMarkerType } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { isTranslatableMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' -import { mongoWhereFilter, MongoQuery } from '@sofie-automation/corelib/dist/mongo' +import { mongoWhereFilter } from '@sofie-automation/corelib/dist/mongo' import { FindOptions } from './collections/lib' import { PartId, @@ -24,7 +24,6 @@ import { ShowStyleBaseId, } from '@sofie-automation/corelib/dist/dataModel/Ids' import { PieceInstances, Pieces } from './collections/libCollections' -import { RundownPlaylistCollectionUtil } from './collections/rundownPlaylistUtil' import { PieceContentStatusObj } from './api/pieceContentStatus' import { ReadonlyDeep } from 'type-fest' import { PieceInstanceWithTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' @@ -246,83 +245,6 @@ export function getPieceInstancesForPartInstance( } } -/** - * Get all PartInstances (or temporary PartInstances) all segments in all rundowns in a playlist, using given queries - * to limit the data, in correct order. - * - * @export - * @param {DBRundownPlaylist} playlist - * @param {(MongoQuery)} [segmentsQuery] - * @param {(MongoQuery)} [partsQuery] - * @param {MongoQuery} [partInstancesQuery] - * @param {FindOptions} [segmentsOptions] - * @param {FindOptions} [partsOptions] - * @param {FindOptions} [partInstancesOptions] - * @return {*} {Array<{ segment: Segment; partInstances: PartInstance[] }>} - */ -export function getSegmentsWithPartInstances( - playlist: DBRundownPlaylist, - segmentsQuery?: MongoQuery, - partsQuery?: MongoQuery, - partInstancesQuery?: MongoQuery, - segmentsOptions?: FindOptions, - partsOptions?: FindOptions, - partInstancesOptions?: FindOptions -): Array<{ segment: DBSegment; partInstances: PartInstance[] }> { - const { segments, parts: rawParts } = RundownPlaylistCollectionUtil.getSegmentsAndPartsSync( - playlist, - segmentsQuery, - partsQuery, - segmentsOptions, - partsOptions - ) - const rawPartInstances = RundownPlaylistCollectionUtil.getActivePartInstances( - playlist, - partInstancesQuery, - partInstancesOptions - ) - const playlistActivationId = playlist.activationId ?? protectString('') - - const partsBySegment = groupByToMap(rawParts, 'segmentId') - const partInstancesBySegment = groupByToMap(rawPartInstances, 'segmentId') - - return segments.map((segment) => { - const segmentParts = partsBySegment.get(segment._id) ?? [] - const segmentPartInstances = partInstancesBySegment.get(segment._id) ?? [] - - if (segmentPartInstances.length === 0) { - return { - segment, - partInstances: segmentParts.map((p) => wrapPartToTemporaryInstance(playlistActivationId, p)), - } - } else if (segmentParts.length === 0) { - return { - segment, - partInstances: segmentPartInstances.sort( - (a, b) => a.part._rank - b.part._rank || a.takeCount - b.takeCount - ), - } - } else { - const partIds: Set = new Set() - for (const partInstance of segmentPartInstances) { - partIds.add(partInstance.part._id) - } - for (const part of segmentParts) { - if (partIds.has(part._id)) continue - segmentPartInstances.push(wrapPartToTemporaryInstance(playlistActivationId, part)) - } - const allPartInstances = segmentPartInstances.sort( - (a, b) => a.part._rank - b.part._rank || a.takeCount - b.takeCount - ) - - return { - segment, - partInstances: allPartInstances, - } - } - }) -} - // 1 reactivelly listen to data changes /* setup () { diff --git a/meteor/lib/collections/libCollections.ts b/meteor/lib/collections/libCollections.ts index c6fd5048b7..9192074ad2 100644 --- a/meteor/lib/collections/libCollections.ts +++ b/meteor/lib/collections/libCollections.ts @@ -6,11 +6,7 @@ import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' -import { - createSyncCustomPublicationMongoCollection, - createSyncMongoCollection, - createSyncReadOnlyMongoCollection, -} from './lib' +import { createSyncMongoCollection, createSyncReadOnlyMongoCollection } from './lib' import { DBOrganization } from './Organization' import { PartInstance } from './PartInstances' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' @@ -21,7 +17,6 @@ import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataMod import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { CustomCollectionName } from '../api/pubsub' export const AdLibActions = createSyncReadOnlyMongoCollection(CollectionName.AdLibActions) @@ -37,11 +32,6 @@ export const PartInstances = createSyncReadOnlyMongoCollection(Col export const Parts = createSyncReadOnlyMongoCollection(CollectionName.Parts) -/** - * A playout UI version of Parts. - */ -export const UIParts = createSyncCustomPublicationMongoCollection(CustomCollectionName.UIParts) - export const RundownBaselineAdLibActions = createSyncReadOnlyMongoCollection( CollectionName.RundownBaselineAdLibActions ) diff --git a/meteor/lib/collections/rundownPlaylistUtil.ts b/meteor/lib/collections/rundownPlaylistUtil.ts index 1c10c6ef0e..ac5a604328 100644 --- a/meteor/lib/collections/rundownPlaylistUtil.ts +++ b/meteor/lib/collections/rundownPlaylistUtil.ts @@ -12,7 +12,7 @@ import { } from '@sofie-automation/corelib/dist/playout/playlist' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import _ from 'underscore' -import { Rundowns, Segments, PartInstances, Pieces, UIParts } from './libCollections' +import { Rundowns, Segments, PartInstances, Pieces } from './libCollections' import { FindOptions } from './lib' import { PartInstance } from './PartInstances' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' @@ -147,95 +147,7 @@ export class RundownPlaylistCollectionUtil { ).fetch() return RundownPlaylistCollectionUtil._sortSegments(segments, playlist) } - static getUnorderedParts( - playlist: Pick, - selector?: MongoQuery, - options?: FindOptions - ): DBPart[] { - const rundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) - const parts = UIParts.find( - { - ...selector, - rundownId: { - $in: rundownIds, - }, - }, - { - ...options, - sort: { - rundownId: 1, - _rank: 1, - }, - } - ).fetch() - return parts - } - /** Synchronous version of getSegmentsAndParts, to be used client-side */ - static getSegmentsAndPartsSync( - playlist: Pick, - segmentsQuery?: MongoQuery, - partsQuery?: MongoQuery, - segmentsOptions?: Omit, 'projection'>, // We are mangling fields, so block projection - partsOptions?: Omit, 'projection'> // We are mangling fields, so block projection - ): { segments: DBSegment[]; parts: DBPart[] } { - const rundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) - const segments = Segments.find( - { - rundownId: { - $in: rundownIds, - }, - ...segmentsQuery, - }, - { - ...segmentsOptions, - //@ts-expect-error This is too clever for the compiler - fields: segmentsOptions?.fields - ? { - ...segmentsOptions?.fields, - _rank: 1, - rundownId: 1, - } - : undefined, - sort: { - ...segmentsOptions?.sort, - rundownId: 1, - _rank: 1, - }, - } - ).fetch() - - const parts = UIParts.find( - { - rundownId: { - $in: rundownIds, - }, - ...partsQuery, - }, - { - ...partsOptions, - //@ts-expect-error This is too clever for the compiler - fields: partsOptions?.fields - ? { - ...partsOptions?.fields, - rundownId: 1, - segmentId: 1, - _rank: 1, - } - : undefined, - sort: { - ...segmentsOptions?.sort, - rundownId: 1, - _rank: 1, - }, - } - ).fetch() - const sortedSegments = RundownPlaylistCollectionUtil._sortSegments(segments, playlist) - return { - segments: sortedSegments, - parts: RundownPlaylistCollectionUtil._sortPartsInner(parts, sortedSegments), - } - } static getSelectedPartInstances( playlist: Pick, rundownIds0?: RundownId[] From 9a9253fa0fdcf946ee3e59dbbe15e3b3799aef1c Mon Sep 17 00:00:00 2001 From: Jonas Hummelstrand Date: Wed, 24 Apr 2024 20:02:09 +0200 Subject: [PATCH 041/276] chore: Update docusaurus.config.js Updated the Slack invite link in the footer. --- packages/documentation/docusaurus.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/documentation/docusaurus.config.js b/packages/documentation/docusaurus.config.js index 682ee812e6..9fcebfeba6 100644 --- a/packages/documentation/docusaurus.config.js +++ b/packages/documentation/docusaurus.config.js @@ -65,7 +65,7 @@ module.exports = { items: [ { label: 'Sofie Slack Community', - href: 'https://join.slack.com/t/sofietv/shared_invite/enQtNTk2Mzc3MTQ1NzAzLTJkZjMyMDg3OGM0YWU3MmU4YzBhZDAyZWI1YmJmNmRiYWQ1OTZjYTkzOTkzMTA2YTE1YjgxMmVkM2U1OGZlNWI', + href: 'https://join.slack.com/t/sofietv/shared_invite/zt-2bfz8l9lw-azLeDB55cvN2wvMgqL1alA', }, ], }, From 13d7a60dca9159a2743b7e45c7a5b00e1b336058 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Apr 2024 06:21:39 +0000 Subject: [PATCH 042/276] chore(deps): bump slackapi/slack-github-action from 1.25.0 to 1.26.0 Bumps [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action) from 1.25.0 to 1.26.0. - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Commits](https://github.com/slackapi/slack-github-action/compare/v1.25.0...v1.26.0) --- updated-dependencies: - dependency-name: slackapi/slack-github-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/trivy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 7d86efc702..98afc2e1f1 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -54,7 +54,7 @@ jobs: echo ${{ env.SUMMARY }} - name: Send Slack Notification - uses: slackapi/slack-github-action@v1.25.0 + uses: slackapi/slack-github-action@v1.26.0 with: payload: | { From 2da22f30720e70a030ff095e181799e52499e989 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Apr 2024 06:47:35 +0000 Subject: [PATCH 043/276] chore(deps): bump peaceiris/actions-gh-pages from 3 to 4 Bumps [peaceiris/actions-gh-pages](https://github.com/peaceiris/actions-gh-pages) from 3 to 4. - [Release notes](https://github.com/peaceiris/actions-gh-pages/releases) - [Changelog](https://github.com/peaceiris/actions-gh-pages/blob/main/CHANGELOG.md) - [Commits](https://github.com/peaceiris/actions-gh-pages/compare/v3...v4) --- updated-dependencies: - dependency-name: peaceiris/actions-gh-pages dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/node.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index b54c46f145..dfc531db5c 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -558,7 +558,7 @@ jobs: CI: true - name: Publish if: github.ref == 'refs/heads/master' # always publish for just the master branch - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./packages/documentation/build From 7194a591ecc5cfb27b03a06aeb19f4a72451b946 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Wed, 1 May 2024 07:52:45 +0000 Subject: [PATCH 044/276] chore: add openapi ci generator --- .github/workflows/node.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index 80686529dd..c21b080546 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -672,3 +672,22 @@ jobs: node scripts/checkForMultipleVersions.mjs env: CI: true + + build-stable-api: + name: Build OpenAPI typescript client + runs-on: ubuntu-latest + continue-on-error: true + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Generate code + uses: hatamiarash7/openapi-generator@v0.2.0 + working-directory: ./packages/openapi + with: + generator: typescript-fetch + openapi-file: ./api/actions.yaml + output-dir: client/ts + command-args: supportsES6=true From ef6569e055f0479a675cbbd7de7006f038573f86 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Wed, 1 May 2024 07:54:12 +0000 Subject: [PATCH 045/276] chore: wip --- .github/workflows/node.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index c21b080546..dc21134ae9 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -684,8 +684,8 @@ jobs: persist-credentials: false - name: Generate code - uses: hatamiarash7/openapi-generator@v0.2.0 working-directory: ./packages/openapi + uses: hatamiarash7/openapi-generator@v0.2.0 with: generator: typescript-fetch openapi-file: ./api/actions.yaml From 3f1c8af3bb47ece7101eb3d516f42bdeb51bc77b Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Wed, 1 May 2024 07:56:30 +0000 Subject: [PATCH 046/276] chore: wip --- .github/workflows/node.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index dc21134ae9..827a9ed858 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -684,10 +684,9 @@ jobs: persist-credentials: false - name: Generate code - working-directory: ./packages/openapi uses: hatamiarash7/openapi-generator@v0.2.0 with: generator: typescript-fetch - openapi-file: ./api/actions.yaml - output-dir: client/ts + openapi-file: ./packages/openapi/api/actions.yaml + output-dir: ./packages/openapi/client/ts command-args: supportsES6=true From 43adc8d634de3b20a6205ff377367bba8a378b60 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Wed, 1 May 2024 08:00:17 +0000 Subject: [PATCH 047/276] chore: wip --- .github/workflows/node.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index 827a9ed858..14b7ca0df1 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -684,9 +684,9 @@ jobs: persist-credentials: false - name: Generate code - uses: hatamiarash7/openapi-generator@v0.2.0 + uses: hatamiarash7/openapi-generator@v0.3.0 with: generator: typescript-fetch openapi-file: ./packages/openapi/api/actions.yaml output-dir: ./packages/openapi/client/ts - command-args: supportsES6=true + command-args: -p supportsES6=true From 618ce57398c56f5121b27410a15e4fd1804ffc63 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Wed, 1 May 2024 09:49:24 +0000 Subject: [PATCH 048/276] chore: wip --- .github/workflows/node.yaml | 36 +++++++++++++++++++++++++-- .github/workflows/prerelease-libs.yml | 15 +++++++++++ packages/openapi/package.json | 1 - 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index 14b7ca0df1..0fba492aa7 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -618,6 +618,21 @@ jobs: yarn build env: CI: true + - name: Generate OpenAPI client library + if: ${{ steps.do-publish.outputs.tag }} + uses: hatamiarash7/openapi-generator@v0.3.0 + with: + generator: typescript-fetch + openapi-file: ./packages/openapi/api/actions.yaml + output-dir: ./packages/openapi/client/ts + command-args: -p supportsES6=true + - name: Build OpenAPI client library + if: ${{ steps.do-publish.outputs.tag }} + run: | + cd packages/openapi + yarn build:main + env: + CI: true - name: Modify dependencies to use npm packages run: node scripts/prepublish.js - name: Publish to NPM @@ -673,16 +688,33 @@ jobs: env: CI: true - build-stable-api: - name: Build OpenAPI typescript client + release-openapi-lib: + name: Build and release OpenAPI typescript client runs-on: ubuntu-latest continue-on-error: true timeout-minutes: 15 + + # only run for tags + if: contains(github.ref, 'refs/tags/') + steps: - uses: actions/checkout@v4 with: persist-credentials: false + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version-file: ".node-version" + - name: Prepare Environment # have to run this first to make sure the semver lib is available + run: | + yarn config set cacheFolder /home/runner/release-libs-cache + + cd packages + yarn install + env: + CI: true + - name: Generate code uses: hatamiarash7/openapi-generator@v0.3.0 with: diff --git a/.github/workflows/prerelease-libs.yml b/.github/workflows/prerelease-libs.yml index cee7b42fd6..6d34115b6a 100644 --- a/.github/workflows/prerelease-libs.yml +++ b/.github/workflows/prerelease-libs.yml @@ -131,6 +131,21 @@ jobs: yarn build env: CI: true + - name: Generate OpenAPI client library + if: ${{ steps.do-publish.outputs.tag }} + uses: hatamiarash7/openapi-generator@v0.3.0 + with: + generator: typescript-fetch + openapi-file: ./packages/openapi/api/actions.yaml + output-dir: ./packages/openapi/client/ts + command-args: -p supportsES6=true + - name: Build OpenAPI client library + if: ${{ steps.do-publish.outputs.tag }} + run: | + cd packages/openapi + yarn build:main + env: + CI: true - name: Modify dependencies to use npm packages run: node scripts/prepublish.js - name: Publish to NPM diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 36b467ed95..b9c4217dbe 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -1,7 +1,6 @@ { "name": "@sofie-automation/openapi", "version": "1.50.1", - "private": true, "license": "MIT", "repository": { "type": "git", From d075d90947ff203851b1aa6bffb002044ecdb380 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Wed, 1 May 2024 09:50:52 +0000 Subject: [PATCH 049/276] chore: wip --- .github/workflows/node.yaml | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index 0fba492aa7..767ebf0541 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -687,38 +687,3 @@ jobs: node scripts/checkForMultipleVersions.mjs env: CI: true - - release-openapi-lib: - name: Build and release OpenAPI typescript client - runs-on: ubuntu-latest - continue-on-error: true - timeout-minutes: 15 - - # only run for tags - if: contains(github.ref, 'refs/tags/') - - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version-file: ".node-version" - - name: Prepare Environment # have to run this first to make sure the semver lib is available - run: | - yarn config set cacheFolder /home/runner/release-libs-cache - - cd packages - yarn install - env: - CI: true - - - name: Generate code - uses: hatamiarash7/openapi-generator@v0.3.0 - with: - generator: typescript-fetch - openapi-file: ./packages/openapi/api/actions.yaml - output-dir: ./packages/openapi/client/ts - command-args: -p supportsES6=true From 5b87e184b0dbf9ae4a05275bf95452b0a30ba19d Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Wed, 1 May 2024 10:01:31 +0000 Subject: [PATCH 050/276] chore: wip --- packages/openapi/LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 packages/openapi/LICENSE diff --git a/packages/openapi/LICENSE b/packages/openapi/LICENSE new file mode 100644 index 0000000000..78f0f2dbb8 --- /dev/null +++ b/packages/openapi/LICENSE @@ -0,0 +1,21 @@ +MIT License (MIT) + +Copyright (c) 2018 Norsk rikskringkasting AS (NRK) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 68243ca939b3a52b50a373843c16071e42f6ea9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Mon, 6 May 2024 09:02:45 +0200 Subject: [PATCH 051/276] Create CODEOWNERS --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..72a337e9e8 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @SofieTeam From 1a6260c135c053fe24b11c9ed8c8ff483fc0b1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20St=C3=A6rk=C3=A6r?= Date: Mon, 6 May 2024 11:07:56 +0200 Subject: [PATCH 052/276] Update CODEOWNERS --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 72a337e9e8..cdc0028efd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @SofieTeam +* @nrkno/sofieteam From a793397e92bdbee69acd82e931e03bd095a95137 Mon Sep 17 00:00:00 2001 From: ianshade Date: Tue, 7 May 2024 23:29:04 +0200 Subject: [PATCH 053/276] perf: track invalidated segments and parts in UIParts publication --- .../publications/partsUI/publication.ts | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/meteor/server/publications/partsUI/publication.ts b/meteor/server/publications/partsUI/publication.ts index 55c09cc6f2..e5eb2a1a96 100644 --- a/meteor/server/publications/partsUI/publication.ts +++ b/meteor/server/publications/partsUI/publication.ts @@ -1,4 +1,4 @@ -import { PartId, RundownId, RundownPlaylistId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, RundownPlaylistId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { check } from 'meteor/check' import { CustomPublishCollection, @@ -43,7 +43,6 @@ export interface UIPartsState { interface UIPartsUpdateProps { newCache: ContentCache - invalidateRundownIds: RundownId[] invalidateSegmentIds: SegmentId[] invalidatePartIds: PartId[] @@ -84,10 +83,18 @@ async function setupUIPartsPublicationObservers( changed: (id) => triggerUpdate({ invalidateSegmentIds: [protectString(id)] }), removed: (id) => triggerUpdate({ invalidateSegmentIds: [protectString(id)] }), }), - cache.Parts.find({}).observeChanges({ - added: (id) => triggerUpdate({ invalidatePartIds: [protectString(id)] }), - changed: (id) => triggerUpdate({ invalidatePartIds: [protectString(id)] }), - removed: (id) => triggerUpdate({ invalidatePartIds: [protectString(id)] }), + cache.Parts.find({}).observe({ + added: (doc) => triggerUpdate({ invalidatePartIds: [doc._id] }), + changed: (doc, oldDoc) => { + if (doc._rank !== oldDoc._rank) { + // with part rank change we need to invalidate the entire segment, + // as the order may affect which unchanged parts are/aren't in quickLoop + triggerUpdate({ invalidateSegmentIds: [doc.segmentId] }) + } else { + triggerUpdate({ invalidatePartIds: [doc._id] }) + } + }, + removed: (doc) => triggerUpdate({ invalidatePartIds: [doc._id] }), }), cache.RundownPlaylists.find({}).observeChanges({ added: () => triggerUpdate({ invalidateQuickLoop: true }), @@ -122,9 +129,6 @@ export async function manipulateUIPartsPublicationData( ): Promise { // Prepare data for publication: - // We know that `collection` does diffing when 'commiting' all of the changes we have made - // meaning that for anything we will call `replace()` on, we can `remove()` it first for no extra cost - if (updateProps?.newCache !== undefined) { state.contentCache = updateProps.newCache ?? undefined } @@ -142,16 +146,6 @@ export async function manipulateUIPartsPublicationData( const studio = state.contentCache.Studios.findOne({}) if (!studio) return - if (!playlist.quickLoop?.start || !playlist.quickLoop?.end) { - collection.remove(null) - state.contentCache.Parts.find({}).forEach((part) => { - collection.replace(part) - }) - return - } - - collection.remove(null) - const rundownRanks = stringsToIndexLookup(playlist.rundownIdsInOrder as unknown as string[]) const segmentRanks = extractRanks(state.contentCache.Segments.find({}).fetch()) @@ -188,9 +182,22 @@ export async function manipulateUIPartsPublicationData( part.autoNext = part.autoNext || (isLoopingOverriden && (part.expectedDuration ?? 0) > 0) } + updateProps?.invalidatePartIds?.forEach((partId) => { + collection.remove(partId) // if it still exists, it will be replaced in the next step + }) + + const invalidatedSegmentsSet = new Set(updateProps?.invalidateSegmentIds ?? []) + const invalidatedPartsSet = new Set(updateProps?.invalidatePartIds ?? []) + state.contentCache.Parts.find({}).forEach((part) => { - modifyPartForQuickLoop(part) - collection.replace(part) + if ( + updateProps?.invalidateQuickLoop || + invalidatedSegmentsSet.has(part.segmentId) || + invalidatedPartsSet.has(part._id) + ) { + modifyPartForQuickLoop(part) + collection.replace(part) + } }) } From 07b911f4257bf8d0b33a9ef95266f4be9e028b0b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 May 2024 09:34:55 +0100 Subject: [PATCH 054/276] chore(deps): bump aquasecurity/trivy-action from 0.19.0 to 0.20.0 (#1187) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/node.yaml | 4 ++-- .github/workflows/trivy.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index 944b92516f..820a9d369a 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -231,7 +231,7 @@ jobs: echo "image=$image" >> $GITHUB_OUTPUT - name: Trivy scanning if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: aquasecurity/trivy-action@0.19.0 + uses: aquasecurity/trivy-action@0.20.0 with: image-ref: "${{ steps.trivy-image.outputs.image }}" format: "table" @@ -380,7 +380,7 @@ jobs: echo "image=$image" >> $GITHUB_OUTPUT - name: Trivy scanning if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: aquasecurity/trivy-action@0.19.0 + uses: aquasecurity/trivy-action@0.20.0 with: image-ref: "${{ steps.trivy-image.outputs.image }}" format: "table" diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 98afc2e1f1..e3c4d122f0 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -13,14 +13,14 @@ jobs: image: ["server-core", "playout-gateway", "mos-gateway"] steps: - name: Run Trivy vulnerability scanner (json) - uses: aquasecurity/trivy-action@0.19.0 + uses: aquasecurity/trivy-action@0.20.0 with: image-ref: ghcr.io/nrkno/sofie-core-${{ matrix.image }}:latest format: json output: '${{ matrix.image }}-trivy-scan-results.json' - name: Run Trivy vulnerability scanner (table) - uses: aquasecurity/trivy-action@0.19.0 + uses: aquasecurity/trivy-action@0.20.0 with: image-ref: ghcr.io/nrkno/sofie-core-${{ matrix.image }}:latest output: '${{ matrix.image }}-trivy-scan-results.txt' @@ -36,7 +36,7 @@ jobs: echo $CODE_BLOCK >> $GITHUB_STEP_SUMMARY - name: Run Trivy in GitHub SBOM mode and submit results to Dependency Graph - uses: aquasecurity/trivy-action@0.19.0 + uses: aquasecurity/trivy-action@0.20.0 with: format: 'github' output: 'dependency-results-${{ matrix.image }}.sbom.json' From a7a547b80014042274861d7427f4c52be0842e03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Jun 2024 09:52:51 +0100 Subject: [PATCH 055/276] chore(deps): bump aquasecurity/trivy-action from 0.20.0 to 0.21.0 (#1196) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/node.yaml | 4 ++-- .github/workflows/trivy.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index 820a9d369a..947ba1071e 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -231,7 +231,7 @@ jobs: echo "image=$image" >> $GITHUB_OUTPUT - name: Trivy scanning if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: aquasecurity/trivy-action@0.20.0 + uses: aquasecurity/trivy-action@0.21.0 with: image-ref: "${{ steps.trivy-image.outputs.image }}" format: "table" @@ -380,7 +380,7 @@ jobs: echo "image=$image" >> $GITHUB_OUTPUT - name: Trivy scanning if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: aquasecurity/trivy-action@0.20.0 + uses: aquasecurity/trivy-action@0.21.0 with: image-ref: "${{ steps.trivy-image.outputs.image }}" format: "table" diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index e3c4d122f0..6f175832ae 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -13,14 +13,14 @@ jobs: image: ["server-core", "playout-gateway", "mos-gateway"] steps: - name: Run Trivy vulnerability scanner (json) - uses: aquasecurity/trivy-action@0.20.0 + uses: aquasecurity/trivy-action@0.21.0 with: image-ref: ghcr.io/nrkno/sofie-core-${{ matrix.image }}:latest format: json output: '${{ matrix.image }}-trivy-scan-results.json' - name: Run Trivy vulnerability scanner (table) - uses: aquasecurity/trivy-action@0.20.0 + uses: aquasecurity/trivy-action@0.21.0 with: image-ref: ghcr.io/nrkno/sofie-core-${{ matrix.image }}:latest output: '${{ matrix.image }}-trivy-scan-results.txt' @@ -36,7 +36,7 @@ jobs: echo $CODE_BLOCK >> $GITHUB_STEP_SUMMARY - name: Run Trivy in GitHub SBOM mode and submit results to Dependency Graph - uses: aquasecurity/trivy-action@0.20.0 + uses: aquasecurity/trivy-action@0.21.0 with: format: 'github' output: 'dependency-results-${{ matrix.image }}.sbom.json' From 3652e4a697953925756e24aa4bfa16a050f8784f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Jun 2024 06:44:01 +0000 Subject: [PATCH 056/276] chore(deps): bump aquasecurity/trivy-action from 0.21.0 to 0.22.0 Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.21.0 to 0.22.0. - [Release notes](https://github.com/aquasecurity/trivy-action/releases) - [Commits](https://github.com/aquasecurity/trivy-action/compare/0.21.0...0.22.0) --- updated-dependencies: - dependency-name: aquasecurity/trivy-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/node.yaml | 4 ++-- .github/workflows/trivy.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index 947ba1071e..b0fb9f0882 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -231,7 +231,7 @@ jobs: echo "image=$image" >> $GITHUB_OUTPUT - name: Trivy scanning if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: aquasecurity/trivy-action@0.21.0 + uses: aquasecurity/trivy-action@0.22.0 with: image-ref: "${{ steps.trivy-image.outputs.image }}" format: "table" @@ -380,7 +380,7 @@ jobs: echo "image=$image" >> $GITHUB_OUTPUT - name: Trivy scanning if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: aquasecurity/trivy-action@0.21.0 + uses: aquasecurity/trivy-action@0.22.0 with: image-ref: "${{ steps.trivy-image.outputs.image }}" format: "table" diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 6f175832ae..a29893e63e 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -13,14 +13,14 @@ jobs: image: ["server-core", "playout-gateway", "mos-gateway"] steps: - name: Run Trivy vulnerability scanner (json) - uses: aquasecurity/trivy-action@0.21.0 + uses: aquasecurity/trivy-action@0.22.0 with: image-ref: ghcr.io/nrkno/sofie-core-${{ matrix.image }}:latest format: json output: '${{ matrix.image }}-trivy-scan-results.json' - name: Run Trivy vulnerability scanner (table) - uses: aquasecurity/trivy-action@0.21.0 + uses: aquasecurity/trivy-action@0.22.0 with: image-ref: ghcr.io/nrkno/sofie-core-${{ matrix.image }}:latest output: '${{ matrix.image }}-trivy-scan-results.txt' @@ -36,7 +36,7 @@ jobs: echo $CODE_BLOCK >> $GITHUB_STEP_SUMMARY - name: Run Trivy in GitHub SBOM mode and submit results to Dependency Graph - uses: aquasecurity/trivy-action@0.21.0 + uses: aquasecurity/trivy-action@0.22.0 with: format: 'github' output: 'dependency-results-${{ matrix.image }}.sbom.json' From d895d3d704114201138c00014d3f1104e1956ea5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jun 2024 06:20:22 +0000 Subject: [PATCH 057/276] chore(deps): bump docker/build-push-action from 5 to 6 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v5...v6) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/node.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index b0fb9f0882..310074f678 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -204,7 +204,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push to GHCR if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: ./meteor/Dockerfile.circle @@ -215,7 +215,7 @@ jobs: github-token: ${{ github.token }} - name: Build and push to DockerHub if: steps.check-build-and-push.outputs.enable == 'true' && steps.dockerhub.outputs.dockerhub-publish == '1' - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: ./meteor/Dockerfile.circle @@ -354,7 +354,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push to GHCR if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: ./packages file: ./packages/${{ matrix.gateway-name }}/Dockerfile.circle @@ -364,7 +364,7 @@ jobs: tags: "${{ steps.ghcr-tag.outputs.tags }}" - name: Build and push to DockerHub if: steps.check-build-and-push.outputs.enable == 'true' && steps.dockerhub.outputs.dockerhub-publish == '1' - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: ./packages file: ./packages/${{ matrix.gateway-name }}/Dockerfile.circle From c2ed9b466cd1093138494ac59678ec63602fc96d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jun 2024 06:20:27 +0000 Subject: [PATCH 058/276] chore(deps): bump aquasecurity/trivy-action from 0.22.0 to 0.23.0 Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.22.0 to 0.23.0. - [Release notes](https://github.com/aquasecurity/trivy-action/releases) - [Commits](https://github.com/aquasecurity/trivy-action/compare/0.22.0...0.23.0) --- updated-dependencies: - dependency-name: aquasecurity/trivy-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/node.yaml | 4 ++-- .github/workflows/trivy.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index b0fb9f0882..cb720cf685 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -231,7 +231,7 @@ jobs: echo "image=$image" >> $GITHUB_OUTPUT - name: Trivy scanning if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: aquasecurity/trivy-action@0.22.0 + uses: aquasecurity/trivy-action@0.23.0 with: image-ref: "${{ steps.trivy-image.outputs.image }}" format: "table" @@ -380,7 +380,7 @@ jobs: echo "image=$image" >> $GITHUB_OUTPUT - name: Trivy scanning if: steps.check-build-and-push.outputs.enable == 'true' && steps.check-ghcr.outputs.enable == 'true' && steps.ghcr-tag.outputs.tags != 0 - uses: aquasecurity/trivy-action@0.22.0 + uses: aquasecurity/trivy-action@0.23.0 with: image-ref: "${{ steps.trivy-image.outputs.image }}" format: "table" diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index a29893e63e..2b289f4383 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -13,14 +13,14 @@ jobs: image: ["server-core", "playout-gateway", "mos-gateway"] steps: - name: Run Trivy vulnerability scanner (json) - uses: aquasecurity/trivy-action@0.22.0 + uses: aquasecurity/trivy-action@0.23.0 with: image-ref: ghcr.io/nrkno/sofie-core-${{ matrix.image }}:latest format: json output: '${{ matrix.image }}-trivy-scan-results.json' - name: Run Trivy vulnerability scanner (table) - uses: aquasecurity/trivy-action@0.22.0 + uses: aquasecurity/trivy-action@0.23.0 with: image-ref: ghcr.io/nrkno/sofie-core-${{ matrix.image }}:latest output: '${{ matrix.image }}-trivy-scan-results.txt' @@ -36,7 +36,7 @@ jobs: echo $CODE_BLOCK >> $GITHUB_STEP_SUMMARY - name: Run Trivy in GitHub SBOM mode and submit results to Dependency Graph - uses: aquasecurity/trivy-action@0.22.0 + uses: aquasecurity/trivy-action@0.23.0 with: format: 'github' output: 'dependency-results-${{ matrix.image }}.sbom.json' From 398ecd1a5a805922efb80f61e587cb9b81d98e18 Mon Sep 17 00:00:00 2001 From: ianshade Date: Thu, 27 Jun 2024 14:45:42 +0200 Subject: [PATCH 059/276] wip: turn partInstances into a custom uiPartInstances publication this publication, similarly to uiParts, contains some overrides applied to support QuickLoop TODO: apply these overrides during playout instead of modifying the actual part instances --- meteor/client/lib/__tests__/rundown.test.ts | 8 +- .../lib/__tests__/rundownTiming.test.ts | 35 --- meteor/client/lib/rundown.ts | 3 +- meteor/client/lib/rundownLayouts.ts | 5 +- meteor/client/lib/rundownPlaylistUtil.ts | 224 ++++++++++++++++- meteor/client/lib/rundownTiming.ts | 24 +- meteor/client/lib/viewPort.ts | 5 +- .../ui/ClockView/CameraScreen/index.tsx | 10 +- .../client/ui/ClockView/PresenterScreen.tsx | 8 +- meteor/client/ui/Collections.ts | 5 + meteor/client/ui/Prompter/PrompterView.tsx | 2 +- meteor/client/ui/Prompter/prompter.ts | 4 +- meteor/client/ui/RundownView.tsx | 24 +- .../RundownTiming/RundownTimingProvider.tsx | 50 +--- .../SegmentContainer/withResolvedSegment.ts | 4 +- .../ui/SegmentList/SegmentListContainer.tsx | 12 +- .../SegmentScratchpadContainer.tsx | 12 +- .../SegmentStoryboardContainer.tsx | 12 +- .../SegmentTimelineContainer.tsx | 5 +- .../TriggeredActionsEditor.tsx | 10 +- meteor/client/ui/Shelf/AdLibPanel.tsx | 20 +- meteor/client/ui/Shelf/BucketPanel.tsx | 6 +- meteor/client/ui/Shelf/ExternalFramePanel.tsx | 7 +- meteor/client/ui/Shelf/MiniRundownPanel.tsx | 10 +- meteor/client/ui/Shelf/NextInfoPanel.tsx | 5 +- meteor/client/ui/Shelf/PartNamePanel.tsx | 4 +- meteor/client/ui/Shelf/PartTimingPanel.tsx | 4 +- meteor/client/ui/Shelf/PlaylistNamePanel.tsx | 4 +- meteor/client/ui/Shelf/SegmentNamePanel.tsx | 9 +- meteor/client/ui/Shelf/SegmentTimingPanel.tsx | 8 +- meteor/lib/api/pubsub.ts | 13 + meteor/lib/collections/rundownPlaylistUtil.ts | 222 +---------------- meteor/server/publications/_publications.ts | 1 + meteor/server/publications/lib/quickLoop.ts | 106 ++++++++ .../partInstancesUI/publication.ts | 231 ++++++++++++++++++ .../partInstancesUI/reactiveContentCache.ts | 52 ++++ .../partInstancesUI/rundownContentObserver.ts | 79 ++++++ .../publications/partsUI/publication.ts | 117 ++------- meteor/server/publications/rundown.ts | 31 --- .../corelib/src/dataModel/RundownPlaylist.ts | 2 + packages/corelib/src/pubsub.ts | 19 +- .../src/playout/model/PlayoutModel.ts | 9 +- .../model/implementation/PlayoutModelImpl.ts | 18 ++ .../src/playout/timings/partPlayback.ts | 4 + 44 files changed, 867 insertions(+), 576 deletions(-) create mode 100644 meteor/server/publications/lib/quickLoop.ts create mode 100644 meteor/server/publications/partInstancesUI/publication.ts create mode 100644 meteor/server/publications/partInstancesUI/reactiveContentCache.ts create mode 100644 meteor/server/publications/partInstancesUI/rundownContentObserver.ts diff --git a/meteor/client/lib/__tests__/rundown.test.ts b/meteor/client/lib/__tests__/rundown.test.ts index ebce7d1e78..a97f02099a 100644 --- a/meteor/client/lib/__tests__/rundown.test.ts +++ b/meteor/client/lib/__tests__/rundown.test.ts @@ -48,7 +48,7 @@ describe('client/lib/rundown', () => { if (!playlist) throw new Error('Rundown not found') const { currentPartInstance, nextPartInstance } = - RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) + RundownPlaylistClientUtil.getSelectedPartInstances(playlist) const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) const { parts, segments } = RundownPlaylistClientUtil.getSegmentsAndPartsSync(playlist) @@ -92,7 +92,7 @@ describe('client/lib/rundown', () => { if (!playlist) throw new Error('Playlist not found') const { currentPartInstance, nextPartInstance } = - RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) + RundownPlaylistClientUtil.getSelectedPartInstances(playlist) const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) const { parts, segments } = RundownPlaylistClientUtil.getSegmentsAndPartsSync(playlist) @@ -165,7 +165,7 @@ describe('client/lib/rundown', () => { if (!playlist) throw new Error('Playlist not found') const { currentPartInstance, nextPartInstance } = - RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) + RundownPlaylistClientUtil.getSelectedPartInstances(playlist) const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) const { parts, segments } = RundownPlaylistClientUtil.getSegmentsAndPartsSync(playlist) @@ -364,7 +364,7 @@ describe('client/lib/rundown', () => { playlist = RundownPlaylists.findOne(playlistId) if (!playlist) throw new Error('Playlist not found') const { currentPartInstance, nextPartInstance } = - RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) + RundownPlaylistClientUtil.getSelectedPartInstances(playlist) const resolvedSegment = RundownUtils.getResolvedSegment( showStyleBase, diff --git a/meteor/client/lib/__tests__/rundownTiming.test.ts b/meteor/client/lib/__tests__/rundownTiming.test.ts index 27350beef8..d1029fd551 100644 --- a/meteor/client/lib/__tests__/rundownTiming.test.ts +++ b/meteor/client/lib/__tests__/rundownTiming.test.ts @@ -140,7 +140,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -167,7 +166,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: undefined, rundownsBeforeNextBreak: undefined, segmentBudgetDurations: {}, - segmentStartedPlayback: {}, }) ) }) @@ -206,7 +204,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -272,7 +269,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: undefined, rundownsBeforeNextBreak: undefined, segmentBudgetDurations: {}, - segmentStartedPlayback: {}, }) ) }) @@ -311,7 +307,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -377,7 +372,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: undefined, rundownsBeforeNextBreak: undefined, segmentBudgetDurations: {}, - segmentStartedPlayback: {}, }) ) }) @@ -418,7 +412,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -486,7 +479,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: undefined, rundownsBeforeNextBreak: undefined, segmentBudgetDurations: {}, - segmentStartedPlayback: {}, }) ) }) @@ -550,7 +542,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -616,7 +607,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: undefined, rundownsBeforeNextBreak: undefined, segmentBudgetDurations: {}, - segmentStartedPlayback: {}, }) ) }) @@ -708,7 +698,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -774,7 +763,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: 2500, rundownsBeforeNextBreak: [], segmentBudgetDurations: {}, - segmentStartedPlayback: {}, nextRundownAnchor: undefined, }) ) @@ -867,7 +855,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -933,7 +920,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: -4000, rundownsBeforeNextBreak: [], segmentBudgetDurations: {}, - segmentStartedPlayback: {}, nextRundownAnchor: undefined, }) ) @@ -975,7 +961,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_NONZERO_DURATION, - [], {} ) expect(result).toEqual( @@ -1041,7 +1026,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: undefined, rundownsBeforeNextBreak: undefined, segmentBudgetDurations: {}, - segmentStartedPlayback: {}, }) ) }) @@ -1109,7 +1093,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_NONZERO_DURATION, - [], {} ) expect(result).toEqual( @@ -1182,7 +1165,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: undefined, rundownsBeforeNextBreak: undefined, segmentBudgetDurations: {}, - segmentStartedPlayback: {}, }) ) }) @@ -1237,7 +1219,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -1306,7 +1287,6 @@ describe('rundown Timing Calculator', () => { [segmentId1]: 5000, [segmentId2]: 3000, }, - segmentStartedPlayback: {}, }) ) }) @@ -1364,7 +1344,6 @@ describe('rundown Timing Calculator', () => { piecesMap, segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -1430,7 +1409,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: undefined, rundownsBeforeNextBreak: undefined, segmentBudgetDurations: {}, - segmentStartedPlayback: {}, nextRundownAnchor: undefined, }) ) @@ -1516,7 +1494,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -1582,7 +1559,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: 500, rundownsBeforeNextBreak: [], segmentBudgetDurations: {}, - segmentStartedPlayback: {}, nextRundownAnchor: 2000, }) ) @@ -1668,7 +1644,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -1734,7 +1709,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: -1500, rundownsBeforeNextBreak: [], segmentBudgetDurations: {}, - segmentStartedPlayback: {}, nextRundownAnchor: 4000, }) ) @@ -1826,7 +1800,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -1892,7 +1865,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: 500, rundownsBeforeNextBreak: [], segmentBudgetDurations: {}, - segmentStartedPlayback: {}, nextRundownAnchor: 3000, }) ) @@ -1978,7 +1950,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -2044,7 +2015,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: 500, rundownsBeforeNextBreak: [], segmentBudgetDurations: {}, - segmentStartedPlayback: {}, nextRundownAnchor: 2000, }) ) @@ -2130,7 +2100,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -2196,7 +2165,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: -1500, rundownsBeforeNextBreak: [], segmentBudgetDurations: {}, - segmentStartedPlayback: {}, nextRundownAnchor: 4000, }) ) @@ -2288,7 +2256,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], {} ) expect(result).toEqual( @@ -2354,7 +2321,6 @@ describe('rundown Timing Calculator', () => { remainingTimeOnCurrentPart: 500, rundownsBeforeNextBreak: [], segmentBudgetDurations: {}, - segmentStartedPlayback: {}, nextRundownAnchor: 3000, }) ) @@ -2389,7 +2355,6 @@ describe('rundown Timing Calculator', () => { new Map(), segmentsMap, DEFAULT_DURATION, - [], { part2: true, part3: true, diff --git a/meteor/client/lib/rundown.ts b/meteor/client/lib/rundown.ts index c721198457..1b3fab104c 100644 --- a/meteor/client/lib/rundown.ts +++ b/meteor/client/lib/rundown.ts @@ -47,7 +47,6 @@ import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { assertNever } from '@sofie-automation/shared-lib/dist/lib/lib' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { RundownPlaylistCollectionUtil } from '../../lib/collections/rundownPlaylistUtil' import { RundownPlaylistClientUtil } from './rundownPlaylistUtil' export namespace RundownUtils { @@ -835,7 +834,7 @@ export namespace RundownUtils { segmentsOptions, partsOptions ) - const rawPartInstances = RundownPlaylistCollectionUtil.getActivePartInstances( + const rawPartInstances = RundownPlaylistClientUtil.getActivePartInstances( playlist, partInstancesQuery, partInstancesOptions diff --git a/meteor/client/lib/rundownLayouts.ts b/meteor/client/lib/rundownLayouts.ts index 3bd7d6cb89..c528e25298 100644 --- a/meteor/client/lib/rundownLayouts.ts +++ b/meteor/client/lib/rundownLayouts.ts @@ -7,8 +7,9 @@ import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/Rund import { getCurrentTime } from '../../lib/lib' import { invalidateAt } from './../../lib/invalidatingTime' import { memoizedIsolatedAutorun } from '../../lib/memoizedIsolatedAutorun' -import { PartInstances, PieceInstances } from '../collections' +import { PieceInstances } from '../collections' import { ReadonlyDeep } from 'type-fest' +import { UIPartInstances } from '../ui/Collections' /** * If the conditions of the filter are met, activePieceInstance will include the first piece instance found that matches the filter, otherwise it will be undefined. @@ -65,7 +66,7 @@ export function getUnfinishedPieceInstancesReactive( const now = getCurrentTime() let prospectivePieces: ReadonlyDeep[] = [] - const partInstance = PartInstances.findOne(currentPartInstanceId) + const partInstance = UIPartInstances.findOne(currentPartInstanceId) if (partInstance) { prospectivePieces = PieceInstances.find({ diff --git a/meteor/client/lib/rundownPlaylistUtil.ts b/meteor/client/lib/rundownPlaylistUtil.ts index 70b9b1185f..51eef5c36e 100644 --- a/meteor/client/lib/rundownPlaylistUtil.ts +++ b/meteor/client/lib/rundownPlaylistUtil.ts @@ -2,11 +2,227 @@ import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { FindOptions, MongoQuery } from '@sofie-automation/corelib/dist/mongo' import { RundownPlaylistCollectionUtil } from '../../lib/collections/rundownPlaylistUtil' -import { UIParts } from '../ui/Collections' +import { UIPartInstances, UIParts } from '../ui/Collections' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { Segments } from '../collections' +import { Pieces, Segments } from '../collections' +import { DBRundown, Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { RundownId, PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' +import { normalizeArrayFunc, groupByToMap } from '@sofie-automation/corelib/dist/lib' +import { + sortSegmentsInRundowns, + sortPartsInSegments, + sortPartsInSortedSegments, +} from '@sofie-automation/corelib/dist/playout/playlist' +import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import { PartInstance } from '../../lib/collections/PartInstances' +import * as _ from 'underscore' export class RundownPlaylistClientUtil { + /** Returns all segments joined with their rundowns in their correct oreder for this RundownPlaylist */ + static getRundownsAndSegments( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): Array<{ + rundown: Pick< + Rundown, + | '_id' + | 'name' + | 'playlistId' + | 'timing' + | 'showStyleBaseId' + | 'showStyleVariantId' + | 'endOfRundownIsShowBreak' + > + segments: DBSegment[] + }> { + const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist, undefined, { + fields: { + name: 1, + playlistId: 1, + timing: 1, + showStyleBaseId: 1, + showStyleVariantId: 1, + endOfRundownIsShowBreak: 1, + }, + }) + const segments = Segments.find( + { + rundownId: { + $in: rundowns.map((i) => i._id), + }, + ...selector, + }, + { + sort: { + rundownId: 1, + _rank: 1, + }, + ...options, + } + ).fetch() + return RundownPlaylistClientUtil._matchSegmentsAndRundowns(segments, rundowns) + } + /** Returns all segments in their correct order for this RundownPlaylist */ + static getSegments( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): DBSegment[] { + const rundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) + const segments = Segments.find( + { + rundownId: { + $in: rundownIds, + }, + ...selector, + }, + { + sort: { + rundownId: 1, + _rank: 1, + }, + ...options, + } + ).fetch() + return RundownPlaylistClientUtil._sortSegments(segments, playlist) + } + + static getSelectedPartInstances( + playlist: Pick, + rundownIds0?: RundownId[] + ): { + currentPartInstance: PartInstance | undefined + nextPartInstance: PartInstance | undefined + previousPartInstance: PartInstance | undefined + } { + let unorderedRundownIds = rundownIds0 + if (!unorderedRundownIds) { + unorderedRundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) + } + + const ids = _.compact([ + playlist.currentPartInfo?.partInstanceId, + playlist.previousPartInfo?.partInstanceId, + playlist.nextPartInfo?.partInstanceId, + ]) + const instances = + ids.length > 0 + ? UIPartInstances.find({ + rundownId: { $in: unorderedRundownIds }, + _id: { $in: ids }, + reset: { $ne: true }, + }).fetch() + : [] + + return { + currentPartInstance: instances.find((inst) => inst._id === playlist.currentPartInfo?.partInstanceId), + nextPartInstance: instances.find((inst) => inst._id === playlist.nextPartInfo?.partInstanceId), + previousPartInstance: instances.find((inst) => inst._id === playlist.previousPartInfo?.partInstanceId), + } + } + + static getAllPartInstances( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): PartInstance[] { + const unorderedRundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) + + return UIPartInstances.find( + { + rundownId: { $in: unorderedRundownIds }, + ...selector, + }, + { + sort: { takeCount: 1 }, + ...options, + } + ).fetch() + } + /** Return a list of PartInstances, omitting the reset ones (ie only the ones that are relevant) */ + static getActivePartInstances( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): PartInstance[] { + const newSelector: MongoQuery = { + ...selector, + reset: { $ne: true }, + } + return RundownPlaylistClientUtil.getAllPartInstances(playlist, newSelector, options) + } + static getActivePartInstancesMap( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): Record { + const instances = RundownPlaylistClientUtil.getActivePartInstances(playlist, selector, options) + return normalizeArrayFunc(instances, (i) => unprotectString(i.part._id)) + } + static getPiecesForParts( + parts: Array, + piecesOptions?: Omit, 'projection'> // We are mangling fields, so block projection + ): Map { + const allPieces = Pieces.find( + { startPartId: { $in: parts } }, + { + ...piecesOptions, + //@ts-expect-error This is too clever for the compiler + fields: piecesOptions?.fields + ? { + ...piecesOptions?.fields, + startPartId: 1, + } + : undefined, + } + ).fetch() + return groupByToMap(allPieces, 'startPartId') + } + + static _sortSegments>( + segments: Array, + playlist: Pick + ): TSegment[] { + return sortSegmentsInRundowns(segments, playlist.rundownIdsInOrder) + } + static _matchSegmentsAndRundowns( + segments: E[], + rundowns: T[] + ): Array<{ rundown: T; segments: E[] }> { + const rundownsMap = new Map< + RundownId, + { + rundown: T + segments: E[] + } + >() + rundowns.forEach((rundown) => { + rundownsMap.set(rundown._id, { + rundown, + segments: [], + }) + }) + segments.forEach((segment) => { + rundownsMap.get(segment.rundownId)?.segments.push(segment) + }) + return Array.from(rundownsMap.values()) + } + static _sortParts( + parts: DBPart[], + playlist: Pick, + segments: Array> + ): DBPart[] { + return sortPartsInSegments(parts, playlist.rundownIdsInOrder, segments) + } + static _sortPartsInner

>( + parts: P[], + sortedSegments: Array> + ): P[] { + return sortPartsInSortedSegments(parts, sortedSegments) + } + static getUnorderedParts( playlist: Pick, selector?: MongoQuery, @@ -90,10 +306,10 @@ export class RundownPlaylistClientUtil { } ).fetch() - const sortedSegments = RundownPlaylistCollectionUtil._sortSegments(segments, playlist) + const sortedSegments = RundownPlaylistClientUtil._sortSegments(segments, playlist) return { segments: sortedSegments, - parts: RundownPlaylistCollectionUtil._sortPartsInner(parts, sortedSegments), + parts: RundownPlaylistClientUtil._sortPartsInner(parts, sortedSegments), } } } diff --git a/meteor/client/lib/rundownTiming.ts b/meteor/client/lib/rundownTiming.ts index 23fbd095d9..c0d082b197 100644 --- a/meteor/client/lib/rundownTiming.ts +++ b/meteor/client/lib/rundownTiming.ts @@ -68,7 +68,6 @@ export class RundownTimingCalculator { private partDisplayDurationsNoPlayback: Record = {} private displayDurationGroups: Record = {} private segmentBudgetDurations: Record = {} - private segmentStartedPlayback: Record = {} private segmentAsPlayedDurations: Record = {} private breakProps: { props: BreakProps | undefined @@ -90,7 +89,6 @@ export class RundownTimingCalculator { * @param {CalculateTimingsPartInstance[]} partInstances * @param {Map} partInstancesMap * @param {number} [defaultDuration] - * @param {CalculateTimingsPartInstance[]} segmentEntryPartInstances * @return {*} {RundownTimingContext} * @memberof RundownTimingCalculator */ @@ -106,13 +104,6 @@ export class RundownTimingCalculator { segmentsMap: Map, /** Fallback duration for Parts that have no as-played duration of their own. */ defaultDuration: number = Settings.defaultDisplayDuration, - /** The first played-out PartInstance in the current playing segment and - * optionally the first played-out PartInstance in the previously playing segment if the - * previousPartInstance of the current RundownPlaylist is from a different Segment than - * the currentPartInstance. - * - * This is being used for calculating Segment Duration Budget */ - segmentEntryPartInstances: CalculateTimingsPartInstance[], partsInQuickLoop: Record ): RundownTimingContext { let totalRundownDuration = 0 @@ -135,7 +126,6 @@ export class RundownTimingCalculator { Object.keys(this.displayDurationGroups).forEach((key) => delete this.displayDurationGroups[key]) Object.keys(this.segmentBudgetDurations).forEach((key) => delete this.segmentBudgetDurations[key]) - Object.keys(this.segmentStartedPlayback).forEach((key) => delete this.segmentStartedPlayback[key]) Object.keys(this.segmentAsPlayedDurations).forEach((key) => delete this.segmentAsPlayedDurations[key]) this.untimedSegments.clear() this.linearParts.length = 0 @@ -170,12 +160,6 @@ export class RundownTimingCalculator { } }) - segmentEntryPartInstances.forEach((partInstance) => { - if (partInstance.timings?.reportedStartedPlayback !== undefined) - this.segmentStartedPlayback[unprotectString(partInstance.segmentId)] = - partInstance.timings?.reportedStartedPlayback - }) - partInstances.forEach((partInstance, itIndex) => { const partId = partInstance.part._id const partInstanceId = !partInstance.isTemporary ? partInstance._id : null @@ -303,7 +287,8 @@ export class RundownTimingCalculator { partDisplayDuration = Math.max(partDisplayDurationNoPlayback, now - lastStartedPlayback) this.partPlayed[partInstanceOrPartId] = now - lastStartedPlayback const segmentStartedPlayback = - this.segmentStartedPlayback[unprotectString(partInstance.segmentId)] || lastStartedPlayback + playlist.segmentsStartedPlayback?.[unprotectString(partInstance.segmentId)] ?? + lastStartedPlayback // NOTE: displayDurationGroups are ignored here, when using budgetDuration if (segmentUsesBudget) { @@ -584,7 +569,7 @@ export class RundownTimingCalculator { let valToAddToRundownAsPlayedDuration = 0 let valToAddToRundownRemainingDuration = 0 if (segment._id === liveSegmentId) { - const startedPlayback = this.segmentStartedPlayback[unprotectString(segment._id)] + const startedPlayback = playlist.segmentsStartedPlayback?.[unprotectString(segment._id)] valToAddToRundownRemainingDuration = Math.max( 0, segmentBudgetDuration - (startedPlayback ? now - startedPlayback : 0) @@ -653,7 +638,6 @@ export class RundownTimingCalculator { partExpectedDurations: this.partExpectedDurations, partDisplayDurations: this.partDisplayDurations, segmentBudgetDurations: this.segmentBudgetDurations, - segmentStartedPlayback: this.segmentStartedPlayback, currentTime: now, remainingTimeOnCurrentPart, currentPartWillAutoNext, @@ -747,8 +731,6 @@ export interface RundownTimingContext { partExpectedDurations?: Record /** Budget durations of segments (sum of parts budget durations). */ segmentBudgetDurations?: Record - /** Time when selected segments started playback. Contains only the current segment and the segment before, if we've just entered a new one */ - segmentStartedPlayback?: Record /** Remaining time on current part */ remainingTimeOnCurrentPart?: number /** Current part will autoNext */ diff --git a/meteor/client/lib/viewPort.ts b/meteor/client/lib/viewPort.ts index 0a9e7f9a83..bb62e1063d 100644 --- a/meteor/client/lib/viewPort.ts +++ b/meteor/client/lib/viewPort.ts @@ -3,9 +3,8 @@ import { isProtectedString } from '../../lib/lib' import RundownViewEventBus, { RundownViewEvents } from '../../lib/api/triggers/RundownViewEventBus' import { Settings } from '../../lib/Settings' import { PartId, PartInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PartInstances } from '../collections' import { logger } from '../../lib/logging' -import { UIParts } from '../ui/Collections' +import { UIPartInstances, UIParts } from '../ui/Collections' const HEADER_MARGIN = 24 // TODOSYNC: TV2 uses 15. If it's needed to be different, it needs to be made generic somehow.. const FALLBACK_HEADER_HEIGHT = 65 @@ -55,7 +54,7 @@ export async function scrollToPartInstance( noAnimation?: boolean ): Promise { quitFocusOnPart() - const partInstance = PartInstances.findOne(partInstanceId) + const partInstance = UIPartInstances.findOne(partInstanceId) if (partInstance) { RundownViewEventBus.emit(RundownViewEvents.GO_TO_PART_INSTANCE, { segmentId: partInstance.segmentId, diff --git a/meteor/client/ui/ClockView/CameraScreen/index.tsx b/meteor/client/ui/ClockView/CameraScreen/index.tsx index 029395b67e..a68cc91dcf 100644 --- a/meteor/client/ui/ClockView/CameraScreen/index.tsx +++ b/meteor/client/ui/ClockView/CameraScreen/index.tsx @@ -7,9 +7,9 @@ import { MeteorPubSub } from '../../../../lib/api/pubsub' import { UIStudio } from '../../../../lib/api/studios' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { PieceExtended } from '../../../../lib/Rundown' -import { PartInstances, Rundowns } from '../../../collections' +import { Rundowns } from '../../../collections' import { useSubscription, useTracker } from '../../../lib/ReactMeteorData/ReactMeteorData' -import { UIStudios } from '../../Collections' +import { UIPartInstances, UIStudios } from '../../Collections' import { Rundown as RundownComponent } from './Rundown' import { useLocation } from 'react-router-dom' import { parse as queryStringParse } from 'query-string' @@ -100,7 +100,7 @@ export function CameraScreen({ playlist, studioId }: Readonly): JSX.Elem useSubscription(CorelibPubSub.segments, rundownIds, {}) const studioReady = useSubscription(MeteorPubSub.uiStudio, studioId) - useSubscription(CorelibPubSub.partInstances, rundownIds, playlist?.activationId ?? null) + useSubscription(MeteorPubSub.uiPartInstances, rundownIds, playlist?.activationId ?? null) useSubscription(CorelibPubSub.parts, rundownIds, null) @@ -116,7 +116,7 @@ export function CameraScreen({ playlist, studioId }: Readonly): JSX.Elem const currentPartInstanceVolatile = useTracker( () => playlist?.currentPartInfo?.partInstanceId - ? PartInstances.findOne(playlist?.currentPartInfo?.partInstanceId) + ? UIPartInstances.findOne(playlist?.currentPartInfo?.partInstanceId) : undefined, [playlist?.currentPartInfo?.partInstanceId], undefined @@ -124,7 +124,7 @@ export function CameraScreen({ playlist, studioId }: Readonly): JSX.Elem const nextPartInstanceVolatile = useTracker( () => playlist?.nextPartInfo?.partInstanceId - ? PartInstances.findOne(playlist?.nextPartInfo?.partInstanceId) + ? UIPartInstances.findOne(playlist?.nextPartInfo?.partInstanceId) : undefined, [playlist?.nextPartInfo?.partInstanceId], undefined diff --git a/meteor/client/ui/ClockView/PresenterScreen.tsx b/meteor/client/ui/ClockView/PresenterScreen.tsx index 34f0328044..c6b1d9d1cf 100644 --- a/meteor/client/ui/ClockView/PresenterScreen.tsx +++ b/meteor/client/ui/ClockView/PresenterScreen.tsx @@ -200,14 +200,14 @@ export const getPresenterScreenReactive = (props: PresenterScreenProps): Present if (playlist) { rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) const orderedSegmentsAndParts = RundownPlaylistClientUtil.getSegmentsAndPartsSync(playlist) - pieces = RundownPlaylistCollectionUtil.getPiecesForParts(orderedSegmentsAndParts.parts.map((p) => p._id)) + pieces = RundownPlaylistClientUtil.getPiecesForParts(orderedSegmentsAndParts.parts.map((p) => p._id)) rundownIds = rundowns.map((rundown) => rundown._id) const rundownsToShowstyles: Map = new Map() for (const rundown of rundowns) { rundownsToShowstyles.set(rundown._id, rundown.showStyleBaseId) } showStyleBaseIds = rundowns.map((rundown) => rundown.showStyleBaseId) - const { currentPartInstance, nextPartInstance } = RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) + const { currentPartInstance, nextPartInstance } = RundownPlaylistClientUtil.getSelectedPartInstances(playlist) const partInstance = currentPartInstance ?? nextPartInstance if (partInstance) { // This is to register a reactive dependency on Rundown-spanning PieceInstances, that we may miss otherwise. @@ -350,7 +350,7 @@ export function usePresenterScreenSubscriptions(props: PresenterScreenProps): vo useSubscription(CorelibPubSub.segments, rundownIds, {}) useSubscription(CorelibPubSub.parts, rundownIds, null) - useSubscription(CorelibPubSub.partInstances, rundownIds, playlist?.activationId ?? null) + useSubscription(MeteorPubSub.uiPartInstances, rundownIds, playlist?.activationId ?? null) useSubscriptions( MeteorPubSub.uiShowStyleBase, showStyleBaseIds.map((id) => [id]) @@ -370,7 +370,7 @@ export function usePresenterScreenSubscriptions(props: PresenterScreenProps): vo }) as Pick | undefined if (playlist) { - return RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) + return RundownPlaylistClientUtil.getSelectedPartInstances(playlist) } else { return { currentPartInstance: undefined, nextPartInstance: undefined, previousPartInstance: undefined } } diff --git a/meteor/client/ui/Collections.ts b/meteor/client/ui/Collections.ts index 9af81dfe73..cfbecc7e84 100644 --- a/meteor/client/ui/Collections.ts +++ b/meteor/client/ui/Collections.ts @@ -43,6 +43,11 @@ export const UIPieceContentStatuses = createSyncCustomPublicationMongoCollection */ export const UIParts = createSyncCustomPublicationMongoCollection(CustomCollectionName.UIParts) +/** + * A playout UI version of PartInstances. + */ +export const UIPartInstances = createSyncCustomPublicationMongoCollection(CustomCollectionName.UIPartInstances) + /** * Pre-processed MediaObjectIssue for Adlibbs in a Bucket */ diff --git a/meteor/client/ui/Prompter/PrompterView.tsx b/meteor/client/ui/Prompter/PrompterView.tsx index 792e50272d..5fe804003f 100644 --- a/meteor/client/ui/Prompter/PrompterView.tsx +++ b/meteor/client/ui/Prompter/PrompterView.tsx @@ -592,7 +592,7 @@ function Prompter(props: Readonly>): JSX.Eleme const rundownIDs = playlist ? RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) : [] useSubscription(CorelibPubSub.segments, rundownIDs, {}) useSubscription(MeteorPubSub.uiParts, props.rundownPlaylistId) - useSubscription(CorelibPubSub.partInstances, rundownIDs, playlist?.activationId ?? null) + useSubscription(MeteorPubSub.uiPartInstances, rundownIDs, playlist?.activationId ?? null) useSubscription(CorelibPubSub.pieces, rundownIDs, null) useSubscription(CorelibPubSub.pieceInstancesSimple, rundownIDs, null) diff --git a/meteor/client/ui/Prompter/prompter.ts b/meteor/client/ui/Prompter/prompter.ts index 347a81883f..981f18eb60 100644 --- a/meteor/client/ui/Prompter/prompter.ts +++ b/meteor/client/ui/Prompter/prompter.ts @@ -23,6 +23,7 @@ import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyle import { RundownPlaylistCollectionUtil } from '../../../lib/collections/rundownPlaylistUtil' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { RundownUtils } from '../../lib/rundown' +import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' // export interface NewPrompterAPI { // getPrompterData (playlistId: RundownPlaylistId): Promise @@ -73,8 +74,7 @@ export namespace PrompterAPI { } const rundownMap = normalizeArrayToMap(rundowns, '_id') - const { currentPartInstance, nextPartInstance } = - RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) + const { currentPartInstance, nextPartInstance } = RundownPlaylistClientUtil.getSelectedPartInstances(playlist) const currentSegment = currentPartInstance ? (Segments.findOne(currentPartInstance?.segmentId, { diff --git a/meteor/client/ui/RundownView.tsx b/meteor/client/ui/RundownView.tsx index 469b1f5b8e..41283600ac 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/meteor/client/ui/RundownView.tsx @@ -158,6 +158,7 @@ import { i18nTranslator } from './i18n' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { isEntirePlaylistLooping, isLoopRunning } from '../../lib/Rundown' import { useRundownAndShowStyleIdsForPlaylist } from './util/useRundownAndShowStyleIdsForPlaylist' +import { RundownPlaylistClientUtil } from '../lib/rundownPlaylistUtil' export const MAGIC_TIME_SCALE_FACTOR = 0.03 @@ -1280,7 +1281,7 @@ export function RundownView(props: Readonly): JSX.Element { auxSubsReady.push(useSubscriptionIfEnabled(MeteorPubSub.uiParts, rundownIds.length > 0, playlistId)) auxSubsReady.push( useSubscriptionIfEnabled( - CorelibPubSub.partInstances, + MeteorPubSub.uiPartInstances, rundownIds.length > 0, rundownIds, playlistActivationId ?? null @@ -1310,23 +1311,6 @@ export function RundownView(props: Readonly): JSX.Element { ].filter((p): p is PartInstanceId => p !== null), {} ) - const { previousPartInstance, currentPartInstance } = - RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) - - if (previousPartInstance) { - meteorSubscribe( - CorelibPubSub.partInstancesForSegmentPlayout, - previousPartInstance.rundownId, - previousPartInstance.segmentPlayoutId - ) - } - if (currentPartInstance) { - meteorSubscribe( - CorelibPubSub.partInstancesForSegmentPlayout, - currentPartInstance.rundownId, - currentPartInstance.segmentPlayoutId - ) - } } }, [playlistId]) @@ -1359,7 +1343,7 @@ const RundownViewContent = translateWithTracker rundown._id === somePartInstance?.rundownId) @@ -1391,7 +1375,7 @@ const RundownViewContent = translateWithTracker ({ + ? RundownPlaylistClientUtil.getRundownsAndSegments(playlist, {}).map((input, rundownIndex, rundownArray) => ({ ...input, segmentIdsBeforeEachSegment: input.segments.map( (_segment, segmentIndex, segmentArray) => diff --git a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index a7901223df..660e957e54 100644 --- a/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/meteor/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -55,7 +55,6 @@ interface IRundownTimingProviderTrackedProps { partInstances: Array partInstancesMap: Map pieces: Map - segmentEntryPartInstances: MinimalPartInstance[] segments: DBSegment[] segmentsMap: Map partsInQuickLoop: Record @@ -79,7 +78,6 @@ export const RundownTimingProvider = withTracker< partInstances: [], partInstancesMap: new Map(), pieces: new Map(), - segmentEntryPartInstances: [], segments: [], segmentsMap: new Map(), partsInQuickLoop: {}, @@ -87,13 +85,12 @@ export const RundownTimingProvider = withTracker< } const partInstancesMap = new Map() - const segmentEntryPartInstances: MinimalPartInstance[] = [] const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) - const segments = RundownPlaylistCollectionUtil.getSegments(playlist) + const segments = RundownPlaylistClientUtil.getSegments(playlist) const segmentsMap = new Map(segments.map((segment) => [segment._id, segment])) const unorderedParts = RundownPlaylistClientUtil.getUnorderedParts(playlist) - const activePartInstances = RundownPlaylistCollectionUtil.getActivePartInstances(playlist, undefined, { + const activePartInstances = RundownPlaylistClientUtil.getActivePartInstances(playlist, undefined, { projection: { _id: 1, rundownId: 1, @@ -120,7 +117,7 @@ export const RundownTimingProvider = withTracker< > > - const { currentPartInstance, previousPartInstance } = findCurrentAndPreviousPartInstance( + const { currentPartInstance } = findCurrentAndPreviousPartInstance( activePartInstances, playlist.currentPartInfo?.partInstanceId, playlist.previousPartInfo?.partInstanceId @@ -129,35 +126,12 @@ export const RundownTimingProvider = withTracker< const currentRundown = currentPartInstance ? rundowns.find((r) => r._id === currentPartInstance.rundownId) : rundowns[0] - // These are needed to retrieve the start time of a segment for calculating the remaining budget, in case the first partInstance was removed - - let firstPartInstanceInCurrentSegmentPlay: MinimalPartInstance | undefined - let firstPartInstanceInCurrentSegmentPlayPartInstanceTakeCount = Number.POSITIVE_INFINITY - let firstPartInstanceInPreviousSegmentPlay: MinimalPartInstance | undefined - let firstPartInstanceInPreviousSegmentPlayPartInstanceTakeCount = Number.POSITIVE_INFINITY let partInstances: MinimalPartInstance[] = [] const allPartIds: Set = new Set() for (const partInstance of activePartInstances) { - if ( - currentPartInstance && - partInstance.segmentPlayoutId === currentPartInstance.segmentPlayoutId && - partInstance.takeCount < firstPartInstanceInCurrentSegmentPlayPartInstanceTakeCount - ) { - firstPartInstanceInCurrentSegmentPlay = partInstance - firstPartInstanceInCurrentSegmentPlayPartInstanceTakeCount = partInstance.takeCount - } - if ( - previousPartInstance && - partInstance.segmentPlayoutId === previousPartInstance.segmentPlayoutId && - partInstance.takeCount < firstPartInstanceInPreviousSegmentPlayPartInstanceTakeCount - ) { - firstPartInstanceInPreviousSegmentPlay = partInstance - firstPartInstanceInPreviousSegmentPlayPartInstanceTakeCount = partInstance.takeCount - } - allPartIds.add(partInstance.part._id) } @@ -174,10 +148,7 @@ export const RundownTimingProvider = withTracker< const partsInQuickLoop = findPartInstancesInQuickLoop(playlist, partInstances) - if (firstPartInstanceInCurrentSegmentPlay) segmentEntryPartInstances.push(firstPartInstanceInCurrentSegmentPlay) - if (firstPartInstanceInPreviousSegmentPlay) segmentEntryPartInstances.push(firstPartInstanceInPreviousSegmentPlay) - - const pieces = RundownPlaylistCollectionUtil.getPiecesForParts(Array.from(allPartIds.values())) + const pieces = RundownPlaylistClientUtil.getPiecesForParts(Array.from(allPartIds.values())) return { rundowns, @@ -185,7 +156,6 @@ export const RundownTimingProvider = withTracker< partInstances, partInstancesMap, pieces, - segmentEntryPartInstances, segments, segmentsMap, partsInQuickLoop, @@ -318,16 +288,7 @@ export const RundownTimingProvider = withTracker< } updateDurations(now: number, isSynced: boolean) { - const { - playlist, - rundowns, - currentRundown, - partInstances, - partInstancesMap, - pieces, - segmentsMap, - segmentEntryPartInstances, - } = this.props + const { playlist, rundowns, currentRundown, partInstances, partInstancesMap, pieces, segmentsMap } = this.props const updatedDurations = this.timingCalculator.updateDurations( now, isSynced, @@ -339,7 +300,6 @@ export const RundownTimingProvider = withTracker< pieces, segmentsMap, this.props.defaultDuration, - segmentEntryPartInstances, this.props.partsInQuickLoop ) if (!isSynced) { diff --git a/meteor/client/ui/SegmentContainer/withResolvedSegment.ts b/meteor/client/ui/SegmentContainer/withResolvedSegment.ts index 7c0b142a6c..5305c7f465 100644 --- a/meteor/client/ui/SegmentContainer/withResolvedSegment.ts +++ b/meteor/client/ui/SegmentContainer/withResolvedSegment.ts @@ -191,7 +191,7 @@ export function withResolvedSegment - RundownPlaylistCollectionUtil.getSelectedPartInstances(props.playlist), + RundownPlaylistClientUtil.getSelectedPartInstances(props.playlist), 'playlist.getSelectedPartInstances', props.playlist._id, props.playlist.currentPartInfo?.partInstanceId, @@ -214,7 +214,7 @@ export function withResolvedSegment { - return RundownPlaylistCollectionUtil.getPiecesForParts(orderedParts, { + return RundownPlaylistClientUtil.getPiecesForParts(orderedParts, { fields: { enable: 1, prerollDuration: 1, postrollDuration: 1, pieceType: 1 }, }) }, diff --git a/meteor/client/ui/SegmentList/SegmentListContainer.tsx b/meteor/client/ui/SegmentList/SegmentListContainer.tsx index 195e026026..73afb1b539 100644 --- a/meteor/client/ui/SegmentList/SegmentListContainer.tsx +++ b/meteor/client/ui/SegmentList/SegmentListContainer.tsx @@ -10,9 +10,9 @@ import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { SegmentList } from './SegmentList' import { unprotectString } from '../../../lib/lib' import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/SegmentTimelineContainer' -import { PartInstances, Segments } from '../../collections' +import { Segments } from '../../collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { UIParts } from '../Collections' +import { UIPartInstances, UIParts } from '../Collections' export const LIVELINE_HISTORY_SIZE = TIMELINE_LIVELINE_HISTORY_SIZE @@ -46,7 +46,7 @@ export const SegmentListContainer = withResolvedSegment(function Segment const partInstanceIds = useTracker( () => - PartInstances.find( + UIPartInstances.find( { segmentId: segmentId, reset: { @@ -87,7 +87,7 @@ export const SegmentListContainer = withResolvedSegment(function Segment return false } - const currentPartInstance = PartInstances.findOne(props.playlist.currentPartInfo.partInstanceId) + const currentPartInstance = UIPartInstances.findOne(props.playlist.currentPartInfo.partInstanceId) if (!currentPartInstance) { return false } @@ -104,7 +104,7 @@ export const SegmentListContainer = withResolvedSegment(function Segment return false } - const partInstance = PartInstances.findOne(props.playlist.nextPartInfo.partInstanceId, { + const partInstance = UIPartInstances.findOne(props.playlist.nextPartInfo.partInstanceId, { fields: { segmentId: 1, 'part._id': 1, @@ -126,7 +126,7 @@ export const SegmentListContainer = withResolvedSegment(function Segment return false } - const currentPartInstance = PartInstances.findOne(props.playlist.currentPartInfo.partInstanceId, { + const currentPartInstance = UIPartInstances.findOne(props.playlist.currentPartInfo.partInstanceId, { fields: { 'part.autoNext': 1, 'part.expectedDuration': 1, diff --git a/meteor/client/ui/SegmentScratchpad/SegmentScratchpadContainer.tsx b/meteor/client/ui/SegmentScratchpad/SegmentScratchpadContainer.tsx index 14893f21fc..a2c1f4ac47 100644 --- a/meteor/client/ui/SegmentScratchpad/SegmentScratchpadContainer.tsx +++ b/meteor/client/ui/SegmentScratchpad/SegmentScratchpadContainer.tsx @@ -11,12 +11,12 @@ import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { SegmentScratchpad } from './SegmentScratchpad' import { unprotectString } from '../../../lib/lib' import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/SegmentTimelineContainer' -import { PartInstances, Segments } from '../../collections' +import { Segments } from '../../collections' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' import { MongoFieldSpecifierOnes } from '@sofie-automation/corelib/dist/mongo' import { PartInstance } from '../../../lib/collections/PartInstances' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { UIParts } from '../Collections' +import { UIPartInstances, UIParts } from '../Collections' export const LIVELINE_HISTORY_SIZE = TIMELINE_LIVELINE_HISTORY_SIZE @@ -50,7 +50,7 @@ export const SegmentScratchpadContainer = withResolvedSegment(function S const partInstanceIds = useTracker( () => - PartInstances.find( + UIPartInstances.find( { segmentId: segmentId, reset: { @@ -91,7 +91,7 @@ export const SegmentScratchpadContainer = withResolvedSegment(function S return false } - const currentPartInstance = PartInstances.findOne(props.playlist.currentPartInfo.partInstanceId) + const currentPartInstance = UIPartInstances.findOne(props.playlist.currentPartInfo.partInstanceId) if (!currentPartInstance) { return false } @@ -108,7 +108,7 @@ export const SegmentScratchpadContainer = withResolvedSegment(function S return false } - const partInstance = PartInstances.findOne(props.playlist.nextPartInfo.partInstanceId, { + const partInstance = UIPartInstances.findOne(props.playlist.nextPartInfo.partInstanceId, { fields: literal>({ segmentId: 1, //@ts-expect-error typescript doesnt like it @@ -131,7 +131,7 @@ export const SegmentScratchpadContainer = withResolvedSegment(function S return false } - const currentPartInstance = PartInstances.findOne(props.playlist.currentPartInfo.partInstanceId, { + const currentPartInstance = UIPartInstances.findOne(props.playlist.currentPartInfo.partInstanceId, { fields: { //@ts-expect-error deep property 'part.autoNext': 1, diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx b/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx index 0ea2f2d01f..4bdad60671 100644 --- a/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx +++ b/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx @@ -11,12 +11,12 @@ import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { SegmentStoryboard } from './SegmentStoryboard' import { unprotectString } from '../../../lib/lib' import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/SegmentTimelineContainer' -import { PartInstances, Segments } from '../../collections' +import { Segments } from '../../collections' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' import { MongoFieldSpecifierOnes } from '@sofie-automation/corelib/dist/mongo' import { PartInstance } from '../../../lib/collections/PartInstances' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { UIParts } from '../Collections' +import { UIPartInstances, UIParts } from '../Collections' export const LIVELINE_HISTORY_SIZE = TIMELINE_LIVELINE_HISTORY_SIZE @@ -50,7 +50,7 @@ export const SegmentStoryboardContainer = withResolvedSegment(function S const partInstanceIds = useTracker( () => - PartInstances.find( + UIPartInstances.find( { segmentId: segmentId, reset: { @@ -91,7 +91,7 @@ export const SegmentStoryboardContainer = withResolvedSegment(function S return false } - const currentPartInstance = PartInstances.findOne(props.playlist.currentPartInfo.partInstanceId) + const currentPartInstance = UIPartInstances.findOne(props.playlist.currentPartInfo.partInstanceId) if (!currentPartInstance) { return false } @@ -108,7 +108,7 @@ export const SegmentStoryboardContainer = withResolvedSegment(function S return false } - const partInstance = PartInstances.findOne(props.playlist.nextPartInfo.partInstanceId, { + const partInstance = UIPartInstances.findOne(props.playlist.nextPartInfo.partInstanceId, { fields: literal>({ segmentId: 1, //@ts-expect-error typescript doesnt like it @@ -131,7 +131,7 @@ export const SegmentStoryboardContainer = withResolvedSegment(function S return false } - const currentPartInstance = PartInstances.findOne(props.playlist.currentPartInfo.partInstanceId, { + const currentPartInstance = UIPartInstances.findOne(props.playlist.currentPartInfo.partInstanceId, { fields: { //@ts-expect-error deep property 'part.autoNext': 1, diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx index dc6ac1e441..d62adc490b 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx +++ b/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx @@ -28,12 +28,11 @@ import { import { computeSegmentDuration, getPartInstanceTimingId, RundownTimingContext } from '../../lib/rundownTiming' import { RundownViewShelf } from '../RundownView/RundownViewShelf' import { PartInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PartInstances } from '../../collections' import { catchError, useDebounce } from '../../lib/lib' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { logger } from '../../../lib/logging' -import { UIParts } from '../Collections' +import { UIPartInstances, UIParts } from '../Collections' // Kept for backwards compatibility export { SegmentUi, PartUi, PieceUi, ISourceLayerUi, IOutputLayerUi } from '../SegmentContainer/withResolvedSegment' @@ -99,7 +98,7 @@ export function SegmentTimelineContainer(props: Readonly): JSX.Element { const partInstanceIds = useTracker( () => - PartInstances.find( + UIPartInstances.find( { segmentId: props.segmentId, reset: { diff --git a/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx b/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx index e722c333bc..dee062f77f 100644 --- a/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx +++ b/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx @@ -24,12 +24,12 @@ import { NotificationCenter, Notification, NoticeLevel } from '../../../../../li import { Meteor } from 'meteor/meteor' import { doModalDialog } from '../../../../lib/ModalDialog' import { PartId, RundownId, ShowStyleBaseId, TriggeredActionId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PartInstances, RundownPlaylists, Rundowns, TriggeredActions } from '../../../../collections' +import { RundownPlaylists, Rundowns, TriggeredActions } from '../../../../collections' import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { SourceLayers, OutputLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { RundownPlaylistCollectionUtil } from '../../../../../lib/collections/rundownPlaylistUtil' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { UIParts } from '../../../Collections' +import { UIPartInstances, UIParts } from '../../../Collections' export interface PreviewContext { rundownPlaylist: DBRundownPlaylist | null @@ -192,7 +192,7 @@ export const TriggeredActionsEditor: React.FC = function TriggeredAction null ) - useSubscription(CorelibPubSub.partInstances, rundown ? [rundown._id] : [], rundownPlaylist?.activationId ?? null) + useSubscription(MeteorPubSub.uiPartInstances, rundown ? [rundown._id] : [], rundownPlaylist?.activationId ?? null) useSubscription(CorelibPubSub.parts, rundown ? [rundown._id] : [], null) const previewContext = useTracker( @@ -203,7 +203,7 @@ export const TriggeredActionsEditor: React.FC = function TriggeredAction let thisNextSegmentPartIds: PartId[] = [] if (rundownPlaylist) { if (rundownPlaylist.currentPartInfo) { - const currentPartInstance = PartInstances.findOne(rundownPlaylist.currentPartInfo.partInstanceId) + const currentPartInstance = UIPartInstances.findOne(rundownPlaylist.currentPartInfo.partInstanceId) if (currentPartInstance) { thisCurrentPart = currentPartInstance.part thisCurrentSegmentPartIds = UIParts.find({ @@ -212,7 +212,7 @@ export const TriggeredActionsEditor: React.FC = function TriggeredAction } } if (rundownPlaylist.nextPartInfo) { - const nextPartInstance = PartInstances.findOne(rundownPlaylist.nextPartInfo.partInstanceId) + const nextPartInstance = UIPartInstances.findOne(rundownPlaylist.nextPartInfo.partInstanceId) if (nextPartInstance) { thisNextPart = nextPartInstance.part thisNextSegmentPartIds = UIParts.find({ diff --git a/meteor/client/ui/Shelf/AdLibPanel.tsx b/meteor/client/ui/Shelf/AdLibPanel.tsx index 4efcac2f43..6839fd1a48 100644 --- a/meteor/client/ui/Shelf/AdLibPanel.tsx +++ b/meteor/client/ui/Shelf/AdLibPanel.tsx @@ -49,15 +49,9 @@ import { AdLibPanelToolbar } from './AdLibPanelToolbar' import { AdLibListView } from './AdLibListView' import { UIShowStyleBase } from '../../../lib/api/showStyles' import { UIStudio } from '../../../lib/api/studios' -import { UIStudios } from '../Collections' +import { UIPartInstances, UIStudios } from '../Collections' import { PartId, PartInstanceId, RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { - AdLibActions, - AdLibPieces, - PartInstances, - RundownBaselineAdLibActions, - RundownBaselineAdLibPieces, -} from '../../collections' +import { AdLibActions, AdLibPieces, RundownBaselineAdLibActions, RundownBaselineAdLibPieces } from '../../collections' import { RundownPlaylistCollectionUtil } from '../../../lib/collections/rundownPlaylistUtil' import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' @@ -194,7 +188,7 @@ export function fetchAndFilter(props: IFetchAndFilterProps): AdLibFetchAndFilter const { segments, rundowns } = memoizedIsolatedAutorun( (playlist) => { - const rundownsAndSegments = RundownPlaylistCollectionUtil.getRundownsAndSegments(playlist) + const rundownsAndSegments = RundownPlaylistClientUtil.getRundownsAndSegments(playlist) const segments: DBSegment[] = [] const rundowns: Record = {} rundownsAndSegments.forEach((pair) => { @@ -220,7 +214,7 @@ export function fetchAndFilter(props: IFetchAndFilterProps): AdLibFetchAndFilter ) => { const currentPartInstance = currentPartInstanceId && - (PartInstances.findOne(currentPartInstanceId, { + (UIPartInstances.findOne(currentPartInstanceId, { projection: { _id: 1, segmentId: 1, @@ -229,7 +223,7 @@ export function fetchAndFilter(props: IFetchAndFilterProps): AdLibFetchAndFilter }) as Pick | undefined) const nextPartInstance = nextPartInstanceId && - (PartInstances.findOne(nextPartInstanceId, { + (UIPartInstances.findOne(nextPartInstanceId, { projection: { _id: 1, segmentId: 1, @@ -251,7 +245,7 @@ export function fetchAndFilter(props: IFetchAndFilterProps): AdLibFetchAndFilter } } - const partInstances = RundownPlaylistCollectionUtil.getActivePartInstancesMap(props.playlist) + const partInstances = RundownPlaylistClientUtil.getActivePartInstancesMap(props.playlist) let liveSegment: AdlibSegmentUi | undefined const uiSegmentMap = new Map() @@ -429,7 +423,7 @@ export function fetchAndFilter(props: IFetchAndFilterProps): AdLibFetchAndFilter currentRundown = rundowns[0] const partInstanceId = props.playlist.currentPartInfo?.partInstanceId || props.playlist.nextPartInfo?.partInstanceId if (partInstanceId) { - const partInstance = PartInstances.findOne(partInstanceId) + const partInstance = UIPartInstances.findOne(partInstanceId) if (partInstance) { currentRundown = rundowns.find((rd) => rd._id === partInstance.rundownId) } diff --git a/meteor/client/ui/Shelf/BucketPanel.tsx b/meteor/client/ui/Shelf/BucketPanel.tsx index 8233bcc112..bebdf2eaa7 100644 --- a/meteor/client/ui/Shelf/BucketPanel.tsx +++ b/meteor/client/ui/Shelf/BucketPanel.tsx @@ -57,11 +57,11 @@ import { isAdLibOnAir, } from '../../lib/shelf' import { MongoFieldSpecifierOnes } from '@sofie-automation/corelib/dist/mongo' -import { BucketAdLibActions, BucketAdLibs, PartInstances, Rundowns } from '../../collections' +import { BucketAdLibActions, BucketAdLibs, Rundowns } from '../../collections' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { UIShowStyleBase } from '../../../lib/api/showStyles' import { UIStudio } from '../../../lib/api/studios' -import { UIStudios } from '../Collections' +import { UIPartInstances, UIStudios } from '../Collections' import { AdLibActionId, BucketId, @@ -289,7 +289,7 @@ export const BucketPanel = React.memo( () => { const selectedPartInstanceId = props.playlist.currentPartInfo?.partInstanceId ?? props.playlist.nextPartInfo?.partInstanceId - const partInstance = PartInstances.findOne(selectedPartInstanceId, { + const partInstance = UIPartInstances.findOne(selectedPartInstanceId, { fields: literal>({ rundownId: 1, //@ts-expect-error deep property diff --git a/meteor/client/ui/Shelf/ExternalFramePanel.tsx b/meteor/client/ui/Shelf/ExternalFramePanel.tsx index 5ea3127755..b0fcc49def 100644 --- a/meteor/client/ui/Shelf/ExternalFramePanel.tsx +++ b/meteor/client/ui/Shelf/ExternalFramePanel.tsx @@ -27,12 +27,13 @@ import { IngestAdlib } from '@sofie-automation/blueprints-integration' import { MeteorCall } from '../../../lib/api/methods' import { check } from '../../../lib/check' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' -import { Buckets, PartInstances, Rundowns } from '../../collections' +import { Buckets, Rundowns } from '../../collections' import { BucketId, PartInstanceId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { MOS_DATA_IS_STRICT } from '../../../lib/mos' import { getMosTypes, stringifyMosObject } from '@mos-connection/helper' import { RundownPlaylistCollectionUtil } from '../../../lib/collections/rundownPlaylistUtil' import { logger } from '../../../lib/logging' +import { UIPartInstances } from '../Collections' const PackageInfo = require('../../../package.json') @@ -196,9 +197,9 @@ export const ExternalFramePanel = withTranslation()( let currentPart: PartInstance | undefined if (playlist.currentPartInfo || playlist.nextPartInfo) { if (playlist.currentPartInfo !== null) { - currentPart = PartInstances.findOne(playlist.currentPartInfo.partInstanceId) + currentPart = UIPartInstances.findOne(playlist.currentPartInfo.partInstanceId) } else if (playlist.nextPartInfo !== null) { - currentPart = PartInstances.findOne(playlist.nextPartInfo.partInstanceId) + currentPart = UIPartInstances.findOne(playlist.nextPartInfo.partInstanceId) } if (!currentPart) { diff --git a/meteor/client/ui/Shelf/MiniRundownPanel.tsx b/meteor/client/ui/Shelf/MiniRundownPanel.tsx index 70fda4ae57..7fe952bedf 100644 --- a/meteor/client/ui/Shelf/MiniRundownPanel.tsx +++ b/meteor/client/ui/Shelf/MiniRundownPanel.tsx @@ -14,8 +14,8 @@ import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { dashboardElementStyle } from './DashboardPanel' import { Meteor } from 'meteor/meteor' import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PartInstances } from '../../collections' -import { RundownPlaylistCollectionUtil } from '../../../lib/collections/rundownPlaylistUtil' +import { UIPartInstances } from '../Collections' +import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' interface IMiniRundownPanelProps { key: string @@ -123,14 +123,14 @@ export const MiniRundownPanel = withTracker( (props: IPartTimingPanelProps) => { if (props.playlist.currentPartInfo) { - const livePart = RundownPlaylistCollectionUtil.getActivePartInstances(props.playlist, { + const livePart = RundownPlaylistClientUtil.getActivePartInstances(props.playlist, { _id: props.playlist.currentPartInfo.partInstanceId, })[0] const { active } = getIsFilterActive(props.playlist, props.showStyleBase, props.panel) diff --git a/meteor/client/ui/Shelf/PlaylistNamePanel.tsx b/meteor/client/ui/Shelf/PlaylistNamePanel.tsx index 293492afbc..5cc12da26c 100644 --- a/meteor/client/ui/Shelf/PlaylistNamePanel.tsx +++ b/meteor/client/ui/Shelf/PlaylistNamePanel.tsx @@ -13,7 +13,7 @@ import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { Rundowns } from '../../collections' import { PartInstance } from '../../../lib/collections/PartInstances' import { logger } from '../../../lib/logging' -import { RundownPlaylistCollectionUtil } from '../../../lib/collections/rundownPlaylistUtil' +import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' interface IPlaylistNamePanelProps { visible?: boolean @@ -55,7 +55,7 @@ class PlaylistNamePanelInner extends React.Component( (props: IPlaylistNamePanelProps) => { if (props.playlist.currentPartInfo) { - const livePart: PartInstance = RundownPlaylistCollectionUtil.getActivePartInstances(props.playlist, { + const livePart: PartInstance = RundownPlaylistClientUtil.getActivePartInstances(props.playlist, { _id: props.playlist.currentPartInfo.partInstanceId, })[0] if (!livePart) { diff --git a/meteor/client/ui/Shelf/SegmentNamePanel.tsx b/meteor/client/ui/Shelf/SegmentNamePanel.tsx index 7c63dbc051..4c971ac65d 100644 --- a/meteor/client/ui/Shelf/SegmentNamePanel.tsx +++ b/meteor/client/ui/Shelf/SegmentNamePanel.tsx @@ -11,7 +11,6 @@ import { RundownLayoutsAPI } from '../../../lib/api/rundownLayouts' import { Translated, translateWithTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { PartInstance } from '../../../lib/collections/PartInstances' -import { RundownPlaylistCollectionUtil } from '../../../lib/collections/rundownPlaylistUtil' import { RundownPlaylistClientUtil } from '../../lib/rundownPlaylistUtil' interface ISegmentNamePanelProps { @@ -56,7 +55,7 @@ class SegmentNamePanelInner extends React.Component< function getSegmentName(selectedSegment: 'current' | 'next', playlist: DBRundownPlaylist): string | undefined { const currentPartInstance = playlist.currentPartInfo - ? (RundownPlaylistCollectionUtil.getActivePartInstances(playlist, { + ? (RundownPlaylistClientUtil.getActivePartInstances(playlist, { _id: playlist.currentPartInfo.partInstanceId, })[0] as PartInstance | undefined) : undefined @@ -65,18 +64,18 @@ function getSegmentName(selectedSegment: 'current' | 'next', playlist: DBRundown if (selectedSegment === 'current') { if (currentPartInstance) { - const segment = RundownPlaylistCollectionUtil.getSegments(playlist, { _id: currentPartInstance.segmentId })[0] as + const segment = RundownPlaylistClientUtil.getSegments(playlist, { _id: currentPartInstance.segmentId })[0] as | DBSegment | undefined return segment?.name } } else { if (playlist.nextPartInfo) { - const nextPartInstance = RundownPlaylistCollectionUtil.getActivePartInstances(playlist, { + const nextPartInstance = RundownPlaylistClientUtil.getActivePartInstances(playlist, { _id: playlist.nextPartInfo.partInstanceId, })[0] as PartInstance | undefined if (nextPartInstance && nextPartInstance.segmentId !== currentPartInstance.segmentId) { - const segment = RundownPlaylistCollectionUtil.getSegments(playlist, { _id: nextPartInstance.segmentId })[0] as + const segment = RundownPlaylistClientUtil.getSegments(playlist, { _id: nextPartInstance.segmentId })[0] as | DBSegment | undefined return segment?.name diff --git a/meteor/client/ui/Shelf/SegmentTimingPanel.tsx b/meteor/client/ui/Shelf/SegmentTimingPanel.tsx index 0e7c166dea..16cde78c6e 100644 --- a/meteor/client/ui/Shelf/SegmentTimingPanel.tsx +++ b/meteor/client/ui/Shelf/SegmentTimingPanel.tsx @@ -86,11 +86,11 @@ export const SegmentTimingPanel = translateWithTracker< >( (props: ISegmentTimingPanelProps) => { if (props.playlist.currentPartInfo) { - const livePart = RundownPlaylistCollectionUtil.getActivePartInstances(props.playlist, { + const livePart = RundownPlaylistClientUtil.getActivePartInstances(props.playlist, { _id: props.playlist.currentPartInfo.partInstanceId, })[0] const liveSegment = livePart - ? RundownPlaylistCollectionUtil.getSegments(props.playlist, { _id: livePart.segmentId })[0] + ? RundownPlaylistClientUtil.getSegments(props.playlist, { _id: livePart.segmentId })[0] : undefined const { active } = getIsFilterActive(props.playlist, props.showStyleBase, props.panel) @@ -112,7 +112,7 @@ export const SegmentTimingPanel = translateWithTracker< ), memoizedIsolatedAutorun( (_playlistId: RundownPlaylistId, _currentPartInstanceId, _nextPartInstanceId) => - RundownPlaylistCollectionUtil.getSelectedPartInstances(props.playlist), + RundownPlaylistClientUtil.getSelectedPartInstances(props.playlist), 'playlist.getSelectedPartInstances', props.playlist._id, props.playlist.currentPartInfo?.partInstanceId, @@ -134,7 +134,7 @@ export const SegmentTimingPanel = translateWithTracker< const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(props.playlist) const rundown = rundowns.find((r) => r._id === liveSegment.rundownId) const segmentIndex = orderedSegmentsAndParts.segments.findIndex((s) => s._id === liveSegment._id) - const pieces = RundownPlaylistCollectionUtil.getPiecesForParts(orderedAllPartIds) + const pieces = RundownPlaylistClientUtil.getPiecesForParts(orderedAllPartIds) if (!rundown) return { active } diff --git a/meteor/lib/api/pubsub.ts b/meteor/lib/api/pubsub.ts index d799eb4000..482838c874 100644 --- a/meteor/lib/api/pubsub.ts +++ b/meteor/lib/api/pubsub.ts @@ -2,6 +2,8 @@ import { BucketId, OrganizationId, PartId, + RundownId, + RundownPlaylistActivationId, RundownPlaylistId, ShowStyleBaseId, StudioId, @@ -35,6 +37,7 @@ import { import { CorelibPubSub, CorelibPubSubCollections, CorelibPubSubTypes } from '@sofie-automation/corelib/dist/pubsub' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { PartInstance } from '../collections/PartInstances' /** * Ids of possible DDP subscriptions for the UI only @@ -163,6 +166,10 @@ export enum MeteorPubSub { * Fetch all Parts with UI overrides */ uiParts = 'uiParts', + /** + * Fetch all PartInstances with UI overrides + */ + uiPartInstances = 'uiPartInstances', } /** @@ -243,6 +250,10 @@ export interface MeteorPubSubTypes { ) => CustomCollectionName.UIBucketContentStatuses [MeteorPubSub.uiBlueprintUpgradeStatuses]: () => CustomCollectionName.UIBlueprintUpgradeStatuses [MeteorPubSub.uiParts]: (playlistId: RundownPlaylistId) => CustomCollectionName.UIParts + [MeteorPubSub.uiPartInstances]: ( + rundownIds: RundownId[], + playlistActivationId: RundownPlaylistActivationId | null + ) => CustomCollectionName.UIPartInstances } export type AllPubSubCollections = PeripheralDevicePubSubCollections & @@ -262,6 +273,7 @@ export enum CustomCollectionName { UIBucketContentStatuses = 'uiBucketContentStatuses', UIBlueprintUpgradeStatuses = 'uiBlueprintUpgradeStatuses', UIParts = 'uiParts', + UIPartInstances = 'uiPartInstances', } export type MeteorPubSubCollections = { @@ -291,6 +303,7 @@ export type MeteorPubSubCustomCollections = { [CustomCollectionName.UIBucketContentStatuses]: UIBucketContentStatus [CustomCollectionName.UIBlueprintUpgradeStatuses]: UIBlueprintUpgradeStatus [CustomCollectionName.UIParts]: DBPart + [CustomCollectionName.UIPartInstances]: PartInstance } /** diff --git a/meteor/lib/collections/rundownPlaylistUtil.ts b/meteor/lib/collections/rundownPlaylistUtil.ts index ac5a604328..7b3eba9236 100644 --- a/meteor/lib/collections/rundownPlaylistUtil.ts +++ b/meteor/lib/collections/rundownPlaylistUtil.ts @@ -1,22 +1,12 @@ -import { PartId, RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { Rundown, DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { normalizeArrayToMap, normalizeArrayFunc, groupByToMap } from '@sofie-automation/corelib/dist/lib' -import { - sortRundownIDsInPlaylist, - sortSegmentsInRundowns, - sortPartsInSegments, - sortPartsInSortedSegments, -} from '@sofie-automation/corelib/dist/playout/playlist' -import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import { normalizeArrayToMap } from '@sofie-automation/corelib/dist/lib' +import { sortRundownIDsInPlaylist } from '@sofie-automation/corelib/dist/playout/playlist' import _ from 'underscore' -import { Rundowns, Segments, PartInstances, Pieces } from './libCollections' +import { Rundowns } from './libCollections' import { FindOptions } from './lib' -import { PartInstance } from './PartInstances' import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' -import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' /** * Direct database accessors for the RundownPlaylist @@ -77,208 +67,4 @@ export class RundownPlaylistCollectionUtil { return rundowns.map((i) => i._id) } - - /** Returns all segments joined with their rundowns in their correct oreder for this RundownPlaylist */ - static getRundownsAndSegments( - playlist: Pick, - selector?: MongoQuery, - options?: FindOptions - ): Array<{ - rundown: Pick< - Rundown, - | '_id' - | 'name' - | 'playlistId' - | 'timing' - | 'showStyleBaseId' - | 'showStyleVariantId' - | 'endOfRundownIsShowBreak' - > - segments: DBSegment[] - }> { - const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist, undefined, { - fields: { - name: 1, - playlistId: 1, - timing: 1, - showStyleBaseId: 1, - showStyleVariantId: 1, - endOfRundownIsShowBreak: 1, - }, - }) - const segments = Segments.find( - { - rundownId: { - $in: rundowns.map((i) => i._id), - }, - ...selector, - }, - { - sort: { - rundownId: 1, - _rank: 1, - }, - ...options, - } - ).fetch() - return RundownPlaylistCollectionUtil._matchSegmentsAndRundowns(segments, rundowns) - } - /** Returns all segments in their correct order for this RundownPlaylist */ - static getSegments( - playlist: Pick, - selector?: MongoQuery, - options?: FindOptions - ): DBSegment[] { - const rundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) - const segments = Segments.find( - { - rundownId: { - $in: rundownIds, - }, - ...selector, - }, - { - sort: { - rundownId: 1, - _rank: 1, - }, - ...options, - } - ).fetch() - return RundownPlaylistCollectionUtil._sortSegments(segments, playlist) - } - - static getSelectedPartInstances( - playlist: Pick, - rundownIds0?: RundownId[] - ): { - currentPartInstance: PartInstance | undefined - nextPartInstance: PartInstance | undefined - previousPartInstance: PartInstance | undefined - } { - let unorderedRundownIds = rundownIds0 - if (!unorderedRundownIds) { - unorderedRundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) - } - - const ids = _.compact([ - playlist.currentPartInfo?.partInstanceId, - playlist.previousPartInfo?.partInstanceId, - playlist.nextPartInfo?.partInstanceId, - ]) - const instances = - ids.length > 0 - ? PartInstances.find({ - rundownId: { $in: unorderedRundownIds }, - _id: { $in: ids }, - reset: { $ne: true }, - }).fetch() - : [] - - return { - currentPartInstance: instances.find((inst) => inst._id === playlist.currentPartInfo?.partInstanceId), - nextPartInstance: instances.find((inst) => inst._id === playlist.nextPartInfo?.partInstanceId), - previousPartInstance: instances.find((inst) => inst._id === playlist.previousPartInfo?.partInstanceId), - } - } - - static getAllPartInstances( - playlist: Pick, - selector?: MongoQuery, - options?: FindOptions - ): PartInstance[] { - const unorderedRundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) - - return PartInstances.find( - { - rundownId: { $in: unorderedRundownIds }, - ...selector, - }, - { - sort: { takeCount: 1 }, - ...options, - } - ).fetch() - } - /** Return a list of PartInstances, omitting the reset ones (ie only the ones that are relevant) */ - static getActivePartInstances( - playlist: Pick, - selector?: MongoQuery, - options?: FindOptions - ): PartInstance[] { - const newSelector: MongoQuery = { - ...selector, - reset: { $ne: true }, - } - return RundownPlaylistCollectionUtil.getAllPartInstances(playlist, newSelector, options) - } - static getActivePartInstancesMap( - playlist: Pick, - selector?: MongoQuery, - options?: FindOptions - ): Record { - const instances = RundownPlaylistCollectionUtil.getActivePartInstances(playlist, selector, options) - return normalizeArrayFunc(instances, (i) => unprotectString(i.part._id)) - } - static getPiecesForParts( - parts: Array, - piecesOptions?: Omit, 'projection'> // We are mangling fields, so block projection - ): Map { - const allPieces = Pieces.find( - { startPartId: { $in: parts } }, - { - ...piecesOptions, - //@ts-expect-error This is too clever for the compiler - fields: piecesOptions?.fields - ? { - ...piecesOptions?.fields, - startPartId: 1, - } - : undefined, - } - ).fetch() - return groupByToMap(allPieces, 'startPartId') - } - - static _sortSegments>( - segments: Array, - playlist: Pick - ): TSegment[] { - return sortSegmentsInRundowns(segments, playlist.rundownIdsInOrder) - } - static _matchSegmentsAndRundowns( - segments: E[], - rundowns: T[] - ): Array<{ rundown: T; segments: E[] }> { - const rundownsMap = new Map< - RundownId, - { - rundown: T - segments: E[] - } - >() - rundowns.forEach((rundown) => { - rundownsMap.set(rundown._id, { - rundown, - segments: [], - }) - }) - segments.forEach((segment) => { - rundownsMap.get(segment.rundownId)?.segments.push(segment) - }) - return Array.from(rundownsMap.values()) - } - static _sortParts( - parts: DBPart[], - playlist: Pick, - segments: Array> - ): DBPart[] { - return sortPartsInSegments(parts, playlist.rundownIdsInOrder, segments) - } - static _sortPartsInner

>( - parts: P[], - sortedSegments: Array> - ): P[] { - return sortPartsInSortedSegments(parts, sortedSegments) - } } diff --git a/meteor/server/publications/_publications.ts b/meteor/server/publications/_publications.ts index bd5120e0f7..1df211e15a 100644 --- a/meteor/server/publications/_publications.ts +++ b/meteor/server/publications/_publications.ts @@ -10,6 +10,7 @@ import './pieceContentStatusUI/bucket/publication' import './pieceContentStatusUI/rundown/publication' import './organization' import './partsUI/publication' +import './partInstancesUI/publication' import './peripheralDevice' import './peripheralDeviceForDevice' import './rundown' diff --git a/meteor/server/publications/lib/quickLoop.ts b/meteor/server/publications/lib/quickLoop.ts new file mode 100644 index 0000000000..6e201e6c4b --- /dev/null +++ b/meteor/server/publications/lib/quickLoop.ts @@ -0,0 +1,106 @@ +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { + DBRundownPlaylist, + ForceQuickLoopAutoNext, + QuickLoopMarker, + QuickLoopMarkerType, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { MarkerPosition, compareMarkerPositions } from '@sofie-automation/corelib/dist/playout/playlist' +import { ProtectedString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import { DEFAULT_FALLBACK_PART_DURATION } from '@sofie-automation/shared-lib/dist/core/constants' +import { generateTranslation } from '../../../lib/lib' +import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep' +import { ReactiveCacheCollection } from './ReactiveCacheCollection' + +export function findPartPosition( + part: DBPart, + segmentRanks: Record, + rundownRanks: Record +): MarkerPosition { + return { + rundownRank: rundownRanks[part.rundownId as unknown as string] ?? 0, + segmentRank: segmentRanks[part.segmentId as unknown as string] ?? 0, + partRank: part._rank, + } +} + +export function stringsToIndexLookup(strings: string[]): Record { + return strings.reduce((result, str, index) => { + result[str] = index + return result + }, {} as Record) +} + +export function extractRanks(docs: { _id: ProtectedString; _rank: number }[]): Record { + return docs.reduce((result, doc) => { + result[doc._id as unknown as string] = doc._rank + return result + }, {} as Record) +} + +export function modifyPartForQuickLoop( + part: DBPart, + segmentRanks: Record, + rundownRanks: Record, + playlist: Pick, + studio: Pick, + quickLoopStartPosition: MarkerPosition | undefined, + quickLoopEndPosition: MarkerPosition | undefined +): void { + const partPosition = findPartPosition(part, segmentRanks, rundownRanks) + const isLoopDefined = quickLoopStartPosition && quickLoopEndPosition + const isLoopingOverriden = + isLoopDefined && + playlist.quickLoop?.forceAutoNext !== ForceQuickLoopAutoNext.DISABLED && + compareMarkerPositions(quickLoopStartPosition, partPosition) >= 0 && + compareMarkerPositions(partPosition, quickLoopEndPosition) >= 0 + + if (isLoopingOverriden && (part.expectedDuration ?? 0) <= 0) { + if (playlist.quickLoop?.forceAutoNext === ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION) { + part.expectedDuration = studio.settings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION + part.expectedDurationWithPreroll = studio.settings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION + } else if (playlist.quickLoop?.forceAutoNext === ForceQuickLoopAutoNext.ENABLED_WHEN_VALID_DURATION) { + part.invalid = true + part.invalidReason = { + message: generateTranslation('Part duration is 0.'), + } + } + } + part.autoNext = part.autoNext || (isLoopingOverriden && (part.expectedDuration ?? 0) > 0) +} + +export function findMarkerPosition( + marker: QuickLoopMarker, + fallback: number, + segmentCache: ReadonlyObjectDeep>>, + partCache: + | { parts: ReadonlyObjectDeep>> } + | { partInstances: ReadonlyObjectDeep> }, + rundownRanks: Record +): MarkerPosition { + const part = + marker.type === QuickLoopMarkerType.PART + ? 'parts' in partCache + ? partCache.parts.findOne(marker.id) + : partCache.partInstances.findOne({ 'part._id': marker.id })?.part + : undefined + const partRank = part?._rank ?? fallback + + const segmentId = marker.type === QuickLoopMarkerType.SEGMENT ? marker.id : part?.segmentId + const segment = segmentId && segmentCache.findOne(segmentId) + const segmentRank = segment?._rank ?? fallback + + const rundownId = marker.type === QuickLoopMarkerType.RUNDOWN ? marker.id : segment?.rundownId + let rundownRank = rundownId ? rundownRanks[unprotectString(rundownId)] : fallback + + if (marker.type === QuickLoopMarkerType.PLAYLIST) rundownRank = fallback + + return { + rundownRank: rundownRank, + segmentRank: segmentRank, + partRank: partRank, + } +} diff --git a/meteor/server/publications/partInstancesUI/publication.ts b/meteor/server/publications/partInstancesUI/publication.ts new file mode 100644 index 0000000000..5ea7106fcf --- /dev/null +++ b/meteor/server/publications/partInstancesUI/publication.ts @@ -0,0 +1,231 @@ +import { + PartInstanceId, + RundownId, + RundownPlaylistActivationId, + SegmentId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { check } from 'meteor/check' +import { + CustomPublishCollection, + TriggerUpdate, + meteorCustomPublish, + setUpCollectionOptimizedObserver, +} from '../../lib/customPublication' +import { logger } from '../../logging' +import { CustomCollectionName, MeteorPubSub } from '../../../lib/api/pubsub' +import { resolveCredentials } from '../../security/lib/credentials' +import { NoSecurityReadAccess } from '../../security/noSecurity' +import { ContentCache, PartInstanceOmitedFields, createReactiveContentCache } from './reactiveContentCache' +import { ReadonlyDeep } from 'type-fest' +import { LiveQueryHandle } from '../../lib/lib' +import { RundownPlaylists } from '../../collections' +import { literal } from '@sofie-automation/corelib/dist/lib' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { MongoFieldSpecifierOnesStrict } from '@sofie-automation/corelib/dist/mongo' +import { RundownsObserver } from '../lib/rundownsObserver' +import { RundownContentObserver } from './rundownContentObserver' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { Match } from '../../../lib/check' +import { RundownReadAccess } from '../../security/rundown' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' +import { extractRanks, findMarkerPosition, modifyPartForQuickLoop, stringsToIndexLookup } from '../lib/quickLoop' + +interface UIPartInstancesArgs { + readonly playlistActivationId: RundownPlaylistActivationId + readonly rundownIds: RundownId[] +} + +export interface UIPartInstancesState { + contentCache: ReadonlyDeep +} + +interface UIPartInstancesUpdateProps { + newCache: ContentCache + + invalidateSegmentIds: SegmentId[] + invalidatePartInstanceIds: PartInstanceId[] + + invalidateQuickLoop: boolean +} + +type RundownPlaylistFields = '_id' | 'studioId' | 'rundownIdsInOrder' +const rundownPlaylistFieldSpecifier = literal< + MongoFieldSpecifierOnesStrict> +>({ + _id: 1, + studioId: 1, + rundownIdsInOrder: 1, +}) + +async function setupUIPartInstancesPublicationObservers( + args: ReadonlyDeep, + triggerUpdate: TriggerUpdate +): Promise { + const playlist = (await RundownPlaylists.findOneAsync( + { activationId: args.playlistActivationId }, + { + projection: rundownPlaylistFieldSpecifier, + } + )) as Pick | undefined + if (!playlist) throw new Error(`RundownPlaylist with activationId="${args.playlistActivationId}" not found!`) + + const rundownsObserver = new RundownsObserver(playlist.studioId, playlist._id, (rundownIds) => { + logger.silly(`Creating new RundownContentObserver`) + + const cache = createReactiveContentCache() + + // Push update + triggerUpdate({ newCache: cache }) + + const obs1 = new RundownContentObserver(playlist.studioId, args.playlistActivationId, rundownIds, cache) + + const innerQueries = [ + cache.Segments.find({}).observeChanges({ + added: (id) => triggerUpdate({ invalidateSegmentIds: [protectString(id)] }), + changed: (id) => triggerUpdate({ invalidateSegmentIds: [protectString(id)] }), + removed: (id) => triggerUpdate({ invalidateSegmentIds: [protectString(id)] }), + }), + cache.PartInstances.find({}).observe({ + added: (doc) => triggerUpdate({ invalidatePartInstanceIds: [doc._id] }), + changed: (doc, oldDoc) => { + if (doc.part._rank !== oldDoc.part._rank) { + // with part rank change we need to invalidate the entire segment, + // as the order may affect which unchanged parts are/aren't in quickLoop + triggerUpdate({ invalidateSegmentIds: [doc.segmentId] }) + } else { + triggerUpdate({ invalidatePartInstanceIds: [doc._id] }) + } + }, + removed: (doc) => triggerUpdate({ invalidatePartInstanceIds: [doc._id] }), + }), + cache.RundownPlaylists.find({}).observeChanges({ + added: () => triggerUpdate({ invalidateQuickLoop: true }), + changed: () => triggerUpdate({ invalidateQuickLoop: true }), + removed: () => triggerUpdate({ invalidateQuickLoop: true }), + }), + cache.Studios.find({}).observeChanges({ + added: () => triggerUpdate({ invalidateQuickLoop: true }), + changed: () => triggerUpdate({ invalidateQuickLoop: true }), + removed: () => triggerUpdate({ invalidateQuickLoop: true }), + }), + ] + + return () => { + obs1.dispose() + + for (const query of innerQueries) { + query.stop() + } + } + }) + + // Set up observers: + return [rundownsObserver] +} + +export async function manipulateUIPartInstancesPublicationData( + _args: ReadonlyDeep, + state: Partial, + collection: CustomPublishCollection, + updateProps: Partial> | undefined +): Promise { + // Prepare data for publication: + + if (updateProps?.newCache !== undefined) { + state.contentCache = updateProps.newCache ?? undefined + } + + if (!state.contentCache) { + // Remove all the partInstances + collection.remove(null) + + return + } + + const playlist = state.contentCache.RundownPlaylists.findOne({}) + if (!playlist) return + + const studio = state.contentCache.Studios.findOne({}) + if (!studio) return + + const rundownRanks = stringsToIndexLookup(playlist.rundownIdsInOrder as unknown as string[]) + const segmentRanks = extractRanks(state.contentCache.Segments.find({}).fetch()) + + const quickLoopStartPosition = + playlist.quickLoop?.start && + findMarkerPosition( + playlist.quickLoop.start, + -Infinity, + state.contentCache.Segments, + { partInstances: state.contentCache.PartInstances }, + rundownRanks + ) + const quickLoopEndPosition = + playlist.quickLoop?.end && + findMarkerPosition( + playlist.quickLoop.end, + Infinity, + state.contentCache.Segments, + { partInstances: state.contentCache.PartInstances }, + rundownRanks + ) + + updateProps?.invalidatePartInstanceIds?.forEach((partId) => { + collection.remove(partId) // if it still exists, it will be replaced in the next step + }) + + const invalidatedSegmentsSet = new Set(updateProps?.invalidateSegmentIds ?? []) + const invalidatedPartInstancesSet = new Set(updateProps?.invalidatePartInstanceIds ?? []) + + state.contentCache.PartInstances.find({}).forEach((partInstance) => { + if ( + updateProps?.invalidateQuickLoop || + invalidatedSegmentsSet.has(partInstance.segmentId) || + invalidatedPartInstancesSet.has(partInstance._id) + ) { + modifyPartForQuickLoop( + partInstance.part, + segmentRanks, + rundownRanks, + playlist, + studio, + quickLoopStartPosition, + quickLoopEndPosition + ) + collection.replace(partInstance) + } + }) +} + +meteorCustomPublish( + MeteorPubSub.uiPartInstances, + CustomCollectionName.UIPartInstances, + async function (pub, rundownIds: RundownId[], playlistActivationId: RundownPlaylistActivationId | null) { + check(rundownIds, [String]) + check(playlistActivationId, Match.Maybe(String)) + + const credentials = await resolveCredentials({ userId: this.userId, token: undefined }) + + if ( + playlistActivationId && + (!credentials || + NoSecurityReadAccess.any() || + (await RundownReadAccess.rundownContent({ $in: rundownIds }, credentials))) + ) { + await setUpCollectionOptimizedObserver< + Omit, + UIPartInstancesArgs, + UIPartInstancesState, + UIPartInstancesUpdateProps + >( + `pub_${MeteorPubSub.uiPartInstances}_${rundownIds.join(',')}_${playlistActivationId}`, + { rundownIds, playlistActivationId }, + setupUIPartInstancesPublicationObservers, + manipulateUIPartInstancesPublicationData, + pub + ) + } else { + logger.warn(`Pub.uiPartInstances: Not allowed: [${rundownIds.join(',')}] "${playlistActivationId}"`) + } + } +) diff --git a/meteor/server/publications/partInstancesUI/reactiveContentCache.ts b/meteor/server/publications/partInstancesUI/reactiveContentCache.ts new file mode 100644 index 0000000000..b9356fb6a1 --- /dev/null +++ b/meteor/server/publications/partInstancesUI/reactiveContentCache.ts @@ -0,0 +1,52 @@ +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { ReactiveCacheCollection } from '../lib/ReactiveCacheCollection' +import { literal } from '@sofie-automation/corelib/dist/lib' +import { MongoFieldSpecifierOnesStrict, MongoFieldSpecifierZeroes } from '@sofie-automation/corelib/dist/mongo' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' + +export type RundownPlaylistCompact = Pick +export const rundownPlaylistFieldSpecifier = literal>({ + _id: 1, + activationId: 1, + quickLoop: 1, // so that it invalidates when the markers or state of the loop change + rundownIdsInOrder: 1, +}) + +export type SegmentFields = '_id' | '_rank' | 'rundownId' +export const segmentFieldSpecifier = literal>>({ + _id: 1, + _rank: 1, + rundownId: 1, +}) + +export type PartInstanceOmitedFields = 'part.privateData' +export const partInstanceFieldSpecifier = literal>({ + // @ts-expect-error Mongo typings aren't clever enough yet + 'part.privateData': 0, +}) + +export type StudioFields = '_id' | 'settings' +export const studioFieldSpecifier = literal>>({ + _id: 1, + settings: 1, +}) + +export interface ContentCache { + Studios: ReactiveCacheCollection> + Segments: ReactiveCacheCollection> + PartInstances: ReactiveCacheCollection> + RundownPlaylists: ReactiveCacheCollection +} + +export function createReactiveContentCache(): ContentCache { + const cache: ContentCache = { + Studios: new ReactiveCacheCollection>('studios'), + Segments: new ReactiveCacheCollection>('segments'), + PartInstances: new ReactiveCacheCollection>('partInstances'), + RundownPlaylists: new ReactiveCacheCollection('rundownPlaylists'), + } + + return cache +} diff --git a/meteor/server/publications/partInstancesUI/rundownContentObserver.ts b/meteor/server/publications/partInstancesUI/rundownContentObserver.ts new file mode 100644 index 0000000000..aa405e9071 --- /dev/null +++ b/meteor/server/publications/partInstancesUI/rundownContentObserver.ts @@ -0,0 +1,79 @@ +import { Meteor } from 'meteor/meteor' +import { RundownId, RundownPlaylistActivationId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { logger } from '../../logging' +import { + ContentCache, + partInstanceFieldSpecifier, + rundownPlaylistFieldSpecifier, + segmentFieldSpecifier, + studioFieldSpecifier, +} from './reactiveContentCache' +import { PartInstances, RundownPlaylists, Segments, Studios } from '../../collections' + +export class RundownContentObserver { + #observers: Meteor.LiveQueryHandle[] = [] + #cache: ContentCache + + constructor( + studioId: StudioId, + playlistActivationId: RundownPlaylistActivationId, + rundownIds: RundownId[], + cache: ContentCache + ) { + logger.silly(`Creating RundownContentObserver for rundowns "${rundownIds.join(',')}"`) + this.#cache = cache + + this.#observers = [ + Studios.observeChanges( + { + _id: studioId, + }, + cache.Studios.link(), + { + fields: studioFieldSpecifier, + } + ), + RundownPlaylists.observeChanges( + { + activationId: playlistActivationId, + }, + cache.RundownPlaylists.link(), + { + fields: rundownPlaylistFieldSpecifier, + } + ), + Segments.observeChanges( + { + rundownId: { + $in: rundownIds, + }, + }, + cache.Segments.link(), + { + projection: segmentFieldSpecifier, + } + ), + PartInstances.observeChanges( + { + rundownId: { + $in: rundownIds, + }, + playlistActivationId, + reset: { $ne: true }, + }, + cache.PartInstances.link(), + { + projection: partInstanceFieldSpecifier, + } + ), + ] + } + + public get cache(): ContentCache { + return this.#cache + } + + public dispose = (): void => { + this.#observers.forEach((observer) => observer.stop()) + } +} diff --git a/meteor/server/publications/partsUI/publication.ts b/meteor/server/publications/partsUI/publication.ts index e5eb2a1a96..1a3682fec0 100644 --- a/meteor/server/publications/partsUI/publication.ts +++ b/meteor/server/publications/partsUI/publication.ts @@ -17,20 +17,12 @@ import { ReadonlyDeep } from 'type-fest' import { LiveQueryHandle } from '../../lib/lib' import { RundownPlaylists } from '../../collections' import { literal } from '@sofie-automation/corelib/dist/lib' -import { - DBRundownPlaylist, - ForceQuickLoopAutoNext, - QuickLoopMarker, - QuickLoopMarkerType, -} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { MongoFieldSpecifierOnesStrict } from '@sofie-automation/corelib/dist/mongo' import { RundownsObserver } from '../lib/rundownsObserver' import { RundownContentObserver } from './rundownContentObserver' -import { ProtectedString, protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' -import { ReadonlyObjectDeep } from 'type-fest/source/readonly-deep' -import { generateTranslation } from '../../../lib/lib' -import { DEFAULT_FALLBACK_PART_DURATION } from '@sofie-automation/shared-lib/dist/core/constants' -import { MarkerPosition, compareMarkerPositions } from '@sofie-automation/corelib/dist/playout/playlist' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { extractRanks, findMarkerPosition, modifyPartForQuickLoop, stringsToIndexLookup } from '../lib/quickLoop' interface UIPartsArgs { readonly playlistId: RundownPlaylistId @@ -151,36 +143,22 @@ export async function manipulateUIPartsPublicationData( const quickLoopStartPosition = playlist.quickLoop?.start && - findMarkerPosition(playlist.quickLoop.start, -Infinity, state.contentCache, rundownRanks) + findMarkerPosition( + playlist.quickLoop.start, + -Infinity, + state.contentCache.Segments, + { parts: state.contentCache.Parts }, + rundownRanks + ) const quickLoopEndPosition = playlist.quickLoop?.end && - findMarkerPosition(playlist.quickLoop.end, Infinity, state.contentCache, rundownRanks) - - const isLoopDefined = - playlist.quickLoop?.start && playlist.quickLoop?.end && quickLoopStartPosition && quickLoopEndPosition - - const modifyPartForQuickLoop = (part: DBPart) => { - const partPosition = findPartPosition(part, segmentRanks, rundownRanks) - const isLoopingOverriden = - isLoopDefined && - playlist.quickLoop?.forceAutoNext !== ForceQuickLoopAutoNext.DISABLED && - compareMarkerPositions(quickLoopStartPosition, partPosition) >= 0 && - compareMarkerPositions(partPosition, quickLoopEndPosition) >= 0 - - if (isLoopingOverriden && (part.expectedDuration ?? 0) <= 0) { - if (playlist.quickLoop?.forceAutoNext === ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION) { - part.expectedDuration = studio.settings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION - part.expectedDurationWithPreroll = - studio.settings.fallbackPartDuration ?? DEFAULT_FALLBACK_PART_DURATION - } else if (playlist.quickLoop?.forceAutoNext === ForceQuickLoopAutoNext.ENABLED_WHEN_VALID_DURATION) { - part.invalid = true - part.invalidReason = { - message: generateTranslation('Part duration is 0.'), - } - } - } - part.autoNext = part.autoNext || (isLoopingOverriden && (part.expectedDuration ?? 0) > 0) - } + findMarkerPosition( + playlist.quickLoop.end, + Infinity, + state.contentCache.Segments, + { parts: state.contentCache.Parts }, + rundownRanks + ) updateProps?.invalidatePartIds?.forEach((partId) => { collection.remove(partId) // if it still exists, it will be replaced in the next step @@ -195,63 +173,20 @@ export async function manipulateUIPartsPublicationData( invalidatedSegmentsSet.has(part.segmentId) || invalidatedPartsSet.has(part._id) ) { - modifyPartForQuickLoop(part) + modifyPartForQuickLoop( + part, + segmentRanks, + rundownRanks, + playlist, + studio, + quickLoopStartPosition, + quickLoopEndPosition + ) collection.replace(part) } }) } -function findMarkerPosition( - marker: QuickLoopMarker, - fallback: number, - contentCache: ReadonlyObjectDeep, - rundownRanks: Record -): MarkerPosition { - const part = marker.type === QuickLoopMarkerType.PART ? contentCache.Parts.findOne(marker.id) : undefined - const partRank = part?._rank ?? fallback - - const segmentId = marker.type === QuickLoopMarkerType.SEGMENT ? marker.id : part?.segmentId - const segment = segmentId && contentCache.Segments.findOne(segmentId) - const segmentRank = segment?._rank ?? fallback - - const rundownId = marker.type === QuickLoopMarkerType.RUNDOWN ? marker.id : segment?.rundownId - let rundownRank = rundownId ? rundownRanks[unprotectString(rundownId)] : fallback - - if (marker.type === QuickLoopMarkerType.PLAYLIST) rundownRank = fallback - - return { - rundownRank: rundownRank, - segmentRank: segmentRank, - partRank: partRank, - } -} - -function findPartPosition( - part: DBPart, - segmentRanks: Record, - rundownRanks: Record -): MarkerPosition { - return { - rundownRank: rundownRanks[part.rundownId as unknown as string] ?? 0, - segmentRank: segmentRanks[part.segmentId as unknown as string] ?? 0, - partRank: part._rank, - } -} - -function stringsToIndexLookup(strings: string[]): Record { - return strings.reduce((result, str, index) => { - result[str] = index - return result - }, {} as Record) -} - -function extractRanks(docs: { _id: ProtectedString; _rank: number }[]): Record { - return docs.reduce((result, doc) => { - result[doc._id as unknown as string] = doc._rank - return result - }, {} as Record) -} - meteorCustomPublish( MeteorPubSub.uiParts, CustomCollectionName.UIParts, diff --git a/meteor/server/publications/rundown.ts b/meteor/server/publications/rundown.ts index 855d9724a2..fa0cacb2f7 100644 --- a/meteor/server/publications/rundown.ts +++ b/meteor/server/publications/rundown.ts @@ -39,7 +39,6 @@ import { RundownPlaylistActivationId, RundownPlaylistId, SegmentId, - SegmentPlayoutId, ShowStyleBaseId, } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' @@ -278,36 +277,6 @@ meteorPublish( return null } ) -meteorPublish( - CorelibPubSub.partInstancesForSegmentPlayout, - async function (rundownId: RundownId, segmentPlayoutId: SegmentPlayoutId, token: string | undefined) { - if (!rundownId) throw new Meteor.Error(400, 'rundownId argument missing') - if (!segmentPlayoutId) throw new Meteor.Error(400, 'segmentPlayoutId argument missing') - - if ( - NoSecurityReadAccess.any() || - (await RundownReadAccess.rundownContent(rundownId, { userId: this.userId, token })) - ) { - return PartInstances.findWithCursor( - { - rundownId, - segmentPlayoutId, - }, - { - fields: { - // @ts-expect-error Mongo typings aren't clever enough yet - 'part.privateData': 0, - }, - sort: { - takeCount: 1, - }, - limit: 1, - } - ) - } - return null - } -) const piecesSubFields: MongoFieldSpecifierZeroes = { privateData: 0, diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 10506e127e..4ed65e7ba1 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -158,6 +158,8 @@ export interface DBRundownPlaylist { lastIncorrectPartPlaybackReported?: Time /** Actual time of each rundown starting playback */ rundownsStartedPlayback?: Record + /** Actual time of SOME segments starting playback - usually just the previous and current one */ + segmentsStartedPlayback?: Record /** Time of the last take */ lastTakeTime?: Time diff --git a/packages/corelib/src/pubsub.ts b/packages/corelib/src/pubsub.ts index 47e8804ffd..34bc32b66f 100644 --- a/packages/corelib/src/pubsub.ts +++ b/packages/corelib/src/pubsub.ts @@ -35,14 +35,7 @@ import { ShowStyleBaseId, StudioId, } from '@sofie-automation/shared-lib/dist/core/model/Ids' -import { - BlueprintId, - BucketId, - RundownPlaylistActivationId, - SegmentId, - SegmentPlayoutId, - ShowStyleVariantId, -} from './dataModel/Ids' +import { BlueprintId, BucketId, RundownPlaylistActivationId, SegmentId, ShowStyleVariantId } from './dataModel/Ids' /** * Ids of possible DDP subscriptions for any the UI and gateways accessing the Rundown & RundownPlaylist model. @@ -101,11 +94,6 @@ export enum CorelibPubSub { * This provides a simplified form of the PartInstance, with any timing information omitted to reduce data churn */ partInstancesSimple = 'partInstancesSimple', - /** - * Fetch the most recent PartInstance in a Rundown with the SegmentPlayoutId, including reset instances - * This provides a simplified form of the PartInstance, with any timing information omitted to reduce data churn - */ - partInstancesForSegmentPlayout = 'partInstancesForSegmentPlayout', /** * Fetch Pieces belonging to the specified Rundowns, optionally limiting the result to the specified Parts */ @@ -277,11 +265,6 @@ export interface CorelibPubSubTypes { playlistActivationId: RundownPlaylistActivationId | null, token?: string ) => CollectionName.PartInstances - [CorelibPubSub.partInstancesForSegmentPlayout]: ( - rundownId: RundownId, - segmentPlayoutId: SegmentPlayoutId, - token?: string - ) => CollectionName.PartInstances [CorelibPubSub.segments]: ( rundownIds: RundownId[], filter: { diff --git a/packages/job-worker/src/playout/model/PlayoutModel.ts b/packages/job-worker/src/playout/model/PlayoutModel.ts index c88f53fe02..258154c9d3 100644 --- a/packages/job-worker/src/playout/model/PlayoutModel.ts +++ b/packages/job-worker/src/playout/model/PlayoutModel.ts @@ -314,11 +314,18 @@ export interface PlayoutModel extends PlayoutModelReadonly, StudioPlayoutModelBa /** * Track a Rundown as having started playback - * @param rundownId If of the Rundown + * @param rundownId Id of the Rundown * @param timestamp Timestamp playback started */ setRundownStartedPlayback(rundownId: RundownId, timestamp: number): void + /** + * Track a Segment as having started playback + * @param segmentId Id of the Segment + * @param timestamp Timestamp playback started + */ + setSegmentStartedPlayback(segmentId: SegmentId, timestamp: number): void + /** * Set or clear a QuickLoop Marker * @param type diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 7a9057592c..479dd32085 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -673,6 +673,24 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } + setSegmentStartedPlayback(segmentId: SegmentId, timestamp: number): void { + const segmentIdsToKeep: string[] = [] + if (this.previousPartInstance) { + segmentIdsToKeep.push(unprotectString(this.previousPartInstance.partInstance.segmentId)) + } + if (this.currentPartInstance) { + segmentIdsToKeep.push(unprotectString(this.currentPartInstance.partInstance.segmentId)) + } + + this.playlistImpl.segmentsStartedPlayback = this.playlistImpl.segmentsStartedPlayback + ? _.pick(this.playlistImpl.segmentsStartedPlayback, segmentIdsToKeep) + : {} + + const segmentIdStr = unprotectString(segmentId) + this.playlistImpl.segmentsStartedPlayback[segmentIdStr] = timestamp + this.#playlistHasChanged = true + } + setTimeline(timelineObjs: TimelineObjGeneric[], generationVersions: TimelineCompleteGenerationVersions): void { this.timelineImpl = { _id: this.context.studioId, diff --git a/packages/job-worker/src/playout/timings/partPlayback.ts b/packages/job-worker/src/playout/timings/partPlayback.ts index 793f2f0381..4383638c21 100644 --- a/packages/job-worker/src/playout/timings/partPlayback.ts +++ b/packages/job-worker/src/playout/timings/partPlayback.ts @@ -195,6 +195,10 @@ export function reportPartInstanceHasStarted( playoutModel.setRundownStartedPlayback(partInstance.partInstance.rundownId, timestamp) } + if (partInstance.partInstance.segmentId !== playoutModel.previousPartInstance?.partInstance.segmentId) { + playoutModel.setSegmentStartedPlayback(partInstance.partInstance.segmentId, timestamp) + } + if (timestampUpdated) { playoutModel.queuePartInstanceTimingEvent(partInstance.partInstance._id) } From 16ca1522a89b000afbec66c761c40f0966479bc8 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Fri, 28 Jun 2024 07:11:11 +0200 Subject: [PATCH 060/276] fix: improve logging of error in handleUpdatedPackageInfoForRundown is ingestRundown is missing --- packages/job-worker/src/ingest/lock.ts | 7 ++++--- packages/job-worker/src/ingest/packageInfo.ts | 11 ++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/job-worker/src/ingest/lock.ts b/packages/job-worker/src/ingest/lock.ts index d5be901bf9..a37a6f0494 100644 --- a/packages/job-worker/src/ingest/lock.ts +++ b/packages/job-worker/src/ingest/lock.ts @@ -37,6 +37,7 @@ export interface CommitIngestData { } export enum UpdateIngestRundownAction { + REJECT = 'reject', DELETE = 'delete', } @@ -79,9 +80,9 @@ export async function runIngestJob( const updatedIngestRundown = updateCacheFcn(clone(oldIngestRundown)) let newIngestRundown: LocalIngestRundown | undefined switch (updatedIngestRundown) { - // case UpdateIngestRundownAction.REJECT: - // // Reject change - // return + case UpdateIngestRundownAction.REJECT: + // Reject change + return case UpdateIngestRundownAction.DELETE: ingestObjCache.delete() newIngestRundown = undefined diff --git a/packages/job-worker/src/ingest/packageInfo.ts b/packages/job-worker/src/ingest/packageInfo.ts index 616b5c0add..3dcc618a61 100644 --- a/packages/job-worker/src/ingest/packageInfo.ts +++ b/packages/job-worker/src/ingest/packageInfo.ts @@ -4,7 +4,7 @@ import { ExpectedPackagesRegenerateProps, PackageInfosUpdatedProps } from '@sofi import { logger } from '../logging' import { JobContext } from '../jobs' import { regenerateSegmentsFromIngestData } from './generationSegment' -import { runIngestJob, runWithRundownLock } from './lock' +import { UpdateIngestRundownAction, runIngestJob, runWithRundownLock } from './lock' import { CacheForIngest } from './cache' import { updateExpectedPackagesOnRundown } from './expectedPackages' @@ -41,11 +41,16 @@ export async function handleUpdatedPackageInfoForRundown( context, data, (ingestRundown) => { - if (!ingestRundown) throw new Error('onUpdatedPackageInfoForRundown called but ingestData is undefined') + if (!ingestRundown) { + logger.error( + `onUpdatedPackageInfoForRundown called but ingestRundown is undefined (rundownExternalId: "${data.rundownExternalId}")` + ) + return UpdateIngestRundownAction.REJECT + } return ingestRundown // don't mutate any ingest data }, async (context, cache, ingestRundown) => { - if (!ingestRundown) throw new Error('onUpdatedPackageInfoForRundown called but ingestData is undefined') + if (!ingestRundown) throw new Error('onUpdatedPackageInfoForRundown called but ingestRundown is undefined') /** All segments that need updating */ const segmentsToUpdate = new Set() From 2aa28c7f0523ec98ecab2ea122278f2daef6dce3 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Fri, 28 Jun 2024 07:12:35 +0200 Subject: [PATCH 061/276] fix: ignore PackageInfo updates for orphaned rundowns (because handleUpdatedPackageInfoForRundown() will error if there is no ingestRundown) --- meteor/server/api/ingest/packageInfo.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/meteor/server/api/ingest/packageInfo.ts b/meteor/server/api/ingest/packageInfo.ts index 50559cd61a..ea9f946ad4 100644 --- a/meteor/server/api/ingest/packageInfo.ts +++ b/meteor/server/api/ingest/packageInfo.ts @@ -77,6 +77,14 @@ async function onUpdatedPackageInfoForRundown( ) return } + if (tmpRundown.orphaned) { + logger.debug( + `onUpdatedPackageInfoForRundown: Ignoring Rundown "${rundownId}", because it is orphaned ("${ + tmpRundown.orphaned + }"), for packages "${packageIds.join(', ')}"` + ) + return + } await runIngestOperation(tmpRundown.studioId, IngestJobs.PackageInfosUpdated, { rundownExternalId: tmpRundown.externalId, From 8b252122c3265c629dd5c3647a412eaf464d61d1 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 9 Jul 2024 13:32:50 +0200 Subject: [PATCH 062/276] chore: don't run sonarCloud analysis for external PRs Because they will fail due to SonarCloud not supporting running on external PRs anyway. --- .github/workflows/sonar.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index 29a206dd6c..71ecc86846 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -12,7 +12,7 @@ jobs: sonarcloud: name: SonarCloud runs-on: ubuntu-latest - if: ${{ github.repository_owner == 'nrkno' }} + if: ${{ github.repository_owner == 'nrkno' && !github.event.pull_request.head.repo.fork }} steps: - uses: actions/checkout@v4 From fc71626b46c3dfc0599a9e28b35d8764b5f41104 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 11 Jul 2024 14:42:15 +0200 Subject: [PATCH 063/276] fix: add PM properties to HTTP accessor --- .../ui/Settings/Studio/PackageManager.tsx | 30 +++++++++++++++++++ .../shared-lib/src/package-manager/package.ts | 8 ++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/meteor/client/ui/Settings/Studio/PackageManager.tsx b/meteor/client/ui/Settings/Studio/PackageManager.tsx index d56ec1cb95..55e0c679e2 100644 --- a/meteor/client/ui/Settings/Studio/PackageManager.tsx +++ b/meteor/client/ui/Settings/Studio/PackageManager.tsx @@ -488,6 +488,36 @@ export const StudioPackageManagerSettings = withTranslation()( {t('Base url to the resource (example: http://myserver/folder)')} + +

+ + diff --git a/packages/webui/jest.config.cjs b/packages/webui/jest.config.cjs new file mode 100644 index 0000000000..060062dd60 --- /dev/null +++ b/packages/webui/jest.config.cjs @@ -0,0 +1,33 @@ +module.exports = { + setupFilesAfterEnv: ['./src/__mocks__/_setupMocks.ts', '/src/client/__tests__/jest-setup.cjs'], + globals: {}, + moduleFileExtensions: ['js', 'ts'], + moduleNameMapper: { + 'meteor/(.*)': '/src/meteor/$1', + }, + transform: { + '^.+\\.(ts|tsx)$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.jest.json', + }, + ], + '^.+\\.(js|jsx)$': ['babel-jest', { presets: ['@babel/preset-env'] }], + }, + transformIgnorePatterns: ['node_modules/(?!(nanoid)/)', '\\.pnp\\.[^\\/]+$'], + testMatch: ['**/__tests__/**/*.(spec|test).(ts|js)'], + testPathIgnorePatterns: ['integrationTests'], + testEnvironment: 'jsdom', + // coverageThreshold: { + // global: { + // branches: 80, + // functions: 100, + // lines: 95, + // statements: 90, + // }, + // }, + coverageDirectory: './coverage/', + coverageProvider: 'v8', + collectCoverage: true, + preset: 'ts-jest', +} diff --git a/packages/webui/package.json b/packages/webui/package.json new file mode 100644 index 0000000000..4b7e151ab1 --- /dev/null +++ b/packages/webui/package.json @@ -0,0 +1,112 @@ +{ + "name": "@sofie-automation/webui", + "private": true, + "version": "1.51.0-in-development", + "type": "module", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/nrkno/sofie-core.git", + "directory": "packages/webui" + }, + "bugs": { + "url": "https://github.com/nrkno/sofie-core/issues" + }, + "homepage": "https://github.com/nrkno/sofie-core/blob/master/packages/webui#readme", + "scripts": { + "dev": "vite --port=3005", + "build": "tsc -b && vite build", + "check-types": "tsc -p tsconfig.app.json --noEmit", + "preview": "vite preview", + "lint:raw": "run -T eslint --ext .ts,.tsx,.js,.jsx --ignore-pattern dist", + "lint": "run lint:raw .", + "unit": "run -T jest", + "test": "run lint && run unit", + "watch": "run -T jest --watch", + "cov": "run -T jest; run -T open-cli coverage/lcov-report/index.html", + "cov-open": "open-cli coverage/lcov-report/index.html", + "ci": "run test", + "validate:dependencies": "yarn npm audit --environment production && run license-validate", + "validate:dev-dependencies": "yarn npm audit --environment development", + "license-validate": "run -T sofie-licensecheck" + }, + "dependencies": { + "@crello/react-lottie": "0.0.9", + "@fortawesome/fontawesome-free": "^6.4.2", + "@fortawesome/fontawesome-svg-core": "^6.4.2", + "@fortawesome/free-solid-svg-icons": "^6.4.2", + "@fortawesome/react-fontawesome": "^0.2.0", + "@jstarpl/react-contextmenu": "^2.15.0", + "@mos-connection/helper": "^4.1.1-nightly-master-20240430-072032-ffb8bf6.0", + "@nrk/core-icons": "^9.6.0", + "@popperjs/core": "^2.11.8", + "@sofie-automation/blueprints-integration": "1.51.0-in-development", + "@sofie-automation/corelib": "1.51.0-in-development", + "@sofie-automation/shared-lib": "1.51.0-in-development", + "@sofie-automation/sorensen": "^1.4.2", + "@types/sinon": "^10.0.20", + "classnames": "^2.5.1", + "cubic-spline": "^3.0.3", + "deep-extend": "0.6.0", + "ejson": "^2.2.3", + "i18next": "^21.10.0", + "i18next-browser-languagedetector": "^6.1.8", + "i18next-http-backend": "^1.4.5", + "immutability-helper": "^3.1.1", + "lottie-web": "^5.12.2", + "moment": "^2.30.1", + "promise.allsettled": "^1.0.7", + "query-string": "^6.14.1", + "rc-tooltip": "^6.1.3", + "react": "^18.3.1", + "react-circular-progressbar": "^2.1.0", + "react-datepicker": "^3.8.0", + "react-dnd": "^14.0.5", + "react-dnd-html5-backend": "^14.1.0", + "react-dom": "^18.3.1", + "react-focus-bounder": "^1.1.6", + "react-hotkeys": "^2.0.0", + "react-i18next": "^11.18.6", + "react-intersection-observer": "^9.10.3", + "react-moment": "^0.9.7", + "react-popper": "^2.3.0", + "react-router-dom": "^5.3.4", + "react-timer-hoc": "^2.3.0", + "semver": "^7.6.3", + "sha.js": "^2.4.11", + "type-fest": "^3.13.1", + "underscore": "^1.13.7", + "velocity-animate": "^1.5.2", + "velocity-react": "^1.4.3", + "webmidi": "^2.5.3", + "xmlbuilder": "^15.1.1" + }, + "devDependencies": { + "@babel/preset-env": "^7.24.8", + "@types/classnames": "^2.3.1", + "@types/deep-extend": "^0.6.2", + "@types/react": "^18.3.3", + "@types/react-circular-progressbar": "^1.1.0", + "@types/react-datepicker": "^3.1.8", + "@types/react-dom": "^18.3.0", + "@types/react-router": "^5.1.20", + "@types/react-router-dom": "^5.3.3", + "@types/sha.js": "^2.4.4", + "@types/xml2js": "^0.4.14", + "@typescript-eslint/parser": "^7.17.0", + "@vitejs/plugin-react": "^4.3.1", + "@welldone-software/why-did-you-render": "^4.3.2", + "@xmldom/xmldom": "^0.8.10", + "babel-jest": "^29.7.0", + "sass": "^1.77.8", + "sinon": "^14.0.2", + "typescript": "^5.2.2", + "vite": "^5.3.4", + "vite-plugin-node-polyfills": "^0.22.0", + "vite-tsconfig-paths": "^4.3.2", + "xml2js": "^0.6.2" + }, + "engines": { + "node": ">=18" + } +} diff --git a/meteor/public/browserconfig.xml b/packages/webui/public/browserconfig.xml similarity index 100% rename from meteor/public/browserconfig.xml rename to packages/webui/public/browserconfig.xml diff --git a/meteor/public/favicon.ico b/packages/webui/public/favicon.ico similarity index 100% rename from meteor/public/favicon.ico rename to packages/webui/public/favicon.ico diff --git a/meteor/public/icons/android-chrome-192x192.png b/packages/webui/public/icons/android-chrome-192x192.png similarity index 100% rename from meteor/public/icons/android-chrome-192x192.png rename to packages/webui/public/icons/android-chrome-192x192.png diff --git a/meteor/public/icons/android-chrome-512x512.png b/packages/webui/public/icons/android-chrome-512x512.png similarity index 100% rename from meteor/public/icons/android-chrome-512x512.png rename to packages/webui/public/icons/android-chrome-512x512.png diff --git a/meteor/public/icons/apple-touch-icon.png b/packages/webui/public/icons/apple-touch-icon.png similarity index 100% rename from meteor/public/icons/apple-touch-icon.png rename to packages/webui/public/icons/apple-touch-icon.png diff --git a/meteor/public/icons/auto-presenter-screen.svg b/packages/webui/public/icons/auto-presenter-screen.svg similarity index 100% rename from meteor/public/icons/auto-presenter-screen.svg rename to packages/webui/public/icons/auto-presenter-screen.svg diff --git a/meteor/public/icons/favicon-16x16.png b/packages/webui/public/icons/favicon-16x16.png similarity index 100% rename from meteor/public/icons/favicon-16x16.png rename to packages/webui/public/icons/favicon-16x16.png diff --git a/meteor/public/icons/favicon-32x32.png b/packages/webui/public/icons/favicon-32x32.png similarity index 100% rename from meteor/public/icons/favicon-32x32.png rename to packages/webui/public/icons/favicon-32x32.png diff --git a/meteor/public/icons/freeze-presenter-screen.svg b/packages/webui/public/icons/freeze-presenter-screen.svg similarity index 100% rename from meteor/public/icons/freeze-presenter-screen.svg rename to packages/webui/public/icons/freeze-presenter-screen.svg diff --git a/meteor/public/icons/hourglass_icon.svg b/packages/webui/public/icons/hourglass_icon.svg similarity index 100% rename from meteor/public/icons/hourglass_icon.svg rename to packages/webui/public/icons/hourglass_icon.svg diff --git a/meteor/public/icons/maskable-512x512.png b/packages/webui/public/icons/maskable-512x512.png similarity index 100% rename from meteor/public/icons/maskable-512x512.png rename to packages/webui/public/icons/maskable-512x512.png diff --git a/meteor/public/icons/maskable-96x96.png b/packages/webui/public/icons/maskable-96x96.png similarity index 100% rename from meteor/public/icons/maskable-96x96.png rename to packages/webui/public/icons/maskable-96x96.png diff --git a/meteor/public/icons/mstile-144x144.png b/packages/webui/public/icons/mstile-144x144.png similarity index 100% rename from meteor/public/icons/mstile-144x144.png rename to packages/webui/public/icons/mstile-144x144.png diff --git a/meteor/public/icons/mstile-150x150.png b/packages/webui/public/icons/mstile-150x150.png similarity index 100% rename from meteor/public/icons/mstile-150x150.png rename to packages/webui/public/icons/mstile-150x150.png diff --git a/meteor/public/icons/mstile-310x150.png b/packages/webui/public/icons/mstile-310x150.png similarity index 100% rename from meteor/public/icons/mstile-310x150.png rename to packages/webui/public/icons/mstile-310x150.png diff --git a/meteor/public/icons/mstile-310x310.png b/packages/webui/public/icons/mstile-310x310.png similarity index 100% rename from meteor/public/icons/mstile-310x310.png rename to packages/webui/public/icons/mstile-310x310.png diff --git a/meteor/public/icons/mstile-70x70.png b/packages/webui/public/icons/mstile-70x70.png similarity index 100% rename from meteor/public/icons/mstile-70x70.png rename to packages/webui/public/icons/mstile-70x70.png diff --git a/meteor/public/icons/safari-pinned-tab.svg b/packages/webui/public/icons/safari-pinned-tab.svg similarity index 100% rename from meteor/public/icons/safari-pinned-tab.svg rename to packages/webui/public/icons/safari-pinned-tab.svg diff --git a/meteor/public/icons/sofie_clock_icon.ico b/packages/webui/public/icons/sofie_clock_icon.ico similarity index 100% rename from meteor/public/icons/sofie_clock_icon.ico rename to packages/webui/public/icons/sofie_clock_icon.ico diff --git a/meteor/public/icons/sofie_logo.ico b/packages/webui/public/icons/sofie_logo.ico similarity index 100% rename from meteor/public/icons/sofie_logo.ico rename to packages/webui/public/icons/sofie_logo.ico diff --git a/meteor/public/icons/sofie_studio-icon.ico b/packages/webui/public/icons/sofie_studio-icon.ico similarity index 100% rename from meteor/public/icons/sofie_studio-icon.ico rename to packages/webui/public/icons/sofie_studio-icon.ico diff --git a/meteor/public/icons/warning_icon.svg b/packages/webui/public/icons/warning_icon.svg similarity index 100% rename from meteor/public/icons/warning_icon.svg rename to packages/webui/public/icons/warning_icon.svg diff --git a/meteor/public/images/collapse-triangle.svg b/packages/webui/public/images/collapse-triangle.svg similarity index 100% rename from meteor/public/images/collapse-triangle.svg rename to packages/webui/public/images/collapse-triangle.svg diff --git a/meteor/public/images/cursor_zoom_area_move.png b/packages/webui/public/images/cursor_zoom_area_move.png similarity index 100% rename from meteor/public/images/cursor_zoom_area_move.png rename to packages/webui/public/images/cursor_zoom_area_move.png diff --git a/meteor/public/images/cursor_zoom_in.png b/packages/webui/public/images/cursor_zoom_in.png similarity index 100% rename from meteor/public/images/cursor_zoom_in.png rename to packages/webui/public/images/cursor_zoom_in.png diff --git a/meteor/public/images/freeze-frame-icicle.svg b/packages/webui/public/images/freeze-frame-icicle.svg similarity index 100% rename from meteor/public/images/freeze-frame-icicle.svg rename to packages/webui/public/images/freeze-frame-icicle.svg diff --git a/meteor/public/images/multi-step-chevrons-default.svg b/packages/webui/public/images/multi-step-chevrons-default.svg similarity index 100% rename from meteor/public/images/multi-step-chevrons-default.svg rename to packages/webui/public/images/multi-step-chevrons-default.svg diff --git a/meteor/public/images/multi-step-chevrons.svg b/packages/webui/public/images/multi-step-chevrons.svg similarity index 100% rename from meteor/public/images/multi-step-chevrons.svg rename to packages/webui/public/images/multi-step-chevrons.svg diff --git a/meteor/public/images/previewBG.jpg b/packages/webui/public/images/previewBG.jpg similarity index 100% rename from meteor/public/images/previewBG.jpg rename to packages/webui/public/images/previewBG.jpg diff --git a/meteor/public/images/previewBG.png b/packages/webui/public/images/previewBG.png similarity index 100% rename from meteor/public/images/previewBG.png rename to packages/webui/public/images/previewBG.png diff --git a/meteor/public/images/screen-saver-bkg.svg b/packages/webui/public/images/screen-saver-bkg.svg similarity index 100% rename from meteor/public/images/screen-saver-bkg.svg rename to packages/webui/public/images/screen-saver-bkg.svg diff --git a/meteor/public/images/sofie-logo-christmas.svg b/packages/webui/public/images/sofie-logo-christmas.svg similarity index 100% rename from meteor/public/images/sofie-logo-christmas.svg rename to packages/webui/public/images/sofie-logo-christmas.svg diff --git a/meteor/public/images/sofie-logo-norway.svg b/packages/webui/public/images/sofie-logo-norway.svg similarity index 100% rename from meteor/public/images/sofie-logo-norway.svg rename to packages/webui/public/images/sofie-logo-norway.svg diff --git a/meteor/public/images/sofie-logo-pride.svg b/packages/webui/public/images/sofie-logo-pride.svg similarity index 100% rename from meteor/public/images/sofie-logo-pride.svg rename to packages/webui/public/images/sofie-logo-pride.svg diff --git a/meteor/public/images/sofie-logo.png b/packages/webui/public/images/sofie-logo.png similarity index 100% rename from meteor/public/images/sofie-logo.png rename to packages/webui/public/images/sofie-logo.png diff --git a/meteor/public/images/sofie-logo.svg b/packages/webui/public/images/sofie-logo.svg similarity index 100% rename from meteor/public/images/sofie-logo.svg rename to packages/webui/public/images/sofie-logo.svg diff --git a/meteor/public/images/sofie-logo_black_bg.png b/packages/webui/public/images/sofie-logo_black_bg.png similarity index 100% rename from meteor/public/images/sofie-logo_black_bg.png rename to packages/webui/public/images/sofie-logo_black_bg.png diff --git a/meteor/public/images/warning-transferring.webp b/packages/webui/public/images/warning-transferring.webp similarity index 100% rename from meteor/public/images/warning-transferring.webp rename to packages/webui/public/images/warning-transferring.webp diff --git a/meteor/public/images/zoom_area_move.svg b/packages/webui/public/images/zoom_area_move.svg similarity index 100% rename from meteor/public/images/zoom_area_move.svg rename to packages/webui/public/images/zoom_area_move.svg diff --git a/meteor/public/locales/en/translations.json b/packages/webui/public/locales/en/translations.json similarity index 100% rename from meteor/public/locales/en/translations.json rename to packages/webui/public/locales/en/translations.json diff --git a/meteor/public/locales/nb/translations.json b/packages/webui/public/locales/nb/translations.json similarity index 100% rename from meteor/public/locales/nb/translations.json rename to packages/webui/public/locales/nb/translations.json diff --git a/meteor/public/locales/nn/translations.json b/packages/webui/public/locales/nn/translations.json similarity index 100% rename from meteor/public/locales/nn/translations.json rename to packages/webui/public/locales/nn/translations.json diff --git a/meteor/public/locales/sv/translations.json b/packages/webui/public/locales/sv/translations.json similarity index 100% rename from meteor/public/locales/sv/translations.json rename to packages/webui/public/locales/sv/translations.json diff --git a/meteor/public/origo-ui/dist/origo.css b/packages/webui/public/origo-ui/dist/origo.css similarity index 100% rename from meteor/public/origo-ui/dist/origo.css rename to packages/webui/public/origo-ui/dist/origo.css diff --git a/meteor/public/origo-ui/images/batch-icon-complete-dark.png b/packages/webui/public/origo-ui/images/batch-icon-complete-dark.png similarity index 100% rename from meteor/public/origo-ui/images/batch-icon-complete-dark.png rename to packages/webui/public/origo-ui/images/batch-icon-complete-dark.png diff --git a/meteor/public/origo-ui/images/batch-icon-complete-light.png b/packages/webui/public/origo-ui/images/batch-icon-complete-light.png similarity index 100% rename from meteor/public/origo-ui/images/batch-icon-complete-light.png rename to packages/webui/public/origo-ui/images/batch-icon-complete-light.png diff --git a/meteor/public/origo-ui/images/batch-icon-dark.png b/packages/webui/public/origo-ui/images/batch-icon-dark.png similarity index 100% rename from meteor/public/origo-ui/images/batch-icon-dark.png rename to packages/webui/public/origo-ui/images/batch-icon-dark.png diff --git a/meteor/public/origo-ui/images/batch-icon-light.png b/packages/webui/public/origo-ui/images/batch-icon-light.png similarity index 100% rename from meteor/public/origo-ui/images/batch-icon-light.png rename to packages/webui/public/origo-ui/images/batch-icon-light.png diff --git a/meteor/public/origo-ui/images/loading-spinner.svg b/packages/webui/public/origo-ui/images/loading-spinner.svg similarity index 100% rename from meteor/public/origo-ui/images/loading-spinner.svg rename to packages/webui/public/origo-ui/images/loading-spinner.svg diff --git a/meteor/public/origo-ui/images/thumb-scrubber-active.png b/packages/webui/public/origo-ui/images/thumb-scrubber-active.png similarity index 100% rename from meteor/public/origo-ui/images/thumb-scrubber-active.png rename to packages/webui/public/origo-ui/images/thumb-scrubber-active.png diff --git a/meteor/public/origo-ui/images/thumb-scrubber.png b/packages/webui/public/origo-ui/images/thumb-scrubber.png similarity index 100% rename from meteor/public/origo-ui/images/thumb-scrubber.png rename to packages/webui/public/origo-ui/images/thumb-scrubber.png diff --git a/meteor/public/origo-ui/images/tooltip-pointer-dark.png b/packages/webui/public/origo-ui/images/tooltip-pointer-dark.png similarity index 100% rename from meteor/public/origo-ui/images/tooltip-pointer-dark.png rename to packages/webui/public/origo-ui/images/tooltip-pointer-dark.png diff --git a/meteor/public/origo-ui/images/tooltip-pointer.png b/packages/webui/public/origo-ui/images/tooltip-pointer.png similarity index 100% rename from meteor/public/origo-ui/images/tooltip-pointer.png rename to packages/webui/public/origo-ui/images/tooltip-pointer.png diff --git a/meteor/public/sw.js b/packages/webui/public/sw.js similarity index 100% rename from meteor/public/sw.js rename to packages/webui/public/sw.js diff --git a/packages/webui/src/__mocks__/_setupMocks.ts b/packages/webui/src/__mocks__/_setupMocks.ts new file mode 100644 index 0000000000..966d33c4e5 --- /dev/null +++ b/packages/webui/src/__mocks__/_setupMocks.ts @@ -0,0 +1,23 @@ +import { resetRandomId } from './random' + +// This file is run before all tests start. + +// 'Mock' the random string generator +jest.mock('nanoid', (...args) => require('./random').setup(args), { virtual: true }) + +// Add references to all "meteor" mocks below, so that jest resolves the imports properly. + +jest.mock('meteor/meteor', (...args) => require('./meteor').setup(args), { virtual: true }) +jest.mock('meteor/random', (...args) => require('./random').setup(args), { virtual: true }) +jest.mock('meteor/check', (...args) => require('./check').setup(args), { virtual: true }) +jest.mock('meteor/tracker', (...args) => require('./tracker').setup(args), { virtual: true }) +// jest.mock('meteor/ejson', (...args) => require('./ejson').setup(args), { virtual: true }) +// jest.mock('meteor/reactive-var', (...args) => require('./reactive-var').setup(args), { virtual: true }) + +jest.mock('meteor/mongo', (...args) => require('./mongo').setup(args), { virtual: true }) + +beforeEach(() => { + // put setLogLevel('info') in the beginning of your test to see logs + + resetRandomId() +}) diff --git a/packages/webui/src/__mocks__/check/README b/packages/webui/src/__mocks__/check/README new file mode 100644 index 0000000000..369c4fd5ec --- /dev/null +++ b/packages/webui/src/__mocks__/check/README @@ -0,0 +1 @@ +Copied from: https://github.com/meteor/meteor/tree/devel/packages/check 2c7616d2211fe96b9d4996eafd5b1b3654d5daca diff --git a/packages/webui/src/__mocks__/check/index.ts b/packages/webui/src/__mocks__/check/index.ts new file mode 100644 index 0000000000..0ee6a3dbaf --- /dev/null +++ b/packages/webui/src/__mocks__/check/index.ts @@ -0,0 +1,6 @@ +// @ts-expect-error No types available +import * as match from './match.cjs' + +export function setup(): any { + return match +} diff --git a/packages/webui/src/__mocks__/check/isPlainObject.cjs b/packages/webui/src/__mocks__/check/isPlainObject.cjs new file mode 100644 index 0000000000..babeeac32d --- /dev/null +++ b/packages/webui/src/__mocks__/check/isPlainObject.cjs @@ -0,0 +1,34 @@ +// Copy of jQuery.isPlainObject for the server side from jQuery v3.1.1. + +var class2type = {} + +var toString = class2type.toString + +var hasOwn = class2type.hasOwnProperty + +var fnToString = hasOwn.toString + +var ObjectFunctionString = fnToString.call(Object) + +var getProto = Object.getPrototypeOf + +exports.isPlainObject = function (obj) { + var proto, Ctor + + // Detect obvious negatives + // Use toString instead of jQuery.type to catch host objects + if (!obj || toString.call(obj) !== '[object Object]') { + return false + } + + proto = getProto(obj) + + // Objects with no prototype (e.g., `Object.create( null )`) are plain + if (!proto) { + return true + } + + // Objects with prototype are plain iff they were constructed by a global Object function + Ctor = hasOwn.call(proto, 'constructor') && proto.constructor + return typeof Ctor === 'function' && fnToString.call(Ctor) === ObjectFunctionString +} diff --git a/packages/webui/src/__mocks__/check/match.cjs b/packages/webui/src/__mocks__/check/match.cjs new file mode 100644 index 0000000000..6811822988 --- /dev/null +++ b/packages/webui/src/__mocks__/check/match.cjs @@ -0,0 +1,561 @@ +/* eslint-disable */ + +// XXX docs + +// Things we explicitly do NOT support: +// - heterogenous arrays + +const { Meteor } = require('meteor/meteor') +const util = require('util') + +Meteor.makeErrorType = function (name, constructor) { + var errorClass = function (/*arguments*/) { + // Ensure we get a proper stack trace in most Javascript environments + if (Error.captureStackTrace) { + // V8 environments (Chrome and Node.js) + Error.captureStackTrace(this, errorClass) + } else { + // Borrow the .stack property of a native Error object. + this.stack = new Error().stack + } + // Safari magically works. + + constructor.apply(this, arguments) + + this.errorType = name + } + + util.inherits(errorClass, Error) + + return errorClass +} + +// var currentArgumentChecker = new Meteor.EnvironmentVariable; +var isPlainObject = require('./isPlainObject.cjs').isPlainObject +var hasOwn = Object.prototype.hasOwnProperty + +/** + * @summary Check that a value matches a [pattern](#matchpatterns). + * If the value does not match the pattern, throw a `Match.Error`. + * + * Particularly useful to assert that arguments to a function have the right + * types and structure. + * @locus Anywhere + * @param {Any} value The value to check + * @param {MatchPattern} pattern The pattern to match + * `value` against + */ +function check(value, pattern) { + // Record that check got called, if somebody cared. + // + // We use getOrNullIfOutsideFiber so that it's OK to call check() + // from non-Fiber server contexts; the downside is that if you forget to + // bindEnvironment on some random callback in your method/publisher, + // it might not find the argumentChecker and you'll get an error about + // not checking an argument that it looks like you're checking (instead + // of just getting a "Node code must run in a Fiber" error). + // var argChecker = currentArgumentChecker.getOrNullIfOutsideFiber(); + // if (argChecker) + // argChecker.checking(value); + var result = testSubtree(value, pattern) + if (result) { + var err = new Match.Error(result.message) + if (result.path) { + err.message += ' in field ' + result.path + err.path = result.path + } + throw err + } +} + +/** + * @namespace Match + * @summary The namespace for all Match types and methods. + */ +const Match = { + Optional: function (pattern) { + return new Optional(pattern) + }, + Maybe: function (pattern) { + return new Maybe(pattern) + }, + OneOf: function (...args) { + return new OneOf(args) + }, + Any: ['__any__'], + Where: function (condition) { + return new Where(condition) + }, + ObjectIncluding: function (pattern) { + return new ObjectIncluding(pattern) + }, + ObjectWithValues: function (pattern) { + return new ObjectWithValues(pattern) + }, + // Matches only signed 32-bit integers + Integer: ['__integer__'], + + // XXX matchers should know how to describe themselves for errors + Error: Meteor.makeErrorType('Match.Error', function (msg) { + this.message = 'Match error: ' + msg + // The path of the value that failed to match. Initially empty, this gets + // populated by catching and rethrowing the exception as it goes back up the + // stack. + // E.g.: "vals[3].entity.created" + this.path = '' + // If this gets sent over DDP, don't give full internal details but at least + // provide something better than 500 Internal server error. + this.sanitizedError = new Meteor.Error(400, 'Match failed') + }), + + // Tests to see if value matches pattern. Unlike check, it merely returns true + // or false (unless an error other than Match.Error was thrown). It does not + // interact with _failIfArgumentsAreNotAllChecked. + // XXX maybe also implement a Match.match which returns more information about + // failures but without using exception handling or doing what check() + // does with _failIfArgumentsAreNotAllChecked and Meteor.Error conversion + + /** + * @summary Returns true if the value matches the pattern. + * @locus Anywhere + * @param {Any} value The value to check + * @param {MatchPattern} pattern The pattern to match `value` against + */ + test(value, pattern) { + return !testSubtree(value, pattern) + }, + + // Runs `f.apply(context, args)`. If check() is not called on every element of + // `args` (either directly or in the first level of an array), throws an error + // (using `description` in the message). + // + _failIfArgumentsAreNotAllChecked(f, context, args, description) { + var argChecker = new ArgumentChecker(args, description) + var result = currentArgumentChecker.withValue(argChecker, function () { + return f.apply(context, args) + }) + // If f didn't itself throw, make sure it checked all of its arguments. + argChecker.throwUnlessAllArgumentsHaveBeenChecked() + return result + }, +} + +class Optional { + constructor(pattern) { + this.pattern = pattern + } +} + +class Maybe { + constructor(pattern) { + this.pattern = pattern + } +} + +class OneOf { + constructor(choices) { + if (!choices || choices.length === 0) throw new Error('Must provide at least one choice to Match.OneOf') + this.choices = choices + } +} + +class Where { + constructor(condition) { + this.condition = condition + } +} + +class ObjectIncluding { + constructor(pattern) { + this.pattern = pattern + } +} + +class ObjectWithValues { + constructor(pattern) { + this.pattern = pattern + } +} + +var stringForErrorMessage = function (value, options) { + options = options || {} + + if (value === null) return 'null' + + if (options.onlyShowType) { + return typeof value + } + + // This is a simplification of the actual stringForErrorMessage implementation + try { + return JSON.stringify(value) + } catch (stringifyError) { + return typeof value + } +} + +var typeofChecks = [ + [String, 'string'], + [Number, 'number'], + [Boolean, 'boolean'], + // While we don't allow undefined/function in EJSON, this is good for optional + // arguments with OneOf. + [Function, 'function'], + [undefined, 'undefined'], +] + +// Return `false` if it matches. Otherwise, return an object with a `message` and a `path` field. +var testSubtree = function (value, pattern) { + // Match anything! + if (pattern === Match.Any) return false + + // Basic atomic types. + // Do not match boxed objects (e.g. String, Boolean) + for (var i = 0; i < typeofChecks.length; ++i) { + if (pattern === typeofChecks[i][0]) { + if (typeof value === typeofChecks[i][1]) return false + return { + message: 'Expected ' + typeofChecks[i][1] + ', got ' + stringForErrorMessage(value, { onlyShowType: true }), + path: '', + } + } + } + + if (pattern === null) { + if (value === null) { + return false + } + return { + message: 'Expected null, got ' + stringForErrorMessage(value), + path: '', + } + } + + // Strings, numbers, and booleans match literally. Goes well with Match.OneOf. + if (typeof pattern === 'string' || typeof pattern === 'number' || typeof pattern === 'boolean') { + if (value === pattern) return false + return { + message: 'Expected ' + pattern + ', got ' + stringForErrorMessage(value), + path: '', + } + } + + // Match.Integer is special type encoded with array + if (pattern === Match.Integer) { + // There is no consistent and reliable way to check if variable is a 64-bit + // integer. One of the popular solutions is to get reminder of division by 1 + // but this method fails on really large floats with big precision. + // E.g.: 1.348192308491824e+23 % 1 === 0 in V8 + // Bitwise operators work consistantly but always cast variable to 32-bit + // signed integer according to JavaScript specs. + if (typeof value === 'number' && (value | 0) === value) return false + return { + message: 'Expected Integer, got ' + stringForErrorMessage(value), + path: '', + } + } + + // "Object" is shorthand for Match.ObjectIncluding({}); + if (pattern === Object) pattern = Match.ObjectIncluding({}) + + // Array (checked AFTER Any, which is implemented as an Array). + if (pattern instanceof Array) { + if (pattern.length !== 1) { + return { + message: 'Bad pattern: arrays must have one type element' + stringForErrorMessage(pattern), + path: '', + } + } + if (!Array.isArray(value) && !isArguments(value)) { + return { + message: 'Expected array, got ' + stringForErrorMessage(value), + path: '', + } + } + + for (var i = 0, length = value.length; i < length; i++) { + var result = testSubtree(value[i], pattern[0]) + if (result) { + result.path = _prependPath(i, result.path) + return result + } + } + return false + } + + // Arbitrary validation checks. The condition can return false or throw a + // Match.Error (ie, it can internally use check()) to fail. + if (pattern instanceof Where) { + var result + try { + result = pattern.condition(value) + } catch (err) { + if (!(err instanceof Match.Error)) throw err + return { + message: err.message, + path: err.path, + } + } + if (result) return false + // XXX this error is terrible + return { + message: 'Failed Match.Where validation', + path: '', + } + } + + if (pattern instanceof Maybe) { + pattern = Match.OneOf(undefined, null, pattern.pattern) + } else if (pattern instanceof Optional) { + pattern = Match.OneOf(undefined, pattern.pattern) + } + + if (pattern instanceof OneOf) { + for (var i = 0; i < pattern.choices.length; ++i) { + var result = testSubtree(value, pattern.choices[i]) + if (!result) { + // No error? Yay, return. + return false + } + // Match errors just mean try another choice. + } + // XXX this error is terrible + return { + message: 'Failed Match.OneOf, Match.Maybe or Match.Optional validation', + path: '', + } + } + + // A function that isn't something we special-case is assumed to be a + // constructor. + if (pattern instanceof Function) { + if (value instanceof pattern) return false + return { + message: 'Expected ' + (pattern.name || 'particular constructor'), + path: '', + } + } + + var unknownKeysAllowed = false + var unknownKeyPattern + if (pattern instanceof ObjectIncluding) { + unknownKeysAllowed = true + pattern = pattern.pattern + } + if (pattern instanceof ObjectWithValues) { + unknownKeysAllowed = true + unknownKeyPattern = [pattern.pattern] + pattern = {} // no required keys + } + + if (typeof pattern !== 'object') { + return { + message: 'Bad pattern: unknown pattern type', + path: '', + } + } + + // An object, with required and optional keys. Note that this does NOT do + // structural matches against objects of special types that happen to match + // the pattern: this really needs to be a plain old {Object}! + if (typeof value !== 'object') { + return { + message: 'Expected object, got ' + typeof value, + path: '', + } + } + if (value === null) { + return { + message: 'Expected object, got null', + path: '', + } + } + if (!isPlainObject(value)) { + return { + message: 'Expected plain object', + path: '', + } + } + + var requiredPatterns = {} + var optionalPatterns = {} + + Object.keys(pattern).forEach((key) => { + const subPattern = pattern[key] + if (subPattern instanceof Optional || subPattern instanceof Maybe) { + optionalPatterns[key] = subPattern.pattern + } else { + requiredPatterns[key] = subPattern + } + }) + + for (var key in Object(value)) { + var subValue = value[key] + if (hasOwn.call(requiredPatterns, key)) { + var result = testSubtree(subValue, requiredPatterns[key]) + if (result) { + result.path = _prependPath(key, result.path) + return result + } + delete requiredPatterns[key] + } else if (hasOwn.call(optionalPatterns, key)) { + var result = testSubtree(subValue, optionalPatterns[key]) + if (result) { + result.path = _prependPath(key, result.path) + return result + } + } else { + if (!unknownKeysAllowed) { + return { + message: 'Unknown key', + path: key, + } + } + if (unknownKeyPattern) { + var result = testSubtree(subValue, unknownKeyPattern[0]) + if (result) { + result.path = _prependPath(key, result.path) + return result + } + } + } + } + + var keys = Object.keys(requiredPatterns) + if (keys.length) { + return { + message: "Missing key '" + keys[0] + "'", + path: '', + } + } +} + +class ArgumentChecker { + constructor(args, description) { + // Make a SHALLOW copy of the arguments. (We'll be doing identity checks + // against its contents.) + this.args = [...args] + // Since the common case will be to check arguments in order, and we splice + // out arguments when we check them, make it so we splice out from the end + // rather than the beginning. + this.args.reverse() + this.description = description + } + + checking(value) { + if (this._checkingOneValue(value)) return + // Allow check(arguments, [String]) or check(arguments.slice(1), [String]) + // or check([foo, bar], [String]) to count... but only if value wasn't + // itself an argument. + if (Array.isArray(value) || isArguments(value)) { + Array.prototype.forEach.call(value, this._checkingOneValue.bind(this)) + } + } + + _checkingOneValue(value) { + for (var i = 0; i < this.args.length; ++i) { + // Is this value one of the arguments? (This can have a false positive if + // the argument is an interned primitive, but it's still a good enough + // check.) + // (NaN is not === to itself, so we have to check specially.) + if (value === this.args[i] || (Number.isNaN(value) && Number.isNaN(this.args[i]))) { + this.args.splice(i, 1) + return true + } + } + return false + } + + throwUnlessAllArgumentsHaveBeenChecked() { + if (this.args.length > 0) throw new Error('Did not check() all arguments during ' + this.description) + } +} + +var _jsKeywords = [ + 'do', + 'if', + 'in', + 'for', + 'let', + 'new', + 'try', + 'var', + 'case', + 'else', + 'enum', + 'eval', + 'false', + 'null', + 'this', + 'true', + 'void', + 'with', + 'break', + 'catch', + 'class', + 'const', + 'super', + 'throw', + 'while', + 'yield', + 'delete', + 'export', + 'import', + 'public', + 'return', + 'static', + 'switch', + 'typeof', + 'default', + 'extends', + 'finally', + 'package', + 'private', + 'continue', + 'debugger', + 'function', + 'arguments', + 'interface', + 'protected', + 'implements', + 'instanceof', +] + +// Assumes the base of path is already escaped properly +// returns key + base +function _prependPath(key, base) { + if (typeof key === 'number' || key.match(/^\d+$/)) { + key = '[' + key + ']' + } else if (!key.match(/^[a-z_$][0-9a-z_$]*$/i) || _jsKeywords.indexOf(key) >= 0) { + key = JSON.stringify([key]) + } + + if (base && base[0] !== '[') { + return key + '.' + base + } + + return key + base +} + +function isObject(value) { + return typeof value === 'object' && value !== null +} + +function baseIsArguments(item) { + return isObject(item) && Object.prototype.toString.call(item) === '[object Arguments]' +} + +var isArguments = baseIsArguments( + (function () { + return arguments + })() +) + ? baseIsArguments + : function (value) { + return isObject(value) && typeof value.callee === 'function' + } + +module.exports = { + check, + Match, +} diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts new file mode 100644 index 0000000000..a7a43da0ce --- /dev/null +++ b/packages/webui/src/__mocks__/defaultCollectionObjects.ts @@ -0,0 +1,215 @@ +import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { clone, getCurrentTime, protectString, unprotectString } from '../lib/lib' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { IBlueprintPieceType, PieceLifespan } from '@sofie-automation/blueprints-integration' +import { Piece, EmptyPieceTimelineObjectsBlob } from '@sofie-automation/corelib/dist/dataModel/Piece' +import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' +import { wrapDefaultObject } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' +import { PartInstance } from '../lib/collections/PartInstances' +import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { + PartId, + PartInstanceId, + PeripheralDeviceId, + PieceId, + PieceInstanceId, + RundownId, + RundownPlaylistActivationId, + RundownPlaylistId, + SegmentId, + SegmentPlayoutId, + ShowStyleBaseId, + ShowStyleVariantId, + StudioId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DEFAULT_MINIMUM_TAKE_SPAN } from '@sofie-automation/shared-lib/dist/core/constants' + +export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioId): DBRundownPlaylist { + return { + _id: _id, + + externalId: 'MOCK_RUNDOWNPLAYLIST', + organizationId: null, + studioId: studioId, + + name: 'Default RundownPlaylist', + created: getCurrentTime(), + modified: getCurrentTime(), + + // activationId: undefined, + rehearsal: false, + currentPartInfo: null, + nextPartInfo: null, + previousPartInfo: null, + timing: { + type: 'none' as any, + }, + rundownIdsInOrder: [], + } +} +export function defaultRundown( + externalId: string, + studioId: StudioId, + ingestDeviceId: PeripheralDeviceId, + playlistId: RundownPlaylistId, + showStyleBaseId: ShowStyleBaseId, + showStyleVariantId: ShowStyleVariantId +): DBRundown { + return { + studioId: studioId, + showStyleBaseId: showStyleBaseId, + showStyleVariantId: showStyleVariantId, + + organizationId: null, + + playlistId: playlistId, + + _id: protectString(`rundown_${studioId}_${externalId}`), + externalId: externalId, + name: 'Default Rundown', + + created: getCurrentTime(), + modified: getCurrentTime(), + importVersions: { + studio: '', + showStyleBase: '', + showStyleVariant: '', + blueprint: '', + core: '', + }, + + timing: { + type: 'none' as any, + }, + source: { + type: 'nrcs', + peripheralDeviceId: ingestDeviceId, + nrcsName: 'mock', + }, + } +} + +export function defaultStudio(_id: StudioId): DBStudio { + return { + _id: _id, + + name: 'mockStudio', + organizationId: null, + mappingsWithOverrides: wrapDefaultObject({}), + supportedShowStyleBase: [], + blueprintConfigWithOverrides: wrapDefaultObject({}), + settings: { + frameRate: 25, + mediaPreviewsUrl: '', + minimumTakeSpan: DEFAULT_MINIMUM_TAKE_SPAN, + }, + _rundownVersionHash: '', + routeSets: {}, + routeSetExclusivityGroups: {}, + packageContainers: {}, + previewContainerIds: [], + thumbnailContainerIds: [], + peripheralDeviceSettings: { + playoutDevices: wrapDefaultObject({}), + ingestDevices: wrapDefaultObject({}), + inputDevices: wrapDefaultObject({}), + }, + lastBlueprintConfig: undefined, + lastBlueprintFixUpHash: undefined, + } +} + +export function defaultSegment(_id: SegmentId, rundownId: RundownId): DBSegment { + return { + _id: _id, + _rank: 0, + externalId: unprotectString(_id), + rundownId: rundownId, + name: 'Default Segment', + externalModified: 1, + } +} + +export function defaultPart(_id: PartId, rundownId: RundownId, segmentId: SegmentId): DBPart { + return { + _id: _id, + rundownId: rundownId, + segmentId: segmentId, + _rank: 0, + externalId: unprotectString(_id), + title: 'Default Part', + expectedDurationWithPreroll: undefined, + } +} +export function defaultPiece(_id: PieceId, rundownId: RundownId, segmentId: SegmentId, partId: PartId): Piece { + return { + _id: _id, + externalId: 'MOCK_PIECE', + startRundownId: rundownId, + startSegmentId: segmentId, + startPartId: partId, + name: 'Default Piece', + lifespan: PieceLifespan.WithinPart, + pieceType: IBlueprintPieceType.Normal, + invalid: false, + enable: { + start: 0, + }, + sourceLayerId: '', + outputLayerId: '', + content: {}, + timelineObjectsString: EmptyPieceTimelineObjectsBlob, + } +} +export function defaultAdLibPiece(_id: PieceId, rundownId: RundownId, partId: PartId): AdLibPiece { + return { + _id: _id, + externalId: 'MOCK_ADLIB', + rundownId: rundownId, + partId: partId, + _rank: 0, + name: 'Default Adlib', + lifespan: PieceLifespan.WithinPart, + sourceLayerId: '', + outputLayerId: '', + content: {}, + timelineObjectsString: EmptyPieceTimelineObjectsBlob, + } +} +export function defaultPartInstance( + _id: PartInstanceId, + playlistActivationId: RundownPlaylistActivationId, + segmentPlayoutId: SegmentPlayoutId, + part: DBPart +): PartInstance { + return { + _id, + isTemporary: false, + part: clone(part), + playlistActivationId, + rehearsal: false, + rundownId: part.rundownId, + segmentId: part.segmentId, + takeCount: 0, + segmentPlayoutId, + } +} +export function defaultPieceInstance( + _id: PieceInstanceId, + playlistActivationId: RundownPlaylistActivationId, + rundownId: RundownId, + partInstanceId: PartInstanceId, + piece: Piece +): PieceInstance { + return { + _id, + partInstanceId, + piece: clone(piece), + playlistActivationId, + rundownId, + isTemporary: false, + } +} diff --git a/packages/webui/src/__mocks__/helpers/database.ts b/packages/webui/src/__mocks__/helpers/database.ts new file mode 100644 index 0000000000..991403a070 --- /dev/null +++ b/packages/webui/src/__mocks__/helpers/database.ts @@ -0,0 +1,547 @@ +import * as _ from 'underscore' +import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { + PieceLifespan, + IOutputLayer, + ISourceLayer, + SourceLayerType, + IBlueprintPieceType, +} from '@sofie-automation/blueprints-integration' +import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' +import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' +import { ICoreSystem, SYSTEM_ID } from '../../lib/collections/CoreSystem' +import { literal, getCurrentTime, protectString, getRandomId, Complete, normalizeArray } from '../../lib/lib' +import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { EmptyPieceTimelineObjectsBlob, Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibPiece' +import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' +import { restartRandomId } from '../random' +import { MongoMock } from '../mongo' +import { defaultRundownPlaylist, defaultStudio } from '../defaultCollectionObjects' +import { + applyAndValidateOverrides, + wrapDefaultObject, +} from '@sofie-automation/corelib/dist/settings/objectWithOverrides' +import { UIShowStyleBase } from '../../lib/api/showStyles' +import { + BlueprintId, + OrganizationId, + RundownId, + RundownPlaylistId, + ShowStyleBaseId, + ShowStyleVariantId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { + AdLibPieces, + CoreSystem, + Parts, + Pieces, + RundownBaselineAdLibPieces, + RundownPlaylists, + Rundowns, + Segments, + ShowStyleBases, + ShowStyleVariants, + Studios, +} from '../../client/collections' + +export enum LAYER_IDS { + SOURCE_CAM0 = 'cam0', + SOURCE_VT0 = 'vt0', + SOURCE_TRANSITION0 = 'transition0', + SOURCE_GRAPHICS0 = 'graphics0', + OUTPUT_PGM = 'pgm', +} + +let dbI = 0 +export async function setupMockCore(doc?: Partial): Promise { + // Reset everything mongo, to keep the ids predictable + restartRandomId() + MongoMock.deleteAllData() + + doc = doc || {} + + const defaultCore: ICoreSystem = { + _id: SYSTEM_ID, + name: 'mock Core', + created: 0, + modified: 0, + version: '0.0.0', + previousVersion: '0.0.0', + serviceMessages: {}, + } + const coreSystem = _.extend(defaultCore, doc) + CoreSystem.remove(SYSTEM_ID) + CoreSystem.insert(coreSystem) + return coreSystem +} +// export async function setupMockTriggeredActions( +// showStyleBaseId: ShowStyleBaseId | null = null, +// num = 3, +// doc?: Partial +// ): Promise { +// doc = doc || {} +// const mocks: DBTriggeredActions[] = [] +// for (let i = 0; i < num; i++) { +// const mock: DBTriggeredActions = { +// _id: protectString(`mockTriggeredAction_${showStyleBaseId ?? 'core'}` + i), +// _rank: i * 1000, +// showStyleBaseId, +// blueprintUniqueId: null, +// actionsWithOverrides: wrapDefaultObject({ +// '0': { +// action: PlayoutActions.adlib, +// filterChain: [ +// { +// object: 'adLib', +// field: 'global', +// value: true, +// }, +// { +// object: 'adLib', +// field: 'pick', +// value: i, +// }, +// ], +// }, +// }), +// triggersWithOverrides: wrapDefaultObject({ +// '0': { +// type: TriggerType.hotkey, +// keys: `Key${String.fromCharCode(65 + i)}`, // KeyA and so on +// }, +// }), +// ...doc, +// } +// mocks.push(mock) +// TriggeredActions.insert(mock) +// } +// return mocks +// } +export async function setupMockStudio(doc?: Partial): Promise { + doc = doc || {} + + const studio: DBStudio = { + ...defaultStudio(protectString('mockStudio' + dbI++)), + name: 'mockStudio', + _rundownVersionHash: 'asdf', + ...doc, + } + Studios.insert(studio) + return studio +} +export async function setupMockShowStyleBase( + blueprintId: BlueprintId, + doc?: Partial +): Promise { + doc = doc || {} + + const defaultShowStyleBase: DBShowStyleBase = { + _id: protectString('mockShowStyleBase' + dbI++), + name: 'mockShowStyleBase', + organizationId: null, + outputLayersWithOverrides: wrapDefaultObject( + normalizeArray( + [ + literal({ + _id: LAYER_IDS.OUTPUT_PGM, + _rank: 0, + isPGM: true, + name: 'PGM', + }), + ], + '_id' + ) + ), + sourceLayersWithOverrides: wrapDefaultObject( + normalizeArray( + [ + literal({ + _id: LAYER_IDS.SOURCE_CAM0, + _rank: 0, + name: 'Camera', + type: SourceLayerType.CAMERA, + exclusiveGroup: 'main', + }), + literal({ + _id: LAYER_IDS.SOURCE_VT0, + _rank: 1, + name: 'VT', + type: SourceLayerType.VT, + exclusiveGroup: 'main', + }), + literal({ + _id: LAYER_IDS.SOURCE_TRANSITION0, + _rank: 2, + name: 'Transition', + type: SourceLayerType.TRANSITION, + }), + literal({ + _id: LAYER_IDS.SOURCE_GRAPHICS0, + _rank: 3, + name: 'Graphic', + type: SourceLayerType.GRAPHICS, + }), + ], + '_id' + ) + ), + blueprintConfigWithOverrides: wrapDefaultObject({}), + blueprintId: blueprintId, + // hotkeyLegend?: Array + _rundownVersionHash: '', + lastBlueprintConfig: undefined, + lastBlueprintFixUpHash: undefined, + } + const showStyleBase = _.extend(defaultShowStyleBase, doc) + ShowStyleBases.insert(showStyleBase) + return showStyleBase +} +export async function setupMockShowStyleVariant( + showStyleBaseId: ShowStyleBaseId, + doc?: Partial +): Promise { + doc = doc || {} + + const defaultShowStyleVariant: DBShowStyleVariant = { + _id: protectString('mockShowStyleVariant' + dbI++), + name: 'mockShowStyleVariant', + showStyleBaseId: showStyleBaseId, + blueprintConfigWithOverrides: wrapDefaultObject({}), + _rundownVersionHash: '', + _rank: 0, + } + const showStyleVariant = _.extend(defaultShowStyleVariant, doc) + ShowStyleVariants.insert(showStyleVariant) + + return showStyleVariant +} + +export interface DefaultEnvironment { + showStyleBaseId: ShowStyleBaseId + showStyleVariantId: ShowStyleVariantId + // studioBlueprint: Blueprint + // showStyleBlueprint: Blueprint + showStyleBase: DBShowStyleBase + // triggeredActions: DBTriggeredActions[] + showStyleVariant: DBShowStyleVariant + studio: DBStudio + core: ICoreSystem + // systemTriggeredActions: DBTriggeredActions[] +} +export async function setupDefaultStudioEnvironment( + organizationId: OrganizationId | null = null +): Promise { + const core = await setupMockCore({}) + // const systemTriggeredActions = await setupMockTriggeredActions() + + const showStyleBaseId: ShowStyleBaseId = getRandomId() + const showStyleVariantId: ShowStyleVariantId = getRandomId() + + const showStyleBase = await setupMockShowStyleBase(protectString('blueprint0'), { + _id: showStyleBaseId, + organizationId: organizationId, + }) + // const triggeredActions = await setupMockTriggeredActions(showStyleBase._id) + const showStyleVariant = await setupMockShowStyleVariant(showStyleBase._id, { _id: showStyleVariantId }) + + const studio = await setupMockStudio({ + blueprintId: protectString('blueprint0'), + supportedShowStyleBase: [showStyleBaseId], + organizationId: organizationId, + }) + + return { + showStyleBaseId, + showStyleVariantId, + showStyleBase, + // triggeredActions, + showStyleVariant, + studio, + core, + // systemTriggeredActions: systemTriggeredActions, + } +} +export async function setupDefaultRundownPlaylist( + env: DefaultEnvironment, + rundownId0?: RundownId, + customRundownFactory?: (env: DefaultEnvironment, playlistId: RundownPlaylistId, rundownId: RundownId) => RundownId +): Promise<{ rundownId: RundownId; playlistId: RundownPlaylistId }> { + const rundownId: RundownId = rundownId0 || getRandomId() + + const playlist: DBRundownPlaylist = defaultRundownPlaylist(protectString('playlist_' + rundownId), env.studio._id) + + const playlistId: RundownPlaylistId = protectString( + MongoMock.getInnerMockCollection(RundownPlaylists).insert(playlist) + ) + + return { + rundownId: await (customRundownFactory || setupDefaultRundown)(env, playlistId, rundownId), + playlistId, + } +} +// export async function setupEmptyEnvironment(): Promise<{ core: ICoreSystem }> { +// const core = await setupMockCore({}) + +// return { +// core, +// } +// } +export async function setupDefaultRundown( + env: DefaultEnvironment, + playlistId: RundownPlaylistId, + rundownId: RundownId +): Promise { + const outputLayerIds = Object.keys(applyAndValidateOverrides(env.showStyleBase.outputLayersWithOverrides).obj) + const sourceLayerIds = Object.keys(applyAndValidateOverrides(env.showStyleBase.sourceLayersWithOverrides).obj) + + const rundown: DBRundown = { + organizationId: null, + studioId: env.studio._id, + showStyleBaseId: env.showStyleBase._id, + showStyleVariantId: env.showStyleVariant._id, + timing: { + type: 'none' as any, + }, + + playlistId: playlistId, + + _id: rundownId, + externalId: 'MOCK_RUNDOWN_' + rundownId, + name: 'Default Rundown', + + created: getCurrentTime(), + modified: getCurrentTime(), + importVersions: { + studio: '', + showStyleBase: '', + showStyleVariant: '', + blueprint: '', + core: '', + }, + + source: { + type: 'nrcs', + peripheralDeviceId: protectString('ingest0'), + nrcsName: 'mock', + }, + } + MongoMock.getInnerMockCollection(Rundowns).insert(rundown) + + MongoMock.getInnerMockCollection(RundownPlaylists).update(playlistId, { + $push: { + rundownIdsInOrder: rundown._id, + }, + }) + + const segment0: DBSegment = { + _id: protectString(rundownId + '_segment0'), + _rank: 0, + externalId: 'MOCK_SEGMENT_0', + rundownId: rundown._id, + name: 'Segment 0', + externalModified: 1, + } + MongoMock.getInnerMockCollection(Segments).insert(segment0) + /* tslint:disable:ter-indent*/ + // + const part00: DBPart = { + _id: protectString(rundownId + '_part0_0'), + segmentId: segment0._id, + rundownId: rundown._id, + _rank: 0, + externalId: 'MOCK_PART_0_0', + title: 'Part 0 0', + expectedDurationWithPreroll: undefined, + } + MongoMock.getInnerMockCollection(Parts).insert(part00) + + const piece000: Piece = { + _id: protectString(rundownId + '_piece000'), + externalId: 'MOCK_PIECE_000', + startRundownId: rundown._id, + startSegmentId: part00.segmentId, + startPartId: part00._id, + name: 'Piece 000', + enable: { + start: 0, + }, + sourceLayerId: sourceLayerIds[0], + outputLayerId: outputLayerIds[0], + lifespan: PieceLifespan.WithinPart, + pieceType: IBlueprintPieceType.Normal, + invalid: false, + content: {}, + timelineObjectsString: EmptyPieceTimelineObjectsBlob, + } + MongoMock.getInnerMockCollection(Pieces).insert(piece000) + + const piece001: Piece = { + _id: protectString(rundownId + '_piece001'), + externalId: 'MOCK_PIECE_001', + startRundownId: rundown._id, + startSegmentId: part00.segmentId, + startPartId: part00._id, + name: 'Piece 001', + enable: { + start: 0, + }, + sourceLayerId: sourceLayerIds[1], + outputLayerId: outputLayerIds[0], + lifespan: PieceLifespan.WithinPart, + pieceType: IBlueprintPieceType.Normal, + invalid: false, + content: {}, + timelineObjectsString: EmptyPieceTimelineObjectsBlob, + } + MongoMock.getInnerMockCollection(Pieces).insert(piece001) + + const adLibPiece000: AdLibPiece = { + _id: protectString(rundownId + '_adLib000'), + _rank: 0, + expectedDuration: 1000, + lifespan: PieceLifespan.WithinPart, + externalId: 'MOCK_ADLIB_000', + partId: part00._id, + rundownId: segment0.rundownId, + name: 'AdLib 0', + sourceLayerId: sourceLayerIds[1], + outputLayerId: outputLayerIds[0], + content: {}, + timelineObjectsString: EmptyPieceTimelineObjectsBlob, + } + + MongoMock.getInnerMockCollection(AdLibPieces).insert(adLibPiece000) + + const part01: DBPart = { + _id: protectString(rundownId + '_part0_1'), + segmentId: segment0._id, + rundownId: segment0.rundownId, + _rank: 1, + externalId: 'MOCK_PART_0_1', + title: 'Part 0 1', + expectedDurationWithPreroll: undefined, + } + MongoMock.getInnerMockCollection(Parts).insert(part01) + + const piece010: Piece = { + _id: protectString(rundownId + '_piece010'), + externalId: 'MOCK_PIECE_010', + startRundownId: rundown._id, + startSegmentId: part01.segmentId, + startPartId: part01._id, + name: 'Piece 010', + enable: { + start: 0, + }, + sourceLayerId: sourceLayerIds[0], + outputLayerId: outputLayerIds[0], + lifespan: PieceLifespan.WithinPart, + pieceType: IBlueprintPieceType.Normal, + invalid: false, + content: {}, + timelineObjectsString: EmptyPieceTimelineObjectsBlob, + } + MongoMock.getInnerMockCollection(Pieces).insert(piece010) + + const segment1: DBSegment = { + _id: protectString(rundownId + '_segment1'), + _rank: 1, + externalId: 'MOCK_SEGMENT_2', + rundownId: rundown._id, + name: 'Segment 1', + externalModified: 1, + } + MongoMock.getInnerMockCollection(Segments).insert(segment1) + + const part10: DBPart = { + _id: protectString(rundownId + '_part1_0'), + segmentId: segment1._id, + rundownId: segment1.rundownId, + _rank: 0, + externalId: 'MOCK_PART_1_0', + title: 'Part 1 0', + expectedDurationWithPreroll: undefined, + } + MongoMock.getInnerMockCollection(Parts).insert(part10) + + const part11: DBPart = { + _id: protectString(rundownId + '_part1_1'), + segmentId: segment1._id, + rundownId: segment1.rundownId, + _rank: 1, + externalId: 'MOCK_PART_1_1', + title: 'Part 1 1', + expectedDurationWithPreroll: undefined, + } + MongoMock.getInnerMockCollection(Parts).insert(part11) + + const part12: DBPart = { + _id: protectString(rundownId + '_part1_2'), + segmentId: segment1._id, + rundownId: segment1.rundownId, + _rank: 2, + externalId: 'MOCK_PART_1_2', + title: 'Part 1 2', + expectedDurationWithPreroll: undefined, + } + MongoMock.getInnerMockCollection(Parts).insert(part12) + + const segment2: DBSegment = { + _id: protectString(rundownId + '_segment2'), + _rank: 2, + externalId: 'MOCK_SEGMENT_2', + rundownId: rundown._id, + name: 'Segment 2', + externalModified: 1, + } + MongoMock.getInnerMockCollection(Segments).insert(segment2) + + const globalAdLib0: RundownBaselineAdLibItem = { + _id: protectString(rundownId + '_globalAdLib0'), + _rank: 0, + externalId: 'MOCK_GLOBAL_ADLIB_0', + lifespan: PieceLifespan.OutOnRundownEnd, + rundownId: segment0.rundownId, + name: 'Global AdLib 0', + sourceLayerId: sourceLayerIds[0], + outputLayerId: outputLayerIds[0], + content: {}, + timelineObjectsString: EmptyPieceTimelineObjectsBlob, + } + + const globalAdLib1: RundownBaselineAdLibItem = { + _id: protectString(rundownId + '_globalAdLib1'), + _rank: 0, + externalId: 'MOCK_GLOBAL_ADLIB_1', + lifespan: PieceLifespan.OutOnRundownEnd, + rundownId: segment0.rundownId, + name: 'Global AdLib 1', + sourceLayerId: sourceLayerIds[1], + outputLayerId: outputLayerIds[0], + content: {}, + timelineObjectsString: EmptyPieceTimelineObjectsBlob, + } + + MongoMock.getInnerMockCollection(RundownBaselineAdLibPieces).insert(globalAdLib0) + MongoMock.getInnerMockCollection(RundownBaselineAdLibPieces).insert(globalAdLib1) + + return rundownId +} + +// // const studioBlueprint +// // const showStyleBlueprint +// // const showStyleVariant + +export function convertToUIShowStyleBase(showStyleBase: DBShowStyleBase): UIShowStyleBase { + return literal>({ + _id: showStyleBase._id, + name: showStyleBase.name, + hotkeyLegend: showStyleBase.hotkeyLegend, + sourceLayers: applyAndValidateOverrides(showStyleBase.sourceLayersWithOverrides).obj, + outputLayers: applyAndValidateOverrides(showStyleBase.outputLayersWithOverrides).obj, + }) +} diff --git a/packages/webui/src/__mocks__/meteor.ts b/packages/webui/src/__mocks__/meteor.ts new file mode 100644 index 0000000000..fe4b488bf0 --- /dev/null +++ b/packages/webui/src/__mocks__/meteor.ts @@ -0,0 +1,322 @@ +import * as _ from 'underscore' +import { MongoMock } from './mongo' + +let controllableDefer = false + +export function useControllableDefer(): void { + controllableDefer = true +} +export function useNextTickDefer(): void { + controllableDefer = false +} + +namespace Meteor { + export interface Settings { + public: { + [id: string]: any + } + [id: string]: any + } + + export interface UserEmail { + address: string + verified: boolean + } + export interface User { + _id?: string + username?: string + emails?: UserEmail[] + createdAt?: number + profile?: any + services?: any + } + + export interface ErrorStatic { + new (error: string | number, reason?: string, details?: string): Error + } + export interface Error { + error: string | number + reason?: string + details?: string + } + + export interface SubscriptionHandle { + stop(): void + ready(): boolean + } + export interface LiveQueryHandle { + stop(): void + } +} +const orgSetTimeout = setTimeout +const orgSetInterval = setInterval +const orgClearTimeout = clearTimeout +const orgClearInterval = clearInterval + +const $ = { + Error, + get setTimeout(): Function { + return setTimeout + }, + get setInterval(): Function { + return setInterval + }, + get clearTimeout(): Function { + return clearTimeout + }, + get clearInterval(): Function { + return clearInterval + }, + + get orgSetTimeout(): Function { + return orgSetTimeout + }, + get orgSetInterval(): Function { + return orgSetInterval + }, + get orgClearTimeout(): Function { + return orgClearTimeout + }, + get orgClearInterval(): Function { + return orgClearInterval + }, +} + +let mockIsClient = false +const publications: Record = {} +export class MeteorMock { + static get isClient(): boolean { + return mockIsClient + } + static get isServer(): boolean { + return !MeteorMock.isClient + } +} + +export namespace MeteorMock { + export const isTest = true + + export const isCordova = false + + export const isProduction = false + export const release = '' + + export const settings: any = {} + + export const mockMethods: { [name: string]: Function } = {} + export let mockUser: Meteor.User | undefined = undefined + export const mockStartupFunctions: Function[] = [] + + export const absolutePath = process.cwd() + + export function user(): Meteor.User | undefined { + return mockUser + } + export function userId(): string | undefined { + return mockUser ? mockUser._id : undefined + } + function getMethodContext() { + return { + userId: mockUser ? mockUser._id : undefined, + connection: { + clientAddress: '1.1.1.1', + }, + unblock: () => { + // noop + }, + } + } + export class Error { + private _stack?: string + constructor(public error: number, public reason?: string) { + const e = new $.Error('') + let stack: string = e.stack || '' + + const lines = stack.split('\n') + if (lines.length > 1) { + lines.shift() + stack = lines.join('\n') + } + this._stack = stack + // console.log(this._stack) + } + get name(): string { + return this.toString() + } + get message(): string { + return this.toString() + } + get details(): any { + return undefined + } + get errorType(): string { + return 'Meteor.Error' + } + get isClientSafe(): boolean { + return false + } + get stack(): string | undefined { + return this._stack + } + toString(): string { + return `[${this.error}] ${this.reason}` // TODO: This should be changed to "${this.reason} [${this.error}]" + } + } + export function methods(addMethods: { [name: string]: Function }): void { + Object.assign(mockMethods, addMethods) + } + export function call(methodName: string, ...args: any[]): any { + const fcn: Function = mockMethods[methodName] + if (!fcn) { + console.log(methodName) + console.log(mockMethods) + console.log(new Error(1).stack) + throw new Error(404, `Method '${methodName}' not found`) + } + + const lastArg = args.length > 0 && args[args.length - 1] + if (lastArg && typeof lastArg === 'function') { + const callback = args.pop() + + defer(() => { + try { + Promise.resolve(fcn.call(getMethodContext(), ...args)) + .then((result) => { + callback(undefined, result) + }) + .catch((e) => { + callback(e) + }) + } catch (e) { + callback(e) + } + }) + } else { + throw new Error(500, 'callback must be supplied on the client to Meteor.call') + } + } + export function apply( + methodName: string, + args: any[], + _options?: { + wait?: boolean + onResultReceived?: Function + returnStubValue?: boolean + throwStubExceptions?: boolean + }, + asyncCallback?: Function + ): any { + // ? + // This is a bad mock, since it doesn't support any of the options.. + // but it'll do for now: + call(methodName, ...args, asyncCallback) + } + export function absoluteUrl(path?: string): string { + return path + '' // todo + } + export function setTimeout(fcn: () => void | Promise, time: number): number { + return $.setTimeout(fcn, time) as number + } + export function clearTimeout(timer: number): void { + $.clearTimeout(timer) + } + export function setInterval(fcn: () => void | Promise, time: number): number { + return $.setInterval(fcn, time) as number + } + export function clearInterval(timer: number): void { + $.clearInterval(timer) + } + export function defer(fcn: () => void | Promise): void { + return (controllableDefer ? $.setTimeout : $.orgSetTimeout)(() => fcn, 0) + } + + export function startup(fcn: Function): void { + mockStartupFunctions.push(fcn) + } + + export function wrapAsync(_fcn: Function, _context?: Object): any { + throw new Error(500, 'Not implemented') + // return (...args: any[]) => { + // const callback = (err: any, value: any) => { + // if (err) { + // fiber.throwInto(err) + // } else { + // fiber.run(value) + // } + // } + // fcn.apply(context, [...args, callback]) + + // const returnValue = Fiber.yield() + // return returnValue + // } + } + + export function publish(publicationName: string, handler: Function): any { + publications[publicationName] = handler + } + + export function bindEnvironment(_fcn: Function): any { + throw new Error(500, 'bindEnvironment not supported on client') + // { + // // the outer bindEnvironment must be called from a fiber + // const fiber = Fiber.current + // if (!fiber) throw new Error(500, `It appears that bindEnvironment isn't running in a fiber`) + // } + + // return (...args: any[]) => { + // const fiber = Fiber.current + // if (fiber) { + // return fcn(...args) + // } else { + // return runInFiber(() => fcn(...args)).catch(console.error) + // } + // } + } + export let users: MongoMock.Collection | undefined = undefined + + // -- Mock functions: -------------------------- + /** + * Run the Meteor.startup() functions + */ + export async function mockRunMeteorStartup(): Promise { + _.each(mockStartupFunctions, (fcn) => { + fcn() + }) + + await waitTimeNoFakeTimers(10) // So that any observers or defers has had time to run. + } + export function mockLoginUser(newUser: Meteor.User): void { + mockUser = newUser + } + export function mockSetUsersCollection(usersCollection: MongoMock.Collection): void { + users = usersCollection + } + export function mockSetClientEnvironment(): void { + mockIsClient = true + } + export function mockSetServerEnvironment(): void { + mockIsClient = false + } + export function mockGetPublications(): Record { + return publications + } + + /** Wait for time to pass ( unaffected by jest.useFakeTimers() ) */ + export async function sleepNoFakeTimers(time: number): Promise { + return new Promise((resolve) => $.orgSetTimeout(resolve, time)) + } + + export function _setImmediate(cb: () => void): number { + return setTimeout(cb, 0) + } +} +export function setup(): any { + return { + Meteor: MeteorMock, + } +} + +/** Wait for time to pass ( unaffected by jest.useFakeTimers() ) */ +export async function waitTimeNoFakeTimers(time: number): Promise { + await MeteorMock.sleepNoFakeTimers(time) +} diff --git a/packages/webui/src/__mocks__/mongo.ts b/packages/webui/src/__mocks__/mongo.ts new file mode 100644 index 0000000000..bb875d10f0 --- /dev/null +++ b/packages/webui/src/__mocks__/mongo.ts @@ -0,0 +1,353 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import * as _ from 'underscore' +import { literal, ProtectedString, unprotectString, protectString, sleep, getRandomString } from '../lib/lib' +import { RandomMock } from './random' +import { MeteorMock } from './meteor' +import { Random } from 'meteor/random' +import { Meteor } from 'meteor/meteor' +import type { AnyBulkWriteOperation } from 'mongodb' +import { + FindOneOptions, + FindOptions, + MongoReadOnlyCollection, + ObserveCallbacks, + ObserveChangesCallbacks, + UpdateOptions, + UpsertOptions, + WrappedMongoCollection, +} from '../lib/collections/lib' +import { + mongoWhere, + mongoFindOptions, + mongoModify, + MongoQuery, + MongoModifier, +} from '@sofie-automation/corelib/dist/mongo' +import { Mongo } from 'meteor/mongo' +const clone = require('fast-clone') + +export namespace MongoMock { + interface ObserverEntry { + id: string + query: any + callbacksChanges?: ObserveChangesCallbacks + callbacksObserve?: ObserveCallbacks + } + + export interface MockCollections { + [collectionName: string]: MockCollection + } + export interface MockCollection { + [id: string]: T + } + interface CollectionObject { + _id: ProtectedString + } + + const mockCollections: MockCollections = {} + export type MongoCollection = {} + export class Collection implements MongoCollection { + public _name: string + private _options: any = {} + // @ts-expect-error used in test to check that it's a mock + private _isMock = true as const + public observers: ObserverEntry[] = [] + + public asyncBulkWriteDelay = 100 + + constructor(name: string | null, options?: { transform?: never }) { + this._options = options || {} + this._name = name || getRandomString() // If `null`, then its an in memory unique collection + + if (this._options.transform) throw new Error('document transform is no longer supported') + } + + find(query: any, options?: FindOptions) { + if (_.isString(query)) query = { _id: query } + query = query || {} + + const unimplementedUsedOptions = _.without(_.keys(options), 'sort', 'limit', 'fields', 'projection') + if (options && 'fields' in options && 'projection' in options) { + throw new Error(`Only one of 'fields' and 'projection' can be specified`) + } + if (unimplementedUsedOptions.length > 0) { + throw new Error(`find being performed using unimplemented options: ${unimplementedUsedOptions}`) + } + + const docsArray = Object.values(this.documents) + let docs: T[] = _.compact( + query._id && typeof query._id === 'string' + ? [this.documents[query._id]] + : docsArray.filter((doc) => mongoWhere(doc, query)) + ) + + docs = mongoFindOptions(docs, options) + + const observers = this.observers + + const removeObserver = (id: string): void => { + const index = observers.findIndex((o) => o.id === id) + if (index === -1) throw new Meteor.Error(500, 'Cannot stop observer that is not registered') + observers.splice(index, 1) + } + + return { + _fetchRaw: () => { + return docs + }, + fetch: () => { + return clone(docs) + }, + count: () => { + return docs.length + }, + observe(clbs: ObserveCallbacks): Meteor.LiveQueryHandle { + const id = Random.id(5) + observers.push( + literal>({ + id: id, + callbacksObserve: clbs, + query: query, + }) + ) + return { + stop() { + removeObserver(id) + }, + } + }, + observeChanges(clbs: ObserveChangesCallbacks): Meteor.LiveQueryHandle { + // todo - finish implementing uses of callbacks + const id = Random.id(5) + observers.push( + literal>({ + id: id, + callbacksChanges: clbs, + query: query, + }) + ) + return { + stop() { + removeObserver(id) + }, + } + }, + forEach(f: any) { + docs.forEach(f) + }, + map(f: any) { + return docs.map(f) + }, + } + } + findOne(query: MongoQuery, options?: FindOneOptions) { + return this.find(query, options).fetch()[0] + } + update(query: MongoQuery, modifier: MongoModifier, options?: UpdateOptions): number { + const unimplementedUsedOptions = _.without(_.keys(options), 'multi') + if (unimplementedUsedOptions.length > 0) { + throw new Error(`update being performed using unimplemented options: ${unimplementedUsedOptions}`) + } + + // todo + let docs = this.find(query)._fetchRaw() + + // By default mongo only updates one doc, unless told multi + if (this.documents.length && !options?.multi) { + docs = [docs[0]] + } + + _.each(docs, (doc) => { + const modifiedDoc = mongoModify(query, doc, modifier) + this.documents[unprotectString(doc._id)] = modifiedDoc + + Meteor.defer(() => { + _.each(_.clone(this.observers), (obs) => { + if (mongoWhere(doc, obs.query)) { + if (obs.callbacksChanges?.changed) { + obs.callbacksChanges.changed(doc._id, {}) // TODO - figure out what changed + } + if (obs.callbacksObserve?.changed) { + obs.callbacksObserve.changed(modifiedDoc, doc) + } + } + }) + }) + }) + + return docs.length + } + insert(doc: T): T['_id'] { + const d = _.clone(doc) + if (!d._id) d._id = protectString(RandomMock.id()) + + if (this.documents[unprotectString(d._id)]) { + throw new MeteorMock.Error(500, `Duplicate key '${d._id}'`) + } + + this.documents[unprotectString(d._id)] = d + + Meteor.defer(() => { + _.each(_.clone(this.observers), (obs) => { + if (mongoWhere(d, obs.query)) { + const fields = _.keys(_.omit(d, '_id')) + if (obs.callbacksChanges?.addedBefore) { + obs.callbacksChanges.addedBefore(d._id, fields, null as any) + } + if (obs.callbacksChanges?.added) { + obs.callbacksChanges.added(d._id, fields) + } + if (obs.callbacksObserve?.added) { + obs.callbacksObserve.added(d) + } + } + }) + }) + + return d._id + } + upsert( + query: any, + modifier: MongoModifier, + options?: UpsertOptions + ): { numberAffected: number | undefined; insertedId: T['_id'] | undefined } { + const id = _.isString(query) ? query : query._id + + const docs = this.find(id)._fetchRaw() + + if (docs.length) { + const count = this.update(docs[0]._id, modifier, options) + return { insertedId: undefined, numberAffected: count } + } else { + const doc = mongoModify(query, { _id: id } as any, modifier) + const insertedId = this.insert(doc) + return { insertedId: insertedId, numberAffected: undefined } + } + } + remove(query: any): number { + const docs = this.find(query)._fetchRaw() + + _.each(docs, (doc) => { + delete this.documents[unprotectString(doc._id)] + + Meteor.defer(() => { + _.each(_.clone(this.observers), (obs) => { + if (mongoWhere(doc, obs.query)) { + if (obs.callbacksChanges?.removed) { + obs.callbacksChanges.removed(doc._id) + } + if (obs.callbacksObserve?.removed) { + obs.callbacksObserve.removed(doc) + } + } + }) + }) + }) + return docs.length + } + + _ensureIndex(_obj: any) { + // todo + } + allow() { + // todo + } + rawCollection() { + return { + bulkWrite: async (updates: AnyBulkWriteOperation[], _options: unknown) => { + await sleep(this.asyncBulkWriteDelay) + + for (const update of updates) { + if ('insertOne' in update) { + this.insert(update.insertOne.document) + } else if ('updateOne' in update) { + if (update.updateOne.upsert) { + this.upsert(update.updateOne.filter, update.updateOne.update as any, { multi: false }) + } else { + this.update(update.updateOne.filter, update.updateOne.update as any, { multi: false }) + } + } else if ('updateMany' in update) { + if (update.updateMany.upsert) { + this.upsert(update.updateMany.filter, update.updateMany.update as any, { multi: true }) + } else { + this.update(update.updateMany.filter, update.updateMany.update as any, { multi: true }) + } + } else if ('deleteOne' in update) { + const docs = this.find(update.deleteOne.filter).fetch() + if (docs.length) { + this.remove(docs[0]._id) + } + } else if ('deleteMany' in update) { + this.remove(update.deleteMany.filter) + } else if (update['replaceOne']) { + this.upsert(update.replaceOne.filter, update.replaceOne.replacement) + } + } + }, + collectionName: this._name, + } + } + private get documents(): MockCollection { + if (!mockCollections[this._name]) mockCollections[this._name] = {} + return mockCollections[this._name] + } + } + // Mock functions: + export function mockSetData( + collection: WrappedMongoCollection, + data: MockCollection | Array | null + ) { + const collectionName = collection.name + if (collectionName === null) { + throw new Meteor.Error(500, 'mockSetData can only be done for named collections') + } + + data = data || {} + if (_.isArray(data)) { + const collectionData: MockCollection = {} + _.each(data, (doc) => { + if (!doc._id) throw Error(`mockSetData: "${collectionName}": doc._id missing`) + collectionData[unprotectString(doc._id)] = doc + }) + mockCollections[collectionName] = collectionData + } else { + mockCollections[collectionName] = data + } + } + + export function deleteAllData() { + Object.keys(mockCollections).forEach((id) => { + mockCollections[id] = {} + }) + } + + /** + * The Mock Collection type does a sleep before starting on executing the bulkWrite. + * This simulates the async nature of writes to mongo, and aims to detect race conditions in our code. + * This method will change the duration of the sleep, and returns the old delay value + */ + export function setCollectionAsyncBulkWriteDelay(collection: WrappedMongoCollection, delay: number): number { + const collection2 = collection as any + if (typeof collection2.asyncWriteDelay !== 'number') { + throw new Error( + "asyncWriteDelay must be defined already, or this won't do anything. Perhaps some refactoring?" + ) + } + const oldDelay = collection2.asyncWriteDelay + collection2.asyncWriteDelay = delay + return oldDelay + } + + export function getInnerMockCollection }>( + collection: MongoReadOnlyCollection | WrappedMongoCollection + ): Mongo.Collection { + return (collection as any).mockCollection + } +} +export function setup(): any { + return { + Mongo: MongoMock, + } +} + +MeteorMock.mockSetUsersCollection(new MongoMock.Collection('Meteor.users')) diff --git a/packages/webui/src/__mocks__/random.ts b/packages/webui/src/__mocks__/random.ts new file mode 100644 index 0000000000..aa4e336b8a --- /dev/null +++ b/packages/webui/src/__mocks__/random.ts @@ -0,0 +1,33 @@ +export class RandomMock { + static mockIds: Array = [] + static mockI = 9000 + + static id(): string { + let id = this.mockIds.shift() + if (!id) id = 'randomId' + RandomMock.mockI++ + return id + } + /** Returns a "mocked random" number 0-1 */ + static number(): number { + return (RandomMock.mockI++ / 1.6180339887) % 1 + } +} +export function setup(): any { + return { + Random: RandomMock, + nanoid: RandomMock.id.bind(RandomMock), + customAlphabet: () => RandomMock.id.bind(RandomMock), + } +} + +export function restartRandomId(): void { + RandomMock.mockI = 9000 + RandomMock.mockIds = [] +} + +export function resetRandomId(): void { + // move the iterator forward and tie to next 1000 + // This will help with making the random id more consistend in tests + RandomMock.mockI += 500 + RandomMock.mockI += 1000 - (RandomMock.mockI % 1000) +} diff --git a/packages/webui/src/__mocks__/tracker.ts b/packages/webui/src/__mocks__/tracker.ts new file mode 100644 index 0000000000..47ebb8cfcd --- /dev/null +++ b/packages/webui/src/__mocks__/tracker.ts @@ -0,0 +1,128 @@ +/** + * This is a very rudimentary mock of a Meteor Tracker. It is quite likely buggy and may not accurately represent + * the order in which flags are turned on/off, since that's not clear from Meteor documentation. + * Also, all of the Tracker.flush() and related methods are not implemented. + */ + +export namespace TrackerMock { + type ComputationCallback = (computation: Computation) => void + type AutorunCallback = (computation: Computation) => void + // eslint-disable-next-line prefer-const + export let currentComputation: Computation | null = null + // eslint-disable-next-line prefer-const + export let active = false + + export class Dependency { + private dependents: Computation[] = [] + public changed = (): void => { + this.dependents.forEach((comp) => comp.invalidate()) + } + public depend = (): void => { + if (TrackerMock.currentComputation) { + const comp = TrackerMock.currentComputation + if (comp) { + const length = this.dependents.push(comp) + comp.onStop(() => { + this.dependents.splice(length - 1, 1) + }) + } + } + } + public hasDependents = (): boolean => { + return this.dependents.length > 0 + } + } + + export class Computation { + private onInvalidateClbs: Array = [] + private onStopClbs: Array = [] + private func: AutorunCallback + private parentComputation: Computation | null = null + stopped = false + invalidated = false + firstRun = true + + constructor(computedFunc: AutorunCallback, parentComputation: Computation | null, _onError?: (e: any) => void) { + this.parentComputation = parentComputation + this.firstRun = true + this.func = computedFunc + this.runFunc() + this.firstRun = false + } + + private runFunc = () => { + const trackBuf = TrackerMock.currentComputation + TrackerMock.currentComputation = this + TrackerMock.active = !!TrackerMock.currentComputation + this.func(this) + TrackerMock.currentComputation = trackBuf + TrackerMock.active = !!TrackerMock.currentComputation + } + private runAll = (clbs: Array) => { + clbs.forEach((clb) => clb(this)) + } + public stop = (): void => { + this.stopped = true + this.runAll(this.onInvalidateClbs) + this.onInvalidateClbs.length = 0 + this.runAll(this.onStopClbs) + this.onStopClbs.length = 0 + } + public invalidate = (): void => { + this.invalidated = true + if (!this.parentComputation) { + this.runAll(this.onInvalidateClbs) + this.runFunc() + this.invalidated = false + } else { + this.stop() + this.parentComputation.invalidate() + } + } + public onInvalidate = (clb: ComputationCallback): void => { + this.onInvalidateClbs.push(clb) + } + public onStop = (clb: ComputationCallback): void => { + this.onStopClbs.push(clb) + } + } + + export function autorun(runFunc: AutorunCallback, options = {}): TrackerMock.Computation { + if (Object.keys(options).length > 0) { + throw new Error(`Tracker.autorun using unimplemented options: ${Object.keys(options).join(', ')}`) + } + + return new TrackerMock.Computation(runFunc, TrackerMock.currentComputation) + } + export function flush(): void { + throw new Error(`Tracker.flush() is not implemented in the mock Tracker`) + } + export function nonreactive(runFunc: () => T): T { + const comp = TrackerMock.currentComputation + TrackerMock.currentComputation = null + TrackerMock.active = !!TrackerMock.currentComputation + const result = runFunc() + TrackerMock.currentComputation = comp + TrackerMock.active = !!TrackerMock.currentComputation + return result + } + export function inFlush(): boolean { + throw new Error(`Tracker.inFlush() is not implemented in the mock Tracker`) + } + export function onInvalidate(clb: ComputationCallback): void { + if (!TrackerMock.currentComputation) { + throw new Error('Tracker.onInvalidate requires a currentComputation') + } + + TrackerMock.currentComputation.onInvalidate(clb) + } + export function afterFlush(_clb: Function): void { + throw new Error(`Tracker.afterFlush() is not implemented in the mock Tracker`) + } +} + +export function setup(): any { + return { + Tracker: TrackerMock, + } +} diff --git a/meteor/client/__tests__/jest-setup.js b/packages/webui/src/client/__tests__/jest-setup.cjs similarity index 50% rename from meteor/client/__tests__/jest-setup.js rename to packages/webui/src/client/__tests__/jest-setup.cjs index 73ddc6089d..bb36d10e75 100644 --- a/meteor/client/__tests__/jest-setup.js +++ b/packages/webui/src/client/__tests__/jest-setup.cjs @@ -1,2 +1,7 @@ +/* eslint-disable node/no-unpublished-require */ + // used by code creating XML with the DOM API to return an XML string global.XMLSerializer = require('@xmldom/xmldom').XMLSerializer + +// Version number injected by vite packaging +global.__APP_VERSION__ = '0.0.0' diff --git a/meteor/client/collections/index.ts b/packages/webui/src/client/collections/index.ts similarity index 100% rename from meteor/client/collections/index.ts rename to packages/webui/src/client/collections/index.ts diff --git a/meteor/client/lib/Components/Base64ImageInput.tsx b/packages/webui/src/client/lib/Components/Base64ImageInput.tsx similarity index 100% rename from meteor/client/lib/Components/Base64ImageInput.tsx rename to packages/webui/src/client/lib/Components/Base64ImageInput.tsx diff --git a/meteor/client/lib/Components/Checkbox.tsx b/packages/webui/src/client/lib/Components/Checkbox.tsx similarity index 100% rename from meteor/client/lib/Components/Checkbox.tsx rename to packages/webui/src/client/lib/Components/Checkbox.tsx diff --git a/meteor/client/lib/Components/DropdownInput.tsx b/packages/webui/src/client/lib/Components/DropdownInput.tsx similarity index 100% rename from meteor/client/lib/Components/DropdownInput.tsx rename to packages/webui/src/client/lib/Components/DropdownInput.tsx diff --git a/meteor/client/lib/Components/FloatInput.tsx b/packages/webui/src/client/lib/Components/FloatInput.tsx similarity index 100% rename from meteor/client/lib/Components/FloatInput.tsx rename to packages/webui/src/client/lib/Components/FloatInput.tsx diff --git a/meteor/client/lib/Components/IntInput.tsx b/packages/webui/src/client/lib/Components/IntInput.tsx similarity index 100% rename from meteor/client/lib/Components/IntInput.tsx rename to packages/webui/src/client/lib/Components/IntInput.tsx diff --git a/meteor/client/lib/Components/JsonTextInput.tsx b/packages/webui/src/client/lib/Components/JsonTextInput.tsx similarity index 100% rename from meteor/client/lib/Components/JsonTextInput.tsx rename to packages/webui/src/client/lib/Components/JsonTextInput.tsx diff --git a/meteor/client/lib/Components/LabelAndOverrides.tsx b/packages/webui/src/client/lib/Components/LabelAndOverrides.tsx similarity index 100% rename from meteor/client/lib/Components/LabelAndOverrides.tsx rename to packages/webui/src/client/lib/Components/LabelAndOverrides.tsx diff --git a/meteor/client/lib/Components/MultiLineTextInput.tsx b/packages/webui/src/client/lib/Components/MultiLineTextInput.tsx similarity index 100% rename from meteor/client/lib/Components/MultiLineTextInput.tsx rename to packages/webui/src/client/lib/Components/MultiLineTextInput.tsx diff --git a/meteor/client/lib/Components/MultiSelectInput.tsx b/packages/webui/src/client/lib/Components/MultiSelectInput.tsx similarity index 96% rename from meteor/client/lib/Components/MultiSelectInput.tsx rename to packages/webui/src/client/lib/Components/MultiSelectInput.tsx index bf49275ac6..1e9602145e 100644 --- a/meteor/client/lib/Components/MultiSelectInput.tsx +++ b/packages/webui/src/client/lib/Components/MultiSelectInput.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { MultiSelect, MultiSelectEvent, MultiSelectOptions } from '../multiSelect' import { DropdownInputOption } from './DropdownInput' import ClassNames from 'classnames' diff --git a/meteor/client/lib/Components/PromiseButton.scss b/packages/webui/src/client/lib/Components/PromiseButton.scss similarity index 100% rename from meteor/client/lib/Components/PromiseButton.scss rename to packages/webui/src/client/lib/Components/PromiseButton.scss index 2f31075b40..5171521faf 100644 --- a/meteor/client/lib/Components/PromiseButton.scss +++ b/packages/webui/src/client/lib/Components/PromiseButton.scss @@ -2,6 +2,10 @@ button.promise-button { position: relative; &.is-success { + animation-name: anim-success; + animation-timing-function: linear; + animation-duration: 3s; + @keyframes anim-success { 0% { background-color: #95e995; @@ -10,12 +14,15 @@ button.promise-button { background-color: inherit; } } - - animation-name: anim-success; - animation-timing-function: linear; - animation-duration: 3s; } &.is-failure { + background-color: inherit; + + animation-name: anim-failure; + animation-timing-function: linear; + animation-duration: 0.25s; + animation-iteration-count: 4; + @keyframes anim-failure { 0% { background-color: inherit; @@ -33,13 +40,6 @@ button.promise-button { background-color: inherit; } } - - background-color: inherit; - - animation-name: anim-failure; - animation-timing-function: linear; - animation-duration: 0.25s; - animation-iteration-count: 4; } > .content { diff --git a/meteor/client/lib/Components/PromiseButton.tsx b/packages/webui/src/client/lib/Components/PromiseButton.tsx similarity index 100% rename from meteor/client/lib/Components/PromiseButton.tsx rename to packages/webui/src/client/lib/Components/PromiseButton.tsx diff --git a/meteor/client/lib/Components/TextInput.tsx b/packages/webui/src/client/lib/Components/TextInput.tsx similarity index 100% rename from meteor/client/lib/Components/TextInput.tsx rename to packages/webui/src/client/lib/Components/TextInput.tsx diff --git a/meteor/client/lib/Components/util.tsx b/packages/webui/src/client/lib/Components/util.tsx similarity index 100% rename from meteor/client/lib/Components/util.tsx rename to packages/webui/src/client/lib/Components/util.tsx diff --git a/meteor/client/lib/ConnectionStatusNotification.tsx b/packages/webui/src/client/lib/ConnectionStatusNotification.tsx similarity index 100% rename from meteor/client/lib/ConnectionStatusNotification.tsx rename to packages/webui/src/client/lib/ConnectionStatusNotification.tsx diff --git a/meteor/client/lib/DocumentTitleProvider.tsx b/packages/webui/src/client/lib/DocumentTitleProvider.tsx similarity index 100% rename from meteor/client/lib/DocumentTitleProvider.tsx rename to packages/webui/src/client/lib/DocumentTitleProvider.tsx diff --git a/meteor/client/lib/EditAttribute.tsx b/packages/webui/src/client/lib/EditAttribute.tsx similarity index 100% rename from meteor/client/lib/EditAttribute.tsx rename to packages/webui/src/client/lib/EditAttribute.tsx diff --git a/meteor/client/lib/ErrorBoundary.tsx b/packages/webui/src/client/lib/ErrorBoundary.tsx similarity index 100% rename from meteor/client/lib/ErrorBoundary.tsx rename to packages/webui/src/client/lib/ErrorBoundary.tsx diff --git a/meteor/client/lib/Escape.tsx b/packages/webui/src/client/lib/Escape.tsx similarity index 100% rename from meteor/client/lib/Escape.tsx rename to packages/webui/src/client/lib/Escape.tsx diff --git a/meteor/client/lib/KeyboardFocusIndicator.tsx b/packages/webui/src/client/lib/KeyboardFocusIndicator.tsx similarity index 100% rename from meteor/client/lib/KeyboardFocusIndicator.tsx rename to packages/webui/src/client/lib/KeyboardFocusIndicator.tsx diff --git a/meteor/client/lib/LottieButton.tsx b/packages/webui/src/client/lib/LottieButton.tsx similarity index 100% rename from meteor/client/lib/LottieButton.tsx rename to packages/webui/src/client/lib/LottieButton.tsx diff --git a/meteor/client/lib/ModalDialog.tsx b/packages/webui/src/client/lib/ModalDialog.tsx similarity index 99% rename from meteor/client/lib/ModalDialog.tsx rename to packages/webui/src/client/lib/ModalDialog.tsx index 8d1ff3e4cb..83c28b6289 100644 --- a/meteor/client/lib/ModalDialog.tsx +++ b/packages/webui/src/client/lib/ModalDialog.tsx @@ -1,6 +1,7 @@ import React, { useLayoutEffect, useRef } from 'react' -import CoreIcons from '@nrk/core-icons/jsx' +import * as CoreIcons from '@nrk/core-icons/jsx' import Escape from './Escape' +// @ts-expect-error type linking issue import FocusBounder from 'react-focus-bounder' import { useTranslation } from 'react-i18next' diff --git a/meteor/client/lib/Moment.tsx b/packages/webui/src/client/lib/Moment.tsx similarity index 93% rename from meteor/client/lib/Moment.tsx rename to packages/webui/src/client/lib/Moment.tsx index 880983d075..6da6124f54 100644 --- a/meteor/client/lib/Moment.tsx +++ b/packages/webui/src/client/lib/Moment.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import Moment, { MomentProps } from 'react-moment' import moment from 'moment' import { getCurrentTime } from '../../lib/lib' diff --git a/meteor/client/lib/PointerLockCursor.tsx b/packages/webui/src/client/lib/PointerLockCursor.tsx similarity index 100% rename from meteor/client/lib/PointerLockCursor.tsx rename to packages/webui/src/client/lib/PointerLockCursor.tsx diff --git a/meteor/client/lib/ReactMeteorData/ReactMeteorData.tsx b/packages/webui/src/client/lib/ReactMeteorData/ReactMeteorData.tsx similarity index 96% rename from meteor/client/lib/ReactMeteorData/ReactMeteorData.tsx rename to packages/webui/src/client/lib/ReactMeteorData/ReactMeteorData.tsx index 133178f2b8..fa9598c8b1 100644 --- a/meteor/client/lib/ReactMeteorData/ReactMeteorData.tsx +++ b/packages/webui/src/client/lib/ReactMeteorData/ReactMeteorData.tsx @@ -270,17 +270,11 @@ export interface WithTrackerOptions { // pure?: boolean } // @todo: add withTrackerPure() -type IWrappedComponent = - | React.ComponentClass - // | (new (props: IProps & TrackedProps, state: IState) => React.Component) - | ((props: IProps & TrackedProps) => JSX.Element | null) export function withTracker( autorunFunction: (props: IProps) => TrackedProps, checkUpdate?: (data: any, props: IProps, nextProps: IProps, state?: IState, nextState?: IState) => boolean, queueTrackerUpdates?: boolean -): ( - WrappedComponent: IWrappedComponent -) => new (props: IProps) => React.Component { +): (component: React.ComponentType) => React.ComponentType { const expandedOptions: WithTrackerOptions = { getMeteorData: autorunFunction, shouldComponentUpdate: checkUpdate, @@ -318,15 +312,15 @@ export function translateWithTracker( autorunFunction: (props: Translated, state?: IState) => TrackedProps, checkUpdate?: (data: any, props: IProps, nextProps: IProps, state?: IState, nextState?: IState) => boolean, queueTrackerUpdates?: boolean -) { +): (component: React.ComponentType>) => React.ComponentType { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - return (WrappedComponent: IWrappedComponent, IState, TrackedProps>) => { - const inner = withTracker, IState, TrackedProps>( + return (WrappedComponent) => { + const inner: React.ComponentType> = withTracker, IState, TrackedProps>( autorunFunction, checkUpdate, queueTrackerUpdates )(WrappedComponent) - return withTranslation()(inner) + return withTranslation()(inner) as React.ComponentType } } export type Translated = T & WithTranslation diff --git a/meteor/client/lib/ReactMeteorData/react-meteor-data.tsx b/packages/webui/src/client/lib/ReactMeteorData/react-meteor-data.tsx similarity index 100% rename from meteor/client/lib/ReactMeteorData/react-meteor-data.tsx rename to packages/webui/src/client/lib/ReactMeteorData/react-meteor-data.tsx diff --git a/meteor/client/lib/RenderLimiter.tsx b/packages/webui/src/client/lib/RenderLimiter.tsx similarity index 100% rename from meteor/client/lib/RenderLimiter.tsx rename to packages/webui/src/client/lib/RenderLimiter.tsx diff --git a/meteor/client/lib/RundownResolver.ts b/packages/webui/src/client/lib/RundownResolver.ts similarity index 100% rename from meteor/client/lib/RundownResolver.ts rename to packages/webui/src/client/lib/RundownResolver.ts diff --git a/meteor/client/lib/SettingsNavigation.tsx b/packages/webui/src/client/lib/SettingsNavigation.tsx similarity index 92% rename from meteor/client/lib/SettingsNavigation.tsx rename to packages/webui/src/client/lib/SettingsNavigation.tsx index d88945b7b8..4fd171f9e9 100644 --- a/meteor/client/lib/SettingsNavigation.tsx +++ b/packages/webui/src/client/lib/SettingsNavigation.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react' +import { useCallback } from 'react' import { useHistory } from 'react-router-dom' import { BlueprintId } from '@sofie-automation/corelib/dist/dataModel/Ids' diff --git a/meteor/client/lib/SorensenContext.tsx b/packages/webui/src/client/lib/SorensenContext.tsx similarity index 100% rename from meteor/client/lib/SorensenContext.tsx rename to packages/webui/src/client/lib/SorensenContext.tsx diff --git a/meteor/client/lib/Spinner.tsx b/packages/webui/src/client/lib/Spinner.tsx similarity index 100% rename from meteor/client/lib/Spinner.tsx rename to packages/webui/src/client/lib/Spinner.tsx diff --git a/meteor/client/lib/SplitDropdown.tsx b/packages/webui/src/client/lib/SplitDropdown.tsx similarity index 100% rename from meteor/client/lib/SplitDropdown.tsx rename to packages/webui/src/client/lib/SplitDropdown.tsx diff --git a/meteor/client/lib/SplitPreviewBox.tsx b/packages/webui/src/client/lib/SplitPreviewBox.tsx similarity index 100% rename from meteor/client/lib/SplitPreviewBox.tsx rename to packages/webui/src/client/lib/SplitPreviewBox.tsx diff --git a/meteor/client/lib/StyledTimecode.tsx b/packages/webui/src/client/lib/StyledTimecode.tsx similarity index 100% rename from meteor/client/lib/StyledTimecode.tsx rename to packages/webui/src/client/lib/StyledTimecode.tsx diff --git a/meteor/client/lib/UIStateStorage.ts b/packages/webui/src/client/lib/UIStateStorage.ts similarity index 100% rename from meteor/client/lib/UIStateStorage.ts rename to packages/webui/src/client/lib/UIStateStorage.ts diff --git a/meteor/client/lib/VideoPreviewPlayer.scss b/packages/webui/src/client/lib/VideoPreviewPlayer.scss similarity index 100% rename from meteor/client/lib/VideoPreviewPlayer.scss rename to packages/webui/src/client/lib/VideoPreviewPlayer.scss diff --git a/meteor/client/lib/VideoPreviewPlayer.tsx b/packages/webui/src/client/lib/VideoPreviewPlayer.tsx similarity index 97% rename from meteor/client/lib/VideoPreviewPlayer.tsx rename to packages/webui/src/client/lib/VideoPreviewPlayer.tsx index 565ab5a31f..727b7ba0b0 100644 --- a/meteor/client/lib/VideoPreviewPlayer.tsx +++ b/packages/webui/src/client/lib/VideoPreviewPlayer.tsx @@ -1,6 +1,6 @@ import { IStudioSettings } from '@sofie-automation/corelib/dist/dataModel/Studio' import classNames from 'classnames' -import React, { useEffect, useRef } from 'react' +import { useEffect, useRef } from 'react' import { StyledTimecode } from './StyledTimecode' function setVideoElementPosition( diff --git a/meteor/client/lib/VirtualElement.tsx b/packages/webui/src/client/lib/VirtualElement.tsx similarity index 100% rename from meteor/client/lib/VirtualElement.tsx rename to packages/webui/src/client/lib/VirtualElement.tsx diff --git a/meteor/client/lib/__tests__/__snapshots__/rundown.test.ts.snap b/packages/webui/src/client/lib/__tests__/__snapshots__/rundown.test.ts.snap similarity index 76% rename from meteor/client/lib/__tests__/__snapshots__/rundown.test.ts.snap rename to packages/webui/src/client/lib/__tests__/__snapshots__/rundown.test.ts.snap index e465b62c50..59fcec5d1a 100644 --- a/meteor/client/lib/__tests__/__snapshots__/rundown.test.ts.snap +++ b/packages/webui/src/client/lib/__tests__/__snapshots__/rundown.test.ts.snap @@ -13,32 +13,32 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu "parts": [ { "instance": { - "_id": "randomId9010_part0_0_tmp_instance", + "_id": "randomId9002_part0_0_tmp_instance", "isTemporary": true, "part": { - "_id": "randomId9010_part0_0", + "_id": "randomId9002_part0_0", "_rank": 0, "externalId": "MOCK_PART_0_0", - "rundownId": "randomId9010", - "segmentId": "randomId9010_segment0", + "rundownId": "randomId9002", + "segmentId": "randomId9002_segment0", "title": "Part 0 0", }, "playlistActivationId": "", "rehearsal": false, - "rundownId": "randomId9010", - "segmentId": "randomId9010_segment0", + "rundownId": "randomId9002", + "segmentId": "randomId9002_segment0", "segmentPlayoutId": "", "takeCount": -1, }, - "partId": "randomId9010_part0_0", + "partId": "randomId9002_part0_0", "pieces": [ { "instance": { - "_id": "randomId9010_part0_0_tmp_instance_randomId9010_piece001", + "_id": "randomId9002_part0_0_tmp_instance_randomId9002_piece001", "isTemporary": true, - "partInstanceId": "randomId9010_part0_0_tmp_instance", + "partInstanceId": "randomId9002_part0_0_tmp_instance", "piece": { - "_id": "randomId9010_piece001", + "_id": "randomId9002_piece001", "content": {}, "enable": { "start": 0, @@ -50,14 +50,14 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu "outputLayerId": "pgm", "pieceType": "normal", "sourceLayerId": "vt0", - "startPartId": "randomId9010_part0_0", - "startRundownId": "randomId9010", - "startSegmentId": "randomId9010_segment0", + "startPartId": "randomId9002_part0_0", + "startRundownId": "randomId9002", + "startSegmentId": "randomId9002_segment0", "timelineObjectsString": "[]", }, "playlistActivationId": "", "priority": 5, - "rundownId": "randomId9010", + "rundownId": "randomId9002", }, "outputLayer": { "_id": "pgm", @@ -85,11 +85,11 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu "pieces": [ { "instance": { - "_id": "randomId9010_part0_1_tmp_instance_randomId9010_piece010", + "_id": "randomId9002_part0_1_tmp_instance_randomId9002_piece010", "isTemporary": true, - "partInstanceId": "randomId9010_part0_1_tmp_instance", + "partInstanceId": "randomId9002_part0_1_tmp_instance", "piece": { - "_id": "randomId9010_piece010", + "_id": "randomId9002_piece010", "content": {}, "enable": { "start": 0, @@ -101,14 +101,14 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu "outputLayerId": "pgm", "pieceType": "normal", "sourceLayerId": "cam0", - "startPartId": "randomId9010_part0_1", - "startRundownId": "randomId9010", - "startSegmentId": "randomId9010_segment0", + "startPartId": "randomId9002_part0_1", + "startRundownId": "randomId9002", + "startSegmentId": "randomId9002_segment0", "timelineObjectsString": "[]", }, "playlistActivationId": "", "priority": 5, - "rundownId": "randomId9010", + "rundownId": "randomId9002", }, "outputLayer": [Circular], "renderedDuration": null, @@ -142,32 +142,32 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu }, { "instance": { - "_id": "randomId9010_part0_1_tmp_instance", + "_id": "randomId9002_part0_1_tmp_instance", "isTemporary": true, "part": { - "_id": "randomId9010_part0_1", + "_id": "randomId9002_part0_1", "_rank": 1, "externalId": "MOCK_PART_0_1", - "rundownId": "randomId9010", - "segmentId": "randomId9010_segment0", + "rundownId": "randomId9002", + "segmentId": "randomId9002_segment0", "title": "Part 0 1", }, "playlistActivationId": "", "rehearsal": false, - "rundownId": "randomId9010", - "segmentId": "randomId9010_segment0", + "rundownId": "randomId9002", + "segmentId": "randomId9002_segment0", "segmentPlayoutId": "", "takeCount": -1, }, - "partId": "randomId9010_part0_1", + "partId": "randomId9002_part0_1", "pieces": [ { "instance": { - "_id": "randomId9010_part0_1_tmp_instance_randomId9010_piece010", + "_id": "randomId9002_part0_1_tmp_instance_randomId9002_piece010", "isTemporary": true, - "partInstanceId": "randomId9010_part0_1_tmp_instance", + "partInstanceId": "randomId9002_part0_1_tmp_instance", "piece": { - "_id": "randomId9010_piece010", + "_id": "randomId9002_piece010", "content": {}, "enable": { "start": 0, @@ -179,14 +179,14 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu "outputLayerId": "pgm", "pieceType": "normal", "sourceLayerId": "cam0", - "startPartId": "randomId9010_part0_1", - "startRundownId": "randomId9010", - "startSegmentId": "randomId9010_segment0", + "startPartId": "randomId9002_part0_1", + "startRundownId": "randomId9002", + "startSegmentId": "randomId9002_segment0", "timelineObjectsString": "[]", }, "playlistActivationId": "", "priority": 5, - "rundownId": "randomId9010", + "rundownId": "randomId9002", }, "outputLayer": { "_id": "pgm", @@ -203,11 +203,11 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu "pieces": [ { "instance": { - "_id": "randomId9010_part0_0_tmp_instance_randomId9010_piece001", + "_id": "randomId9002_part0_0_tmp_instance_randomId9002_piece001", "isTemporary": true, - "partInstanceId": "randomId9010_part0_0_tmp_instance", + "partInstanceId": "randomId9002_part0_0_tmp_instance", "piece": { - "_id": "randomId9010_piece001", + "_id": "randomId9002_piece001", "content": {}, "enable": { "start": 0, @@ -219,14 +219,14 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu "outputLayerId": "pgm", "pieceType": "normal", "sourceLayerId": "vt0", - "startPartId": "randomId9010_part0_0", - "startRundownId": "randomId9010", - "startSegmentId": "randomId9010_segment0", + "startPartId": "randomId9002_part0_0", + "startRundownId": "randomId9002", + "startSegmentId": "randomId9002_segment0", "timelineObjectsString": "[]", }, "playlistActivationId": "", "priority": 5, - "rundownId": "randomId9010", + "rundownId": "randomId9002", }, "outputLayer": [Circular], "renderedDuration": null, @@ -271,7 +271,7 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu }, ], "segmentExtended": { - "_id": "randomId9010_segment0", + "_id": "randomId9002_segment0", "_rank": 0, "externalId": "MOCK_SEGMENT_0", "externalModified": 1, @@ -292,11 +292,11 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu "pieces": [ { "instance": { - "_id": "randomId9010_part0_0_tmp_instance_randomId9010_piece001", + "_id": "randomId9002_part0_0_tmp_instance_randomId9002_piece001", "isTemporary": true, - "partInstanceId": "randomId9010_part0_0_tmp_instance", + "partInstanceId": "randomId9002_part0_0_tmp_instance", "piece": { - "_id": "randomId9010_piece001", + "_id": "randomId9002_piece001", "content": {}, "enable": { "start": 0, @@ -308,14 +308,14 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu "outputLayerId": "pgm", "pieceType": "normal", "sourceLayerId": "vt0", - "startPartId": "randomId9010_part0_0", - "startRundownId": "randomId9010", - "startSegmentId": "randomId9010_segment0", + "startPartId": "randomId9002_part0_0", + "startRundownId": "randomId9002", + "startSegmentId": "randomId9002_segment0", "timelineObjectsString": "[]", }, "playlistActivationId": "", "priority": 5, - "rundownId": "randomId9010", + "rundownId": "randomId9002", }, "outputLayer": [Circular], "renderedDuration": null, @@ -334,11 +334,11 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu "pieces": [ { "instance": { - "_id": "randomId9010_part0_1_tmp_instance_randomId9010_piece010", + "_id": "randomId9002_part0_1_tmp_instance_randomId9002_piece010", "isTemporary": true, - "partInstanceId": "randomId9010_part0_1_tmp_instance", + "partInstanceId": "randomId9002_part0_1_tmp_instance", "piece": { - "_id": "randomId9010_piece010", + "_id": "randomId9002_piece010", "content": {}, "enable": { "start": 0, @@ -350,14 +350,14 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu "outputLayerId": "pgm", "pieceType": "normal", "sourceLayerId": "cam0", - "startPartId": "randomId9010_part0_1", - "startRundownId": "randomId9010", - "startSegmentId": "randomId9010_segment0", + "startPartId": "randomId9002_part0_1", + "startRundownId": "randomId9002", + "startSegmentId": "randomId9002_segment0", "timelineObjectsString": "[]", }, "playlistActivationId": "", "priority": 5, - "rundownId": "randomId9010", + "rundownId": "randomId9002", }, "outputLayer": [Circular], "renderedDuration": null, @@ -371,7 +371,7 @@ exports[`client/lib/rundown RundownUtils.getResolvedSegment Basic Segment resolu "used": true, }, }, - "rundownId": "randomId9010", + "rundownId": "randomId9002", "sourceLayers": { "cam0": { "_id": "cam0", diff --git a/meteor/client/lib/__tests__/rundown.test.ts b/packages/webui/src/client/lib/__tests__/rundown.test.ts similarity index 62% rename from meteor/client/lib/__tests__/rundown.test.ts rename to packages/webui/src/client/lib/__tests__/rundown.test.ts index e0fd6c7ace..a0af6e0bea 100644 --- a/meteor/client/lib/__tests__/rundown.test.ts +++ b/packages/webui/src/client/lib/__tests__/rundown.test.ts @@ -1,4 +1,3 @@ -import { testInFiber } from '../../../__mocks__/helpers/jest' import { setupDefaultStudioEnvironment, DefaultEnvironment, @@ -30,7 +29,7 @@ describe('client/lib/rundown', () => { playlistId = (await setupDefaultRundownPlaylist(env)).playlistId }) describe('RundownUtils.getResolvedSegment', () => { - testInFiber('Basic Segment resolution', () => { + test('Basic Segment resolution', () => { const showStyleBase = convertToUIShowStyleBase(env.showStyleBase) const playlist = RundownPlaylists.findOne(playlistId) if (!playlist) throw new Error('Rundown not found') @@ -70,7 +69,7 @@ describe('client/lib/rundown', () => { expect(resolvedSegment).toMatchSnapshot() }) - testInFiber('Infinite Piece starting in first Part is populated across the Segment', () => { + test('Infinite Piece starting in first Part is populated across the Segment', () => { const showStyleBase = convertToUIShowStyleBase(env.showStyleBase) const sourceLayerIds = Object.keys(showStyleBase.sourceLayers) const outputLayerIds = Object.keys(showStyleBase.outputLayers) @@ -142,7 +141,7 @@ describe('client/lib/rundown', () => { expect(resolvedInfinitePiece01?.renderedDuration).toBe(null) }) - testInFiber('Infinite Piece starting in first Part is cropped by another Piece', () => { + test('Infinite Piece starting in first Part is cropped by another Piece', () => { const showStyleBase = convertToUIShowStyleBase(env.showStyleBase) const sourceLayerIds = Object.keys(showStyleBase.sourceLayers) const outputLayerIds = Object.keys(showStyleBase.outputLayers) @@ -233,165 +232,152 @@ describe('client/lib/rundown', () => { expect(resolvedSegment.parts[1].pieces).toHaveLength(0) }) - testInFiber( - "User-Stopped Infinite Piece starting in first Part maintains it's length when followed by another Piece", - () => { - const showStyleBase = convertToUIShowStyleBase(env.showStyleBase) - const sourceLayerIds = Object.keys(showStyleBase.sourceLayers) - const outputLayerIds = Object.keys(showStyleBase.outputLayers) - - const playlistActivationId = protectString('mock_activation_0') - mockRundownPlaylistsCollection.update(unprotectString(playlistId), { - $set: { - activationId: playlistActivationId, - }, - }) - - let playlist = RundownPlaylists.findOne(playlistId) - if (!playlist) throw new Error('Playlist not found') - - const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) - const { parts, segments } = RundownPlaylistCollectionUtil.getSegmentsAndPartsSync(playlist) - const rundown = rundowns[0] - const rundownId = rundown._id - const segment = segments[1] - const firstPart = parts.find((part) => part.segmentId === segment._id) - - if (!firstPart) throw new Error('Mock Segment 1 not found') - - const infinitePiece: Piece = { - ...defaultPiece( - protectString(rundownId + '_infinite_piece'), - rundownId, - segment._id, - firstPart._id - ), - externalId: 'MOCK_INFINITE_PIECE', - name: 'Infinite', - sourceLayerId: sourceLayerIds[0], - outputLayerId: outputLayerIds[0], - enable: { - start: 1000, - }, - lifespan: PieceLifespan.OutOnSegmentChange, - } - mockPiecesCollection.insert(infinitePiece) - - const followingPiece: Piece = { - ...defaultPiece( - protectString(rundownId + '_cropping_piece'), - rundownId, - segment._id, - firstPart._id - ), - externalId: 'MOCK_CROPPING_PIECE', - name: 'Cropping', - sourceLayerId: sourceLayerIds[0], - outputLayerId: outputLayerIds[0], - enable: { - start: 4000, - duration: 1000, - }, - lifespan: PieceLifespan.WithinPart, - } - mockPiecesCollection.insert(followingPiece) - - const segmentPlayoutId = protectString('mock_segment_playout_0') - const mockCurrentPartInstance: PartInstance = { - ...defaultPartInstance( - protectString(rundownId + '_partInstance_0'), - playlistActivationId, - segmentPlayoutId, - firstPart - ), - } - - mockPartInstancesCollection.insert(mockCurrentPartInstance) - - const infinitePieceInstance: PieceInstance = { - ...defaultPieceInstance( - protectString('instance_' + infinitePiece._id), - playlistActivationId, - rundown._id, - mockCurrentPartInstance._id, - infinitePiece - ), - userDuration: { - endRelativeToPart: 2000, - }, - } - - mockPieceInstancesCollection.insert(infinitePieceInstance) - - const followingPieceInstance: PieceInstance = { - ...defaultPieceInstance( - protectString('instance_' + followingPiece._id), - playlistActivationId, - rundown._id, - mockCurrentPartInstance._id, - followingPiece - ), - } - - mockPieceInstancesCollection.insert(followingPieceInstance) - - mockRundownPlaylistsCollection.update(unprotectString(playlistId), { - $set: { - currentPartInfo: { - partInstanceId: mockCurrentPartInstance._id, - rundownId: mockCurrentPartInstance.rundownId, - manuallySelected: false, - consumesQueuedSegmentId: false, - }, - }, - }) - - playlist = RundownPlaylists.findOne(playlistId) - if (!playlist) throw new Error('Playlist not found') - const { currentPartInstance, nextPartInstance } = - RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) - - const resolvedSegment = RundownUtils.getResolvedSegment( - showStyleBase, - playlist, - rundown, - segment, - new Set(segments.slice(0, 1).map((s) => s._id)), - [], - new Map(), - parts.map((part) => part._id), - currentPartInstance, - nextPartInstance - ) - expect(resolvedSegment).toBeTruthy() - expect(resolvedSegment.parts).toHaveLength(3) - expect(resolvedSegment).toMatchObject({ - isLiveSegment: true, - isNextSegment: false, - currentNextPart: undefined, - hasRemoteItems: false, - hasGuestItems: false, - autoNextPart: false, - }) - - expect(resolvedSegment.parts[0].pieces).toHaveLength(2) - - const resolvedInfinitePiece00 = resolvedSegment.parts[0].pieces.find( - (piece) => piece.instance._id === infinitePieceInstance._id - ) - expect(resolvedInfinitePiece00).toBeDefined() - expect(resolvedInfinitePiece00?.renderedInPoint).toBe(1000) - expect(resolvedInfinitePiece00?.renderedDuration).toBe(1000) - - const resolvedCroppingPiece00 = resolvedSegment.parts[0].pieces.find( - (piece) => piece.instance._id === followingPieceInstance._id - ) - expect(resolvedCroppingPiece00).toBeDefined() - expect(resolvedCroppingPiece00?.renderedInPoint).toBe(4000) - expect(resolvedCroppingPiece00?.renderedDuration).toBe(1000) - - expect(resolvedSegment.parts[1].pieces).toHaveLength(0) + test("User-Stopped Infinite Piece starting in first Part maintains it's length when followed by another Piece", () => { + const showStyleBase = convertToUIShowStyleBase(env.showStyleBase) + const sourceLayerIds = Object.keys(showStyleBase.sourceLayers) + const outputLayerIds = Object.keys(showStyleBase.outputLayers) + + const playlistActivationId = protectString('mock_activation_0') + mockRundownPlaylistsCollection.update(unprotectString(playlistId), { + $set: { + activationId: playlistActivationId, + }, + }) + + let playlist = RundownPlaylists.findOne(playlistId) + if (!playlist) throw new Error('Playlist not found') + + const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist) + const { parts, segments } = RundownPlaylistCollectionUtil.getSegmentsAndPartsSync(playlist) + const rundown = rundowns[0] + const rundownId = rundown._id + const segment = segments[1] + const firstPart = parts.find((part) => part.segmentId === segment._id) + + if (!firstPart) throw new Error('Mock Segment 1 not found') + + const infinitePiece: Piece = { + ...defaultPiece(protectString(rundownId + '_infinite_piece'), rundownId, segment._id, firstPart._id), + externalId: 'MOCK_INFINITE_PIECE', + name: 'Infinite', + sourceLayerId: sourceLayerIds[0], + outputLayerId: outputLayerIds[0], + enable: { + start: 1000, + }, + lifespan: PieceLifespan.OutOnSegmentChange, + } + mockPiecesCollection.insert(infinitePiece) + + const followingPiece: Piece = { + ...defaultPiece(protectString(rundownId + '_cropping_piece'), rundownId, segment._id, firstPart._id), + externalId: 'MOCK_CROPPING_PIECE', + name: 'Cropping', + sourceLayerId: sourceLayerIds[0], + outputLayerId: outputLayerIds[0], + enable: { + start: 4000, + duration: 1000, + }, + lifespan: PieceLifespan.WithinPart, + } + mockPiecesCollection.insert(followingPiece) + + const segmentPlayoutId = protectString('mock_segment_playout_0') + const mockCurrentPartInstance: PartInstance = { + ...defaultPartInstance( + protectString(rundownId + '_partInstance_0'), + playlistActivationId, + segmentPlayoutId, + firstPart + ), + } + + mockPartInstancesCollection.insert(mockCurrentPartInstance) + + const infinitePieceInstance: PieceInstance = { + ...defaultPieceInstance( + protectString('instance_' + infinitePiece._id), + playlistActivationId, + rundown._id, + mockCurrentPartInstance._id, + infinitePiece + ), + userDuration: { + endRelativeToPart: 2000, + }, } - ) + + mockPieceInstancesCollection.insert(infinitePieceInstance) + + const followingPieceInstance: PieceInstance = { + ...defaultPieceInstance( + protectString('instance_' + followingPiece._id), + playlistActivationId, + rundown._id, + mockCurrentPartInstance._id, + followingPiece + ), + } + + mockPieceInstancesCollection.insert(followingPieceInstance) + + mockRundownPlaylistsCollection.update(unprotectString(playlistId), { + $set: { + currentPartInfo: { + partInstanceId: mockCurrentPartInstance._id, + rundownId: mockCurrentPartInstance.rundownId, + manuallySelected: false, + consumesQueuedSegmentId: false, + }, + }, + }) + + playlist = RundownPlaylists.findOne(playlistId) + if (!playlist) throw new Error('Playlist not found') + const { currentPartInstance, nextPartInstance } = + RundownPlaylistCollectionUtil.getSelectedPartInstances(playlist) + + const resolvedSegment = RundownUtils.getResolvedSegment( + showStyleBase, + playlist, + rundown, + segment, + new Set(segments.slice(0, 1).map((s) => s._id)), + [], + new Map(), + parts.map((part) => part._id), + currentPartInstance, + nextPartInstance + ) + expect(resolvedSegment).toBeTruthy() + expect(resolvedSegment.parts).toHaveLength(3) + expect(resolvedSegment).toMatchObject({ + isLiveSegment: true, + isNextSegment: false, + currentNextPart: undefined, + hasRemoteItems: false, + hasGuestItems: false, + autoNextPart: false, + }) + + expect(resolvedSegment.parts[0].pieces).toHaveLength(2) + + const resolvedInfinitePiece00 = resolvedSegment.parts[0].pieces.find( + (piece) => piece.instance._id === infinitePieceInstance._id + ) + expect(resolvedInfinitePiece00).toBeDefined() + expect(resolvedInfinitePiece00?.renderedInPoint).toBe(1000) + expect(resolvedInfinitePiece00?.renderedDuration).toBe(1000) + + const resolvedCroppingPiece00 = resolvedSegment.parts[0].pieces.find( + (piece) => piece.instance._id === followingPieceInstance._id + ) + expect(resolvedCroppingPiece00).toBeDefined() + expect(resolvedCroppingPiece00?.renderedInPoint).toBe(4000) + expect(resolvedCroppingPiece00?.renderedDuration).toBe(1000) + + expect(resolvedSegment.parts[1].pieces).toHaveLength(0) + }) }) }) diff --git a/meteor/client/lib/__tests__/rundownTiming.test.ts b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts similarity index 100% rename from meteor/client/lib/__tests__/rundownTiming.test.ts rename to packages/webui/src/client/lib/__tests__/rundownTiming.test.ts diff --git a/meteor/client/lib/clientAPI.ts b/packages/webui/src/client/lib/clientAPI.ts similarity index 100% rename from meteor/client/lib/clientAPI.ts rename to packages/webui/src/client/lib/clientAPI.ts diff --git a/meteor/client/lib/colorPicker.tsx b/packages/webui/src/client/lib/colorPicker.tsx similarity index 100% rename from meteor/client/lib/colorPicker.tsx rename to packages/webui/src/client/lib/colorPicker.tsx diff --git a/meteor/client/lib/currentTimeReactive.ts b/packages/webui/src/client/lib/currentTimeReactive.ts similarity index 100% rename from meteor/client/lib/currentTimeReactive.ts rename to packages/webui/src/client/lib/currentTimeReactive.ts diff --git a/meteor/client/lib/data/mos/__tests__/plugin-support.test.ts b/packages/webui/src/client/lib/data/mos/__tests__/plugin-support.test.ts similarity index 100% rename from meteor/client/lib/data/mos/__tests__/plugin-support.test.ts rename to packages/webui/src/client/lib/data/mos/__tests__/plugin-support.test.ts diff --git a/meteor/client/lib/data/mos/plugin-support.ts b/packages/webui/src/client/lib/data/mos/plugin-support.ts similarity index 94% rename from meteor/client/lib/data/mos/plugin-support.ts rename to packages/webui/src/client/lib/data/mos/plugin-support.ts index 73bc8b9ed7..0f271e824a 100644 --- a/meteor/client/lib/data/mos/plugin-support.ts +++ b/packages/webui/src/client/lib/data/mos/plugin-support.ts @@ -1,7 +1,5 @@ import { objectToXML } from '../util/object-to-xml' -const PackageInfo = require('../../../../package.json') - export enum AckStatus { ACK = 'ACK', Error = 'ERROR', @@ -68,7 +66,7 @@ export function createMosAppInfoXmlString(uiMetrics?: UIMetric[]): string { software: { manufacturer: 'Sofie Project', product: 'Sofie TV Automation', - version: PackageInfo.version || 'UNSTABLE', + version: __APP_VERSION__ || 'UNSTABLE', }, uiMetric: uiMetrics ? uiMetrics.map((metric, index) => { diff --git a/meteor/client/lib/data/nora/browser-plugin-data.ts b/packages/webui/src/client/lib/data/nora/browser-plugin-data.ts similarity index 100% rename from meteor/client/lib/data/nora/browser-plugin-data.ts rename to packages/webui/src/client/lib/data/nora/browser-plugin-data.ts diff --git a/meteor/client/lib/data/util/__tests__/object-to-xml.test.ts b/packages/webui/src/client/lib/data/util/__tests__/object-to-xml.test.ts similarity index 100% rename from meteor/client/lib/data/util/__tests__/object-to-xml.test.ts rename to packages/webui/src/client/lib/data/util/__tests__/object-to-xml.test.ts diff --git a/meteor/client/lib/data/util/object-to-xml.ts b/packages/webui/src/client/lib/data/util/object-to-xml.ts similarity index 100% rename from meteor/client/lib/data/util/object-to-xml.ts rename to packages/webui/src/client/lib/data/util/object-to-xml.ts diff --git a/meteor/client/lib/datePicker.tsx b/packages/webui/src/client/lib/datePicker.tsx similarity index 100% rename from meteor/client/lib/datePicker.tsx rename to packages/webui/src/client/lib/datePicker.tsx diff --git a/meteor/client/lib/dev.ts b/packages/webui/src/client/lib/dev.ts similarity index 94% rename from meteor/client/lib/dev.ts rename to packages/webui/src/client/lib/dev.ts index a6a7769694..b68b808695 100644 --- a/meteor/client/lib/dev.ts +++ b/packages/webui/src/client/lib/dev.ts @@ -1,5 +1,5 @@ import { getCurrentTime } from '../../lib/lib' -import { Session } from 'meteor/session' +// import { Session } from 'meteor/session' import { Meteor } from 'meteor/meteor' import { Tracker } from 'meteor/tracker' import * as _ from 'underscore' @@ -17,7 +17,7 @@ Meteor.startup(() => { }) windowAny['getCurrentTime'] = getCurrentTime -windowAny['Session'] = Session +// windowAny['Session'] = Session function setDebugData() { Tracker.autorun(() => { diff --git a/meteor/client/lib/downloadBlob.ts b/packages/webui/src/client/lib/downloadBlob.ts similarity index 100% rename from meteor/client/lib/downloadBlob.ts rename to packages/webui/src/client/lib/downloadBlob.ts diff --git a/meteor/client/lib/forms/SchemaFormForCollection.tsx b/packages/webui/src/client/lib/forms/SchemaFormForCollection.tsx similarity index 97% rename from meteor/client/lib/forms/SchemaFormForCollection.tsx rename to packages/webui/src/client/lib/forms/SchemaFormForCollection.tsx index e70c205f7e..0c4024854c 100644 --- a/meteor/client/lib/forms/SchemaFormForCollection.tsx +++ b/packages/webui/src/client/lib/forms/SchemaFormForCollection.tsx @@ -4,7 +4,7 @@ import { ObjectOverrideDeleteOp, ObjectOverrideSetOp, } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' -import React, { useCallback, useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { MongoCollection } from '../../../lib/collections/lib' import { WrappedOverridableItemNormal, @@ -120,7 +120,7 @@ class OverrideOpHelperCollection implements OverrideOpHelperForItemContentsBatch this.#changes.$unset[`${this.#basePath}.${subPath}`] = 1 } else { if (!this.#changes.$set) this.#changes.$set = {} - ;(this.#changes.$set[`${this.#basePath}.${subPath}`] as any) = value + this.#changes.$set[`${this.#basePath}.${subPath}`] = value } return this diff --git a/meteor/client/lib/forms/SchemaFormInPlace.tsx b/packages/webui/src/client/lib/forms/SchemaFormInPlace.tsx similarity index 97% rename from meteor/client/lib/forms/SchemaFormInPlace.tsx rename to packages/webui/src/client/lib/forms/SchemaFormInPlace.tsx index 9d7be60ec1..9afc265469 100644 --- a/meteor/client/lib/forms/SchemaFormInPlace.tsx +++ b/packages/webui/src/client/lib/forms/SchemaFormInPlace.tsx @@ -1,5 +1,5 @@ import { literal, objectPathSet } from '@sofie-automation/corelib/dist/lib' -import React, { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { WrappedOverridableItemNormal, OverrideOpHelperForItemContentsBatcher, diff --git a/meteor/client/lib/forms/SchemaFormSectionHeader.tsx b/packages/webui/src/client/lib/forms/SchemaFormSectionHeader.tsx similarity index 95% rename from meteor/client/lib/forms/SchemaFormSectionHeader.tsx rename to packages/webui/src/client/lib/forms/SchemaFormSectionHeader.tsx index b230e9516d..3c9b219565 100644 --- a/meteor/client/lib/forms/SchemaFormSectionHeader.tsx +++ b/packages/webui/src/client/lib/forms/SchemaFormSectionHeader.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { translateStringIfHasNamespaces } from './schemaFormUtil' export function SchemaFormSectionHeader({ diff --git a/meteor/client/lib/forms/SchemaFormTable/ArrayTable.tsx b/packages/webui/src/client/lib/forms/SchemaFormTable/ArrayTable.tsx similarity index 100% rename from meteor/client/lib/forms/SchemaFormTable/ArrayTable.tsx rename to packages/webui/src/client/lib/forms/SchemaFormTable/ArrayTable.tsx diff --git a/meteor/client/lib/forms/SchemaFormTable/ArrayTableOpHelper.tsx b/packages/webui/src/client/lib/forms/SchemaFormTable/ArrayTableOpHelper.tsx similarity index 100% rename from meteor/client/lib/forms/SchemaFormTable/ArrayTableOpHelper.tsx rename to packages/webui/src/client/lib/forms/SchemaFormTable/ArrayTableOpHelper.tsx diff --git a/meteor/client/lib/forms/SchemaFormTable/ArrayTableRow.tsx b/packages/webui/src/client/lib/forms/SchemaFormTable/ArrayTableRow.tsx similarity index 100% rename from meteor/client/lib/forms/SchemaFormTable/ArrayTableRow.tsx rename to packages/webui/src/client/lib/forms/SchemaFormTable/ArrayTableRow.tsx diff --git a/meteor/client/lib/forms/SchemaFormTable/ObjectTable.scss b/packages/webui/src/client/lib/forms/SchemaFormTable/ObjectTable.scss similarity index 100% rename from meteor/client/lib/forms/SchemaFormTable/ObjectTable.scss rename to packages/webui/src/client/lib/forms/SchemaFormTable/ObjectTable.scss diff --git a/meteor/client/lib/forms/SchemaFormTable/ObjectTable.tsx b/packages/webui/src/client/lib/forms/SchemaFormTable/ObjectTable.tsx similarity index 100% rename from meteor/client/lib/forms/SchemaFormTable/ObjectTable.tsx rename to packages/webui/src/client/lib/forms/SchemaFormTable/ObjectTable.tsx diff --git a/meteor/client/lib/forms/SchemaFormTable/ObjectTableDeletedRow.tsx b/packages/webui/src/client/lib/forms/SchemaFormTable/ObjectTableDeletedRow.tsx similarity index 96% rename from meteor/client/lib/forms/SchemaFormTable/ObjectTableDeletedRow.tsx rename to packages/webui/src/client/lib/forms/SchemaFormTable/ObjectTableDeletedRow.tsx index d876c2c263..700c5cbcfc 100644 --- a/meteor/client/lib/forms/SchemaFormTable/ObjectTableDeletedRow.tsx +++ b/packages/webui/src/client/lib/forms/SchemaFormTable/ObjectTableDeletedRow.tsx @@ -1,7 +1,7 @@ import { faSync } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { objectPathGet } from '@sofie-automation/corelib/dist/lib' -import React, { useCallback } from 'react' +import { useCallback } from 'react' import { SchemaSummaryField } from '../schemaFormUtil' interface ObjectTableDeletedRowProps { diff --git a/meteor/client/lib/forms/SchemaFormTable/ObjectTableOpHelper.tsx b/packages/webui/src/client/lib/forms/SchemaFormTable/ObjectTableOpHelper.tsx similarity index 100% rename from meteor/client/lib/forms/SchemaFormTable/ObjectTableOpHelper.tsx rename to packages/webui/src/client/lib/forms/SchemaFormTable/ObjectTableOpHelper.tsx diff --git a/meteor/client/lib/forms/SchemaFormTable/TableEditRow.tsx b/packages/webui/src/client/lib/forms/SchemaFormTable/TableEditRow.tsx similarity index 97% rename from meteor/client/lib/forms/SchemaFormTable/TableEditRow.tsx rename to packages/webui/src/client/lib/forms/SchemaFormTable/TableEditRow.tsx index 215c970c81..d797966c90 100644 --- a/meteor/client/lib/forms/SchemaFormTable/TableEditRow.tsx +++ b/packages/webui/src/client/lib/forms/SchemaFormTable/TableEditRow.tsx @@ -1,7 +1,7 @@ import { faCheck } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import classNames from 'classnames' -import React, { useCallback } from 'react' +import { useCallback } from 'react' import { OverrideOpHelperForItemContents } from '../../../ui/Settings/util/OverrideOpHelper' import { SchemaFormSofieEnumDefinition } from '../schemaFormUtil' import { SchemaFormWithOverrides } from '../SchemaFormWithOverrides' diff --git a/meteor/client/lib/forms/SchemaFormWithOverrides.tsx b/packages/webui/src/client/lib/forms/SchemaFormWithOverrides.tsx similarity index 99% rename from meteor/client/lib/forms/SchemaFormWithOverrides.tsx rename to packages/webui/src/client/lib/forms/SchemaFormWithOverrides.tsx index d55d193dd8..13488af167 100644 --- a/meteor/client/lib/forms/SchemaFormWithOverrides.tsx +++ b/packages/webui/src/client/lib/forms/SchemaFormWithOverrides.tsx @@ -1,5 +1,5 @@ import { joinObjectPathFragments, literal } from '@sofie-automation/corelib/dist/lib' -import React, { useMemo } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { WrappedOverridableItemNormal, OverrideOpHelperForItemContents } from '../../ui/Settings/util/OverrideOpHelper' import { CheckboxControl } from '../Components/Checkbox' diff --git a/meteor/client/lib/forms/SchemaTableSummaryRow.tsx b/packages/webui/src/client/lib/forms/SchemaTableSummaryRow.tsx similarity index 98% rename from meteor/client/lib/forms/SchemaTableSummaryRow.tsx rename to packages/webui/src/client/lib/forms/SchemaTableSummaryRow.tsx index 7cef346160..402589bd5b 100644 --- a/meteor/client/lib/forms/SchemaTableSummaryRow.tsx +++ b/packages/webui/src/client/lib/forms/SchemaTableSummaryRow.tsx @@ -2,7 +2,7 @@ import { faPencilAlt, faSync, faTrash } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { objectPathGet } from '@sofie-automation/corelib/dist/lib' import classNames from 'classnames' -import React, { useCallback } from 'react' +import { useCallback } from 'react' import { SchemaSummaryField } from './schemaFormUtil' import { WrappedOverridableItemNormal } from '../../ui/Settings/util/OverrideOpHelper' import { useTranslation } from 'react-i18next' diff --git a/meteor/client/lib/forms/schemaFormUtil.tsx b/packages/webui/src/client/lib/forms/schemaFormUtil.tsx similarity index 99% rename from meteor/client/lib/forms/schemaFormUtil.tsx rename to packages/webui/src/client/lib/forms/schemaFormUtil.tsx index a603d6eb1e..783b642644 100644 --- a/meteor/client/lib/forms/schemaFormUtil.tsx +++ b/packages/webui/src/client/lib/forms/schemaFormUtil.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { translateMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' import { i18nTranslator } from '../../ui/i18n' import { JSONSchema, TypeName } from '@sofie-automation/shared-lib/dist/lib/JSONSchemaTypes' diff --git a/meteor/client/lib/hotkeyHelper.ts b/packages/webui/src/client/lib/hotkeyHelper.ts similarity index 100% rename from meteor/client/lib/hotkeyHelper.ts rename to packages/webui/src/client/lib/hotkeyHelper.ts diff --git a/meteor/client/lib/iconPicker.tsx b/packages/webui/src/client/lib/iconPicker.tsx similarity index 100% rename from meteor/client/lib/iconPicker.tsx rename to packages/webui/src/client/lib/iconPicker.tsx diff --git a/meteor/client/lib/language.ts b/packages/webui/src/client/lib/language.ts similarity index 100% rename from meteor/client/lib/language.ts rename to packages/webui/src/client/lib/language.ts diff --git a/meteor/client/lib/lib.tsx b/packages/webui/src/client/lib/lib.tsx similarity index 100% rename from meteor/client/lib/lib.tsx rename to packages/webui/src/client/lib/lib.tsx diff --git a/meteor/client/lib/localStorage.ts b/packages/webui/src/client/lib/localStorage.ts similarity index 100% rename from meteor/client/lib/localStorage.ts rename to packages/webui/src/client/lib/localStorage.ts diff --git a/meteor/client/lib/logStatus.ts b/packages/webui/src/client/lib/logStatus.ts similarity index 100% rename from meteor/client/lib/logStatus.ts rename to packages/webui/src/client/lib/logStatus.ts diff --git a/meteor/client/lib/multiSelect.tsx b/packages/webui/src/client/lib/multiSelect.tsx similarity index 99% rename from meteor/client/lib/multiSelect.tsx rename to packages/webui/src/client/lib/multiSelect.tsx index ea3d515d92..396c3be59c 100644 --- a/meteor/client/lib/multiSelect.tsx +++ b/packages/webui/src/client/lib/multiSelect.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import * as _ from 'underscore' +import _ from 'underscore' import ClassNames from 'classnames' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' diff --git a/meteor/client/lib/notifications/NotificationCenterPanel.tsx b/packages/webui/src/client/lib/notifications/NotificationCenterPanel.tsx similarity index 99% rename from meteor/client/lib/notifications/NotificationCenterPanel.tsx rename to packages/webui/src/client/lib/notifications/NotificationCenterPanel.tsx index fb3d561919..fd49e3fcc2 100644 --- a/meteor/client/lib/notifications/NotificationCenterPanel.tsx +++ b/packages/webui/src/client/lib/notifications/NotificationCenterPanel.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import CoreIcon from '@nrk/core-icons/jsx' +import * as CoreIcon from '@nrk/core-icons/jsx' import ClassNames from 'classnames' // @ts-expect-error No types available import * as VelocityReact from 'velocity-react' diff --git a/meteor/client/lib/notifications/ReactNotification.tsx b/packages/webui/src/client/lib/notifications/ReactNotification.tsx similarity index 100% rename from meteor/client/lib/notifications/ReactNotification.tsx rename to packages/webui/src/client/lib/notifications/ReactNotification.tsx diff --git a/meteor/client/lib/notifications/warningIcon.tsx b/packages/webui/src/client/lib/notifications/warningIcon.tsx similarity index 100% rename from meteor/client/lib/notifications/warningIcon.tsx rename to packages/webui/src/client/lib/notifications/warningIcon.tsx diff --git a/meteor/client/lib/parsers/mos/__tests__/mosReqAppInfo.xml b/packages/webui/src/client/lib/parsers/mos/__tests__/mosReqAppInfo.xml similarity index 100% rename from meteor/client/lib/parsers/mos/__tests__/mosReqAppInfo.xml rename to packages/webui/src/client/lib/parsers/mos/__tests__/mosReqAppInfo.xml diff --git a/meteor/client/lib/parsers/mos/__tests__/mosSample1.json b/packages/webui/src/client/lib/parsers/mos/__tests__/mosSample1.json similarity index 100% rename from meteor/client/lib/parsers/mos/__tests__/mosSample1.json rename to packages/webui/src/client/lib/parsers/mos/__tests__/mosSample1.json diff --git a/meteor/client/lib/parsers/mos/__tests__/mosSample1.xml b/packages/webui/src/client/lib/parsers/mos/__tests__/mosSample1.xml similarity index 100% rename from meteor/client/lib/parsers/mos/__tests__/mosSample1.xml rename to packages/webui/src/client/lib/parsers/mos/__tests__/mosSample1.xml diff --git a/meteor/client/lib/parsers/mos/__tests__/mosSample2.json b/packages/webui/src/client/lib/parsers/mos/__tests__/mosSample2.json similarity index 100% rename from meteor/client/lib/parsers/mos/__tests__/mosSample2.json rename to packages/webui/src/client/lib/parsers/mos/__tests__/mosSample2.json diff --git a/meteor/client/lib/parsers/mos/__tests__/mosSample2.xml b/packages/webui/src/client/lib/parsers/mos/__tests__/mosSample2.xml similarity index 100% rename from meteor/client/lib/parsers/mos/__tests__/mosSample2.xml rename to packages/webui/src/client/lib/parsers/mos/__tests__/mosSample2.xml diff --git a/meteor/client/lib/parsers/mos/__tests__/mosXml2Js.test.ts b/packages/webui/src/client/lib/parsers/mos/__tests__/mosXml2Js.test.ts similarity index 100% rename from meteor/client/lib/parsers/mos/__tests__/mosXml2Js.test.ts rename to packages/webui/src/client/lib/parsers/mos/__tests__/mosXml2Js.test.ts diff --git a/meteor/client/lib/parsers/mos/mosXml2Js.ts b/packages/webui/src/client/lib/parsers/mos/mosXml2Js.ts similarity index 100% rename from meteor/client/lib/parsers/mos/mosXml2Js.ts rename to packages/webui/src/client/lib/parsers/mos/mosXml2Js.ts diff --git a/meteor/client/lib/polyfill/polyfills.ts b/packages/webui/src/client/lib/polyfill/polyfills.ts similarity index 100% rename from meteor/client/lib/polyfill/polyfills.ts rename to packages/webui/src/client/lib/polyfill/polyfills.ts diff --git a/meteor/client/lib/polyfill/promise.allSettled.ts b/packages/webui/src/client/lib/polyfill/promise.allSettled.ts similarity index 100% rename from meteor/client/lib/polyfill/promise.allSettled.ts rename to packages/webui/src/client/lib/polyfill/promise.allSettled.ts diff --git a/meteor/client/lib/polyfill/requestIdleCallback.ts b/packages/webui/src/client/lib/polyfill/requestIdleCallback.ts similarity index 100% rename from meteor/client/lib/polyfill/requestIdleCallback.ts rename to packages/webui/src/client/lib/polyfill/requestIdleCallback.ts diff --git a/meteor/client/lib/polyfill/vibrate.ts b/packages/webui/src/client/lib/polyfill/vibrate.ts similarity index 100% rename from meteor/client/lib/polyfill/vibrate.ts rename to packages/webui/src/client/lib/polyfill/vibrate.ts diff --git a/meteor/client/lib/popperUtils.ts b/packages/webui/src/client/lib/popperUtils.ts similarity index 100% rename from meteor/client/lib/popperUtils.ts rename to packages/webui/src/client/lib/popperUtils.ts diff --git a/meteor/client/lib/reactiveData/__tests__/reactiveDataHelper.test.ts b/packages/webui/src/client/lib/reactiveData/__tests__/reactiveDataHelper.test.ts similarity index 69% rename from meteor/client/lib/reactiveData/__tests__/reactiveDataHelper.test.ts rename to packages/webui/src/client/lib/reactiveData/__tests__/reactiveDataHelper.test.ts index 308dd68cc9..31be0e0966 100644 --- a/meteor/client/lib/reactiveData/__tests__/reactiveDataHelper.test.ts +++ b/packages/webui/src/client/lib/reactiveData/__tests__/reactiveDataHelper.test.ts @@ -1,4 +1,3 @@ -import { testInFiber } from '../../../../__mocks__/helpers/jest' import { Tracker } from 'meteor/tracker' import { slowDownReactivity } from '../reactiveDataHelper' import { sleep } from '../../../../lib/lib' @@ -9,7 +8,7 @@ describe('client/lib/reactiveData/reactiveDataHelper', () => { MeteorMock.mockSetClientEnvironment() }) describe('slowDownReactivity', () => { - testInFiber('it invalidates the parent computation immediately, when delay === 0', () => { + test('it invalidates the parent computation immediately, when delay === 0', () => { let runCount = 0 const dep = new Tracker.Dependency() let result = '' @@ -27,7 +26,7 @@ describe('client/lib/reactiveData/reactiveDataHelper', () => { expect(runCount).toBe(2) expect(result).toBe('test2') }) - testInFiber("it invalidates the computation after a delay, if it's dependency changes", async () => { + test("it invalidates the computation after a delay, if it's dependency changes", async () => { let runCount = 0 const dep = new Tracker.Dependency() Tracker.autorun(() => { @@ -47,36 +46,33 @@ describe('client/lib/reactiveData/reactiveDataHelper', () => { await sleep(200) expect(runCount).toBe(2) }) - testInFiber( - 'it invalidates once after a delay, even when there are additional invalidations in the delay period', - async () => { - let runCount = 0 - const dep = new Tracker.Dependency() - Tracker.autorun(() => { - runCount++ - slowDownReactivity(() => { - dep.depend() - }, 200) - }) + test('it invalidates once after a delay, even when there are additional invalidations in the delay period', async () => { + let runCount = 0 + const dep = new Tracker.Dependency() + Tracker.autorun(() => { + runCount++ + slowDownReactivity(() => { + dep.depend() + }, 200) + }) - expect(runCount).toBe(1) - dep.changed() - expect(runCount).toBe(1) - await sleep(100) - expect(runCount).toBe(1) - dep.changed() + expect(runCount).toBe(1) + dep.changed() + expect(runCount).toBe(1) + await sleep(100) + expect(runCount).toBe(1) + dep.changed() - await sleep(50) - dep.changed() + await sleep(50) + dep.changed() - await sleep(100) - expect(runCount).toBe(2) + await sleep(100) + expect(runCount).toBe(2) - await sleep(400) - expect(runCount).toBe(2) - } - ) - testInFiber('it cleans up after itself when parent computation is invalidated', async () => { + await sleep(400) + expect(runCount).toBe(2) + }) + test('it cleans up after itself when parent computation is invalidated', async () => { let runCount0 = 0 let runCount1 = 0 const dep0 = new Tracker.Dependency() diff --git a/meteor/client/lib/reactiveData/reactiveData.ts b/packages/webui/src/client/lib/reactiveData/reactiveData.ts similarity index 100% rename from meteor/client/lib/reactiveData/reactiveData.ts rename to packages/webui/src/client/lib/reactiveData/reactiveData.ts diff --git a/meteor/client/lib/reactiveData/reactiveDataHelper.ts b/packages/webui/src/client/lib/reactiveData/reactiveDataHelper.ts similarity index 100% rename from meteor/client/lib/reactiveData/reactiveDataHelper.ts rename to packages/webui/src/client/lib/reactiveData/reactiveDataHelper.ts diff --git a/meteor/client/lib/resizeObserver.ts b/packages/webui/src/client/lib/resizeObserver.ts similarity index 100% rename from meteor/client/lib/resizeObserver.ts rename to packages/webui/src/client/lib/resizeObserver.ts diff --git a/meteor/client/lib/rundown.ts b/packages/webui/src/client/lib/rundown.ts similarity index 100% rename from meteor/client/lib/rundown.ts rename to packages/webui/src/client/lib/rundown.ts diff --git a/meteor/client/lib/rundownLayouts.ts b/packages/webui/src/client/lib/rundownLayouts.ts similarity index 100% rename from meteor/client/lib/rundownLayouts.ts rename to packages/webui/src/client/lib/rundownLayouts.ts diff --git a/meteor/client/lib/rundownTiming.ts b/packages/webui/src/client/lib/rundownTiming.ts similarity index 100% rename from meteor/client/lib/rundownTiming.ts rename to packages/webui/src/client/lib/rundownTiming.ts diff --git a/meteor/client/lib/shelf.ts b/packages/webui/src/client/lib/shelf.ts similarity index 100% rename from meteor/client/lib/shelf.ts rename to packages/webui/src/client/lib/shelf.ts diff --git a/meteor/client/lib/speechSynthesis.ts b/packages/webui/src/client/lib/speechSynthesis.ts similarity index 100% rename from meteor/client/lib/speechSynthesis.ts rename to packages/webui/src/client/lib/speechSynthesis.ts diff --git a/meteor/client/lib/triggers/ActionAdLibHotkeyPreview.tsx b/packages/webui/src/client/lib/triggers/ActionAdLibHotkeyPreview.tsx similarity index 100% rename from meteor/client/lib/triggers/ActionAdLibHotkeyPreview.tsx rename to packages/webui/src/client/lib/triggers/ActionAdLibHotkeyPreview.tsx diff --git a/meteor/client/lib/triggers/README.md b/packages/webui/src/client/lib/triggers/README.md similarity index 100% rename from meteor/client/lib/triggers/README.md rename to packages/webui/src/client/lib/triggers/README.md diff --git a/meteor/client/lib/triggers/TriggersHandler.tsx b/packages/webui/src/client/lib/triggers/TriggersHandler.tsx similarity index 100% rename from meteor/client/lib/triggers/TriggersHandler.tsx rename to packages/webui/src/client/lib/triggers/TriggersHandler.tsx diff --git a/meteor/client/lib/triggers/codesToKeyLabels.ts b/packages/webui/src/client/lib/triggers/codesToKeyLabels.ts similarity index 100% rename from meteor/client/lib/triggers/codesToKeyLabels.ts rename to packages/webui/src/client/lib/triggers/codesToKeyLabels.ts diff --git a/meteor/client/lib/ui/PieceStatusIcon.tsx b/packages/webui/src/client/lib/ui/PieceStatusIcon.tsx similarity index 94% rename from meteor/client/lib/ui/PieceStatusIcon.tsx rename to packages/webui/src/client/lib/ui/PieceStatusIcon.tsx index 80b659cb54..9e3436c421 100644 --- a/meteor/client/lib/ui/PieceStatusIcon.tsx +++ b/packages/webui/src/client/lib/ui/PieceStatusIcon.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { NoticeLevel } from '../../../lib/notifications/notifications' import { CriticalIconSmall, WarningIconSmall } from './icons/notifications' diff --git a/meteor/client/lib/ui/containers/modals/Modal.tsx b/packages/webui/src/client/lib/ui/containers/modals/Modal.tsx similarity index 98% rename from meteor/client/lib/ui/containers/modals/Modal.tsx rename to packages/webui/src/client/lib/ui/containers/modals/Modal.tsx index 13454a5ab2..c655fc7f9b 100644 --- a/meteor/client/lib/ui/containers/modals/Modal.tsx +++ b/packages/webui/src/client/lib/ui/containers/modals/Modal.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import type { Sorensen } from '@sofie-automation/sorensen' -import CoreIcons from '@nrk/core-icons/jsx' +import * as CoreIcons from '@nrk/core-icons/jsx' import Escape from './../../../Escape' import { SorensenContext } from '../../../SorensenContext' diff --git a/meteor/client/lib/ui/icons/freezeFrame.tsx b/packages/webui/src/client/lib/ui/icons/freezeFrame.tsx similarity index 100% rename from meteor/client/lib/ui/icons/freezeFrame.tsx rename to packages/webui/src/client/lib/ui/icons/freezeFrame.tsx diff --git a/meteor/client/lib/ui/icons/icon-loop.json b/packages/webui/src/client/lib/ui/icons/icon-loop.json similarity index 100% rename from meteor/client/lib/ui/icons/icon-loop.json rename to packages/webui/src/client/lib/ui/icons/icon-loop.json diff --git a/meteor/client/lib/ui/icons/listView.tsx b/packages/webui/src/client/lib/ui/icons/listView.tsx similarity index 100% rename from meteor/client/lib/ui/icons/listView.tsx rename to packages/webui/src/client/lib/ui/icons/listView.tsx diff --git a/meteor/client/lib/ui/icons/looping.tsx b/packages/webui/src/client/lib/ui/icons/looping.tsx similarity index 96% rename from meteor/client/lib/ui/icons/looping.tsx rename to packages/webui/src/client/lib/ui/icons/looping.tsx index 24362662b3..96d7b46c06 100644 --- a/meteor/client/lib/ui/icons/looping.tsx +++ b/packages/webui/src/client/lib/ui/icons/looping.tsx @@ -1,5 +1,4 @@ import React, { JSX } from 'react' -// @ts-expect-error Not recognized by Typescript import * as loopAnimation from './icon-loop.json' import { Lottie } from '@crello/react-lottie' diff --git a/meteor/client/lib/ui/icons/mediaStatus.tsx b/packages/webui/src/client/lib/ui/icons/mediaStatus.tsx similarity index 98% rename from meteor/client/lib/ui/icons/mediaStatus.tsx rename to packages/webui/src/client/lib/ui/icons/mediaStatus.tsx index 46c28f3787..0d7dd38384 100644 --- a/meteor/client/lib/ui/icons/mediaStatus.tsx +++ b/packages/webui/src/client/lib/ui/icons/mediaStatus.tsx @@ -1,4 +1,4 @@ -import React, { JSX } from 'react' +import { JSX } from 'react' export function MediaStatusIcon(): JSX.Element { return ( diff --git a/meteor/client/lib/ui/icons/notifications.tsx b/packages/webui/src/client/lib/ui/icons/notifications.tsx similarity index 99% rename from meteor/client/lib/ui/icons/notifications.tsx rename to packages/webui/src/client/lib/ui/icons/notifications.tsx index e955973775..e4e6dd26ed 100644 --- a/meteor/client/lib/ui/icons/notifications.tsx +++ b/packages/webui/src/client/lib/ui/icons/notifications.tsx @@ -1,4 +1,4 @@ -import React, { JSX } from 'react' +import { JSX } from 'react' export function CriticalIcon(): JSX.Element { return ( diff --git a/meteor/client/lib/ui/icons/rewindAllSegmentsIcon.tsx b/packages/webui/src/client/lib/ui/icons/rewindAllSegmentsIcon.tsx similarity index 92% rename from meteor/client/lib/ui/icons/rewindAllSegmentsIcon.tsx rename to packages/webui/src/client/lib/ui/icons/rewindAllSegmentsIcon.tsx index 40d731692b..dda2fc4cac 100644 --- a/meteor/client/lib/ui/icons/rewindAllSegmentsIcon.tsx +++ b/packages/webui/src/client/lib/ui/icons/rewindAllSegmentsIcon.tsx @@ -1,5 +1,3 @@ -import * as React from 'react' - export const RewindAllSegmentsIcon = (): JSX.Element => ( diff --git a/meteor/client/lib/ui/icons/segment.tsx b/packages/webui/src/client/lib/ui/icons/segment.tsx similarity index 100% rename from meteor/client/lib/ui/icons/segment.tsx rename to packages/webui/src/client/lib/ui/icons/segment.tsx diff --git a/meteor/client/lib/ui/icons/segmentZoomIcon.tsx b/packages/webui/src/client/lib/ui/icons/segmentZoomIcon.tsx similarity index 97% rename from meteor/client/lib/ui/icons/segmentZoomIcon.tsx rename to packages/webui/src/client/lib/ui/icons/segmentZoomIcon.tsx index 21912405a7..8ee0562389 100644 --- a/meteor/client/lib/ui/icons/segmentZoomIcon.tsx +++ b/packages/webui/src/client/lib/ui/icons/segmentZoomIcon.tsx @@ -1,5 +1,3 @@ -import * as React from 'react' - export const ZoomOutIcon = (): JSX.Element => ( diff --git a/meteor/client/lib/ui/icons/sorting.tsx b/packages/webui/src/client/lib/ui/icons/sorting.tsx similarity index 99% rename from meteor/client/lib/ui/icons/sorting.tsx rename to packages/webui/src/client/lib/ui/icons/sorting.tsx index a28ae3dc69..a8bd8b274c 100644 --- a/meteor/client/lib/ui/icons/sorting.tsx +++ b/packages/webui/src/client/lib/ui/icons/sorting.tsx @@ -1,4 +1,4 @@ -import React, { JSX } from 'react' +import { JSX } from 'react' export function SortDescending(): JSX.Element { return ( diff --git a/meteor/client/lib/ui/icons/supportIcon.tsx b/packages/webui/src/client/lib/ui/icons/supportIcon.tsx similarity index 98% rename from meteor/client/lib/ui/icons/supportIcon.tsx rename to packages/webui/src/client/lib/ui/icons/supportIcon.tsx index 599d01c121..844246696a 100644 --- a/meteor/client/lib/ui/icons/supportIcon.tsx +++ b/packages/webui/src/client/lib/ui/icons/supportIcon.tsx @@ -1,5 +1,3 @@ -import * as React from 'react' - export const SupportIcon = (): JSX.Element => ( svg.svg-inline--fa + span { + margin-left: 0.5em; +} + +.switch-button { + width: 42px !important; + height: 21px !important; + + &.sb-nocolor.sb-on { + .sb-content { + .sb-label { + background-color: #737373; + } + } + } + + .sb-content { + width: 42px; + height: 21px; + > .sb-label { + width: 42px; + height: 21px; + } + .sb-switch { + width: 19px; + height: 19px; + } + } + + &:active .sb-label { + opacity: 0.8; + } + + &.sb-on { + .sb-switch { + transform: matrix(1, 0, 0, 1, 21, 0); + } + } +} diff --git a/meteor/client/styles/modalDialog.scss b/packages/webui/src/client/styles/modalDialog.scss similarity index 100% rename from meteor/client/styles/modalDialog.scss rename to packages/webui/src/client/styles/modalDialog.scss diff --git a/meteor/client/styles/multiSelect.scss b/packages/webui/src/client/styles/multiSelect.scss similarity index 100% rename from meteor/client/styles/multiSelect.scss rename to packages/webui/src/client/styles/multiSelect.scss diff --git a/meteor/client/styles/notifications.scss b/packages/webui/src/client/styles/notifications.scss similarity index 100% rename from meteor/client/styles/notifications.scss rename to packages/webui/src/client/styles/notifications.scss diff --git a/meteor/client/styles/organization.scss b/packages/webui/src/client/styles/organization.scss similarity index 100% rename from meteor/client/styles/organization.scss rename to packages/webui/src/client/styles/organization.scss diff --git a/meteor/client/styles/overflowingContainer.scss b/packages/webui/src/client/styles/overflowingContainer.scss similarity index 100% rename from meteor/client/styles/overflowingContainer.scss rename to packages/webui/src/client/styles/overflowingContainer.scss diff --git a/meteor/client/styles/pieceStatusIcon.scss b/packages/webui/src/client/styles/pieceStatusIcon.scss similarity index 100% rename from meteor/client/styles/pieceStatusIcon.scss rename to packages/webui/src/client/styles/pieceStatusIcon.scss diff --git a/meteor/client/styles/prompter.scss b/packages/webui/src/client/styles/prompter.scss similarity index 92% rename from meteor/client/styles/prompter.scss rename to packages/webui/src/client/styles/prompter.scss index 2a6c061ce1..805741d38d 100644 --- a/meteor/client/styles/prompter.scss +++ b/packages/webui/src/client/styles/prompter.scss @@ -2,13 +2,13 @@ @font-face { font-family: 'Source Serif Pro'; - src: url('/fonts/Source_Serif_Pro/SourceSerifPro-Regular.woff') format('woff'), - /* Pretty Modern Browsers */ url('/fonts/Source_Serif_Pro/SourceSerifPro-Regular.ttf') format('truetype'); /* Safari, Android, iOS */ + src: url('../../fonts/Source_Serif_Pro/SourceSerifPro-Regular.woff') format('woff'), + /* Pretty Modern Browsers */ url('../../fonts/Source_Serif_Pro/SourceSerifPro-Regular.ttf') format('truetype'); /* Safari, Android, iOS */ } @font-face { font-family: 'Overpass'; - src: url('/fonts/Overpass/Overpass-Regular.woff') format('woff'), - /* Pretty Modern Browsers */ url('/fonts/Overpass/Overpass-Regular.ttf') format('truetype'); /* Safari, Android, iOS */ + src: url('../../fonts/Overpass/Overpass-Regular.woff') format('woff'), + /* Pretty Modern Browsers */ url('../../fonts/Overpass/Overpass-Regular.ttf') format('truetype'); /* Safari, Android, iOS */ } body.prompter-scrollbar { diff --git a/meteor/client/styles/rundownList.scss b/packages/webui/src/client/styles/rundownList.scss similarity index 100% rename from meteor/client/styles/rundownList.scss rename to packages/webui/src/client/styles/rundownList.scss diff --git a/meteor/client/styles/rundownSystemStatus.scss b/packages/webui/src/client/styles/rundownSystemStatus.scss similarity index 100% rename from meteor/client/styles/rundownSystemStatus.scss rename to packages/webui/src/client/styles/rundownSystemStatus.scss diff --git a/meteor/client/styles/rundownView.scss b/packages/webui/src/client/styles/rundownView.scss similarity index 100% rename from meteor/client/styles/rundownView.scss rename to packages/webui/src/client/styles/rundownView.scss diff --git a/meteor/client/styles/settings.scss b/packages/webui/src/client/styles/settings.scss similarity index 100% rename from meteor/client/styles/settings.scss rename to packages/webui/src/client/styles/settings.scss diff --git a/meteor/client/styles/shelf/adLibPanel.scss b/packages/webui/src/client/styles/shelf/adLibPanel.scss similarity index 100% rename from meteor/client/styles/shelf/adLibPanel.scss rename to packages/webui/src/client/styles/shelf/adLibPanel.scss diff --git a/meteor/client/styles/shelf/adLibRegionPanel.scss b/packages/webui/src/client/styles/shelf/adLibRegionPanel.scss similarity index 100% rename from meteor/client/styles/shelf/adLibRegionPanel.scss rename to packages/webui/src/client/styles/shelf/adLibRegionPanel.scss diff --git a/meteor/client/styles/shelf/colored-box-panel.scss b/packages/webui/src/client/styles/shelf/colored-box-panel.scss similarity index 100% rename from meteor/client/styles/shelf/colored-box-panel.scss rename to packages/webui/src/client/styles/shelf/colored-box-panel.scss diff --git a/meteor/client/styles/shelf/dashboard-rundownView.scss b/packages/webui/src/client/styles/shelf/dashboard-rundownView.scss similarity index 100% rename from meteor/client/styles/shelf/dashboard-rundownView.scss rename to packages/webui/src/client/styles/shelf/dashboard-rundownView.scss diff --git a/meteor/client/styles/shelf/dashboard-streamdeck.scss b/packages/webui/src/client/styles/shelf/dashboard-streamdeck.scss similarity index 100% rename from meteor/client/styles/shelf/dashboard-streamdeck.scss rename to packages/webui/src/client/styles/shelf/dashboard-streamdeck.scss diff --git a/meteor/client/styles/shelf/dashboard.scss b/packages/webui/src/client/styles/shelf/dashboard.scss similarity index 100% rename from meteor/client/styles/shelf/dashboard.scss rename to packages/webui/src/client/styles/shelf/dashboard.scss diff --git a/meteor/client/styles/shelf/endTimerPanel.scss b/packages/webui/src/client/styles/shelf/endTimerPanel.scss similarity index 100% rename from meteor/client/styles/shelf/endTimerPanel.scss rename to packages/webui/src/client/styles/shelf/endTimerPanel.scss diff --git a/meteor/client/styles/shelf/endWordsPanel.scss b/packages/webui/src/client/styles/shelf/endWordsPanel.scss similarity index 100% rename from meteor/client/styles/shelf/endWordsPanel.scss rename to packages/webui/src/client/styles/shelf/endWordsPanel.scss diff --git a/meteor/client/styles/shelf/externalFramePanel.scss b/packages/webui/src/client/styles/shelf/externalFramePanel.scss similarity index 100% rename from meteor/client/styles/shelf/externalFramePanel.scss rename to packages/webui/src/client/styles/shelf/externalFramePanel.scss diff --git a/meteor/client/styles/shelf/inspector.scss b/packages/webui/src/client/styles/shelf/inspector.scss similarity index 100% rename from meteor/client/styles/shelf/inspector.scss rename to packages/webui/src/client/styles/shelf/inspector.scss diff --git a/meteor/client/styles/shelf/miniRundownPanel.scss b/packages/webui/src/client/styles/shelf/miniRundownPanel.scss similarity index 100% rename from meteor/client/styles/shelf/miniRundownPanel.scss rename to packages/webui/src/client/styles/shelf/miniRundownPanel.scss diff --git a/meteor/client/styles/shelf/nextInfoPanel.scss b/packages/webui/src/client/styles/shelf/nextInfoPanel.scss similarity index 100% rename from meteor/client/styles/shelf/nextInfoPanel.scss rename to packages/webui/src/client/styles/shelf/nextInfoPanel.scss diff --git a/meteor/client/styles/shelf/partCountdownPanel.scss b/packages/webui/src/client/styles/shelf/partCountdownPanel.scss similarity index 100% rename from meteor/client/styles/shelf/partCountdownPanel.scss rename to packages/webui/src/client/styles/shelf/partCountdownPanel.scss diff --git a/meteor/client/styles/shelf/partNamePanel.scss b/packages/webui/src/client/styles/shelf/partNamePanel.scss similarity index 100% rename from meteor/client/styles/shelf/partNamePanel.scss rename to packages/webui/src/client/styles/shelf/partNamePanel.scss diff --git a/meteor/client/styles/shelf/partTimingPanel.scss b/packages/webui/src/client/styles/shelf/partTimingPanel.scss similarity index 100% rename from meteor/client/styles/shelf/partTimingPanel.scss rename to packages/webui/src/client/styles/shelf/partTimingPanel.scss diff --git a/meteor/client/styles/shelf/pieceCountdownPanel.scss b/packages/webui/src/client/styles/shelf/pieceCountdownPanel.scss similarity index 100% rename from meteor/client/styles/shelf/pieceCountdownPanel.scss rename to packages/webui/src/client/styles/shelf/pieceCountdownPanel.scss diff --git a/meteor/client/styles/shelf/playlistNamePanel.scss b/packages/webui/src/client/styles/shelf/playlistNamePanel.scss similarity index 100% rename from meteor/client/styles/shelf/playlistNamePanel.scss rename to packages/webui/src/client/styles/shelf/playlistNamePanel.scss diff --git a/meteor/client/styles/shelf/segmentNamePanel.scss b/packages/webui/src/client/styles/shelf/segmentNamePanel.scss similarity index 100% rename from meteor/client/styles/shelf/segmentNamePanel.scss rename to packages/webui/src/client/styles/shelf/segmentNamePanel.scss diff --git a/meteor/client/styles/shelf/segmentTimingPanel.scss b/packages/webui/src/client/styles/shelf/segmentTimingPanel.scss similarity index 100% rename from meteor/client/styles/shelf/segmentTimingPanel.scss rename to packages/webui/src/client/styles/shelf/segmentTimingPanel.scss diff --git a/meteor/client/styles/shelf/shelf.scss b/packages/webui/src/client/styles/shelf/shelf.scss similarity index 100% rename from meteor/client/styles/shelf/shelf.scss rename to packages/webui/src/client/styles/shelf/shelf.scss diff --git a/meteor/client/styles/shelf/showStylePanel.scss b/packages/webui/src/client/styles/shelf/showStylePanel.scss similarity index 100% rename from meteor/client/styles/shelf/showStylePanel.scss rename to packages/webui/src/client/styles/shelf/showStylePanel.scss diff --git a/meteor/client/styles/shelf/startTimerPanel.scss b/packages/webui/src/client/styles/shelf/startTimerPanel.scss similarity index 100% rename from meteor/client/styles/shelf/startTimerPanel.scss rename to packages/webui/src/client/styles/shelf/startTimerPanel.scss diff --git a/meteor/client/styles/shelf/studioNamePanel.scss b/packages/webui/src/client/styles/shelf/studioNamePanel.scss similarity index 100% rename from meteor/client/styles/shelf/studioNamePanel.scss rename to packages/webui/src/client/styles/shelf/studioNamePanel.scss diff --git a/meteor/client/styles/shelf/systemStatusPanel.scss b/packages/webui/src/client/styles/shelf/systemStatusPanel.scss similarity index 100% rename from meteor/client/styles/shelf/systemStatusPanel.scss rename to packages/webui/src/client/styles/shelf/systemStatusPanel.scss diff --git a/meteor/client/styles/shelf/textLabelPanel.scss b/packages/webui/src/client/styles/shelf/textLabelPanel.scss similarity index 100% rename from meteor/client/styles/shelf/textLabelPanel.scss rename to packages/webui/src/client/styles/shelf/textLabelPanel.scss diff --git a/meteor/client/styles/shelf/timeOfDayPanel.scss b/packages/webui/src/client/styles/shelf/timeOfDayPanel.scss similarity index 100% rename from meteor/client/styles/shelf/timeOfDayPanel.scss rename to packages/webui/src/client/styles/shelf/timeOfDayPanel.scss diff --git a/meteor/client/styles/splitDropdown.scss b/packages/webui/src/client/styles/splitDropdown.scss similarity index 100% rename from meteor/client/styles/splitDropdown.scss rename to packages/webui/src/client/styles/splitDropdown.scss diff --git a/meteor/client/styles/statusNotification.scss b/packages/webui/src/client/styles/statusNotification.scss similarity index 100% rename from meteor/client/styles/statusNotification.scss rename to packages/webui/src/client/styles/statusNotification.scss diff --git a/meteor/client/styles/statusbar.scss b/packages/webui/src/client/styles/statusbar.scss similarity index 100% rename from meteor/client/styles/statusbar.scss rename to packages/webui/src/client/styles/statusbar.scss diff --git a/meteor/client/styles/studioScreenSaver.scss b/packages/webui/src/client/styles/studioScreenSaver.scss similarity index 100% rename from meteor/client/styles/studioScreenSaver.scss rename to packages/webui/src/client/styles/studioScreenSaver.scss diff --git a/meteor/client/styles/supportAndSwitchboardPanel.scss b/packages/webui/src/client/styles/supportAndSwitchboardPanel.scss similarity index 100% rename from meteor/client/styles/supportAndSwitchboardPanel.scss rename to packages/webui/src/client/styles/supportAndSwitchboardPanel.scss diff --git a/meteor/client/styles/systemStatus.scss b/packages/webui/src/client/styles/systemStatus.scss similarity index 100% rename from meteor/client/styles/systemStatus.scss rename to packages/webui/src/client/styles/systemStatus.scss diff --git a/meteor/client/styles/testtools.scss b/packages/webui/src/client/styles/testtools.scss similarity index 100% rename from meteor/client/styles/testtools.scss rename to packages/webui/src/client/styles/testtools.scss diff --git a/meteor/client/styles/tooltips.scss b/packages/webui/src/client/styles/tooltips.scss similarity index 100% rename from meteor/client/styles/tooltips.scss rename to packages/webui/src/client/styles/tooltips.scss diff --git a/meteor/client/styles/users.scss b/packages/webui/src/client/styles/users.scss similarity index 100% rename from meteor/client/styles/users.scss rename to packages/webui/src/client/styles/users.scss diff --git a/meteor/client/styles/utils.scss b/packages/webui/src/client/styles/utils.scss similarity index 100% rename from meteor/client/styles/utils.scss rename to packages/webui/src/client/styles/utils.scss diff --git a/meteor/client/ui/Account/AccountPage.tsx b/packages/webui/src/client/ui/Account/AccountPage.tsx similarity index 99% rename from meteor/client/ui/Account/AccountPage.tsx rename to packages/webui/src/client/ui/Account/AccountPage.tsx index c08bedefbe..40b4e139bc 100644 --- a/meteor/client/ui/Account/AccountPage.tsx +++ b/packages/webui/src/client/ui/Account/AccountPage.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { Accounts } from 'meteor/accounts-base' +import { Accounts } from './fake-accounts' import { Translated, translateWithTracker } from '../../lib/ReactMeteorData/react-meteor-data' import type { RouteComponentProps } from 'react-router' import { NotificationCenter, Notification, NoticeLevel } from '../../../lib/notifications/notifications' diff --git a/meteor/client/ui/Account/NotLoggedIn/LoginPage.tsx b/packages/webui/src/client/ui/Account/NotLoggedIn/LoginPage.tsx similarity index 98% rename from meteor/client/ui/Account/NotLoggedIn/LoginPage.tsx rename to packages/webui/src/client/ui/Account/NotLoggedIn/LoginPage.tsx index 219f22465c..a66e221273 100644 --- a/meteor/client/ui/Account/NotLoggedIn/LoginPage.tsx +++ b/packages/webui/src/client/ui/Account/NotLoggedIn/LoginPage.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { Meteor } from 'meteor/meteor' -import { Accounts } from 'meteor/accounts-base' +import { Accounts } from '../fake-accounts' import { Translated, translateWithTracker } from '../../../lib/ReactMeteorData/react-meteor-data' import { Link } from 'react-router-dom' import type { RouteComponentProps } from 'react-router' diff --git a/meteor/client/ui/Account/NotLoggedIn/LostPassword.tsx b/packages/webui/src/client/ui/Account/NotLoggedIn/LostPassword.tsx similarity index 100% rename from meteor/client/ui/Account/NotLoggedIn/LostPassword.tsx rename to packages/webui/src/client/ui/Account/NotLoggedIn/LostPassword.tsx diff --git a/meteor/client/ui/Account/NotLoggedIn/ResetPasswordPage.tsx b/packages/webui/src/client/ui/Account/NotLoggedIn/ResetPasswordPage.tsx similarity index 98% rename from meteor/client/ui/Account/NotLoggedIn/ResetPasswordPage.tsx rename to packages/webui/src/client/ui/Account/NotLoggedIn/ResetPasswordPage.tsx index 47995bf21a..cf1bab9190 100644 --- a/meteor/client/ui/Account/NotLoggedIn/ResetPasswordPage.tsx +++ b/packages/webui/src/client/ui/Account/NotLoggedIn/ResetPasswordPage.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { Accounts } from 'meteor/accounts-base' +import { Accounts } from '../fake-accounts' import { Translated, translateWithTracker } from '../../../lib/ReactMeteorData/react-meteor-data' import type { RouteComponentProps } from 'react-router' import { getUser } from '../../../../lib/collections/Users' diff --git a/meteor/client/ui/Account/NotLoggedIn/SignupPage.tsx b/packages/webui/src/client/ui/Account/NotLoggedIn/SignupPage.tsx similarity index 100% rename from meteor/client/ui/Account/NotLoggedIn/SignupPage.tsx rename to packages/webui/src/client/ui/Account/NotLoggedIn/SignupPage.tsx diff --git a/meteor/client/ui/Account/NotLoggedIn/lib.tsx b/packages/webui/src/client/ui/Account/NotLoggedIn/lib.tsx similarity index 93% rename from meteor/client/ui/Account/NotLoggedIn/lib.tsx rename to packages/webui/src/client/ui/Account/NotLoggedIn/lib.tsx index 7a96a9dd89..e60fdcca9a 100644 --- a/meteor/client/ui/Account/NotLoggedIn/lib.tsx +++ b/packages/webui/src/client/ui/Account/NotLoggedIn/lib.tsx @@ -1,5 +1,3 @@ -import * as React from 'react' - interface IProps { children: JSX.Element[] } diff --git a/meteor/client/ui/Account/OrganizationPage.tsx b/packages/webui/src/client/ui/Account/OrganizationPage.tsx similarity index 100% rename from meteor/client/ui/Account/OrganizationPage.tsx rename to packages/webui/src/client/ui/Account/OrganizationPage.tsx diff --git a/packages/webui/src/client/ui/Account/fake-accounts.ts b/packages/webui/src/client/ui/Account/fake-accounts.ts new file mode 100644 index 0000000000..c9f9fa8aed --- /dev/null +++ b/packages/webui/src/client/ui/Account/fake-accounts.ts @@ -0,0 +1,57 @@ +import { Meteor } from 'meteor/meteor' + +export class Accounts { + public static changePassword( + _oldPassword: string, + _password: string, + _callback: (error: Error | null) => void + ): void { + // nocommit not implemented + throw new Error('Not implemented') + } + + public static createUser( + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + _options: any, + _callback?: (error?: Error | Meteor.Error | Meteor.TypedError) => void + ): string { + // nocommit not implemented + throw new Error('Not implemented') + } + + public static resetPassword( + _token: string, + _newPassword: string, + _callback?: (error?: Error | Meteor.Error | Meteor.TypedError) => void + ): void { + // nocommit not implemented + throw new Error('Not implemented') + } + + public static verifyEmail( + _token: string, + _callback?: (error?: Error | Meteor.Error | Meteor.TypedError) => void + ): void { + // nocommit not implemented + throw new Error('Not implemented') + } + + public static loginWithPassword( + _user: { username: string } | { email: string } | { id: string } | string, + _password: string, + _callback?: (error?: Error | Meteor.Error | Meteor.TypedError) => void + ): void { + // nocommit not implemented + throw new Error('Not implemented') + } + + public static createUserAsync(_options: { + username?: string | undefined + email?: string | undefined + password?: string | undefined + profile?: Meteor.UserProfile | undefined + }): Promise { + // nocommit not implemented + throw new Error('Not implemented') + } +} diff --git a/meteor/client/ui/ActiveRundownView.tsx b/packages/webui/src/client/ui/ActiveRundownView.tsx similarity index 98% rename from meteor/client/ui/ActiveRundownView.tsx rename to packages/webui/src/client/ui/ActiveRundownView.tsx index 04b1ed05ee..0e2c9ce85e 100644 --- a/meteor/client/ui/ActiveRundownView.tsx +++ b/packages/webui/src/client/ui/ActiveRundownView.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { NavLink, Route, Switch, useRouteMatch } from 'react-router-dom' import { useSubscription, useTracker } from '../lib/ReactMeteorData/ReactMeteorData' diff --git a/meteor/client/ui/AfterBroadcastForm.tsx b/packages/webui/src/client/ui/AfterBroadcastForm.tsx similarity index 100% rename from meteor/client/ui/AfterBroadcastForm.tsx rename to packages/webui/src/client/ui/AfterBroadcastForm.tsx diff --git a/meteor/client/ui/App.tsx b/packages/webui/src/client/ui/App.tsx similarity index 100% rename from meteor/client/ui/App.tsx rename to packages/webui/src/client/ui/App.tsx diff --git a/meteor/client/ui/ClipTrimPanel/ClipTrimDialog.tsx b/packages/webui/src/client/ui/ClipTrimPanel/ClipTrimDialog.tsx similarity index 98% rename from meteor/client/ui/ClipTrimPanel/ClipTrimDialog.tsx rename to packages/webui/src/client/ui/ClipTrimPanel/ClipTrimDialog.tsx index 45ba2b7f75..88d2753146 100644 --- a/meteor/client/ui/ClipTrimPanel/ClipTrimDialog.tsx +++ b/packages/webui/src/client/ui/ClipTrimPanel/ClipTrimDialog.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { ClipTrimPanel } from './ClipTrimPanel' import { VTContent } from '@sofie-automation/blueprints-integration' diff --git a/meteor/client/ui/ClipTrimPanel/ClipTrimPanel.scss b/packages/webui/src/client/ui/ClipTrimPanel/ClipTrimPanel.scss similarity index 100% rename from meteor/client/ui/ClipTrimPanel/ClipTrimPanel.scss rename to packages/webui/src/client/ui/ClipTrimPanel/ClipTrimPanel.scss diff --git a/meteor/client/ui/ClipTrimPanel/ClipTrimPanel.tsx b/packages/webui/src/client/ui/ClipTrimPanel/ClipTrimPanel.tsx similarity index 99% rename from meteor/client/ui/ClipTrimPanel/ClipTrimPanel.tsx rename to packages/webui/src/client/ui/ClipTrimPanel/ClipTrimPanel.tsx index 3b3d080e27..341f57c063 100644 --- a/meteor/client/ui/ClipTrimPanel/ClipTrimPanel.tsx +++ b/packages/webui/src/client/ui/ClipTrimPanel/ClipTrimPanel.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { VTContent } from '@sofie-automation/blueprints-integration' import { VideoEditMonitor } from './VideoEditMonitor' import { TimecodeEncoder } from './TimecodeEncoder' diff --git a/meteor/client/ui/ClipTrimPanel/TimecodeEncoder.scss b/packages/webui/src/client/ui/ClipTrimPanel/TimecodeEncoder.scss similarity index 100% rename from meteor/client/ui/ClipTrimPanel/TimecodeEncoder.scss rename to packages/webui/src/client/ui/ClipTrimPanel/TimecodeEncoder.scss diff --git a/meteor/client/ui/ClipTrimPanel/TimecodeEncoder.tsx b/packages/webui/src/client/ui/ClipTrimPanel/TimecodeEncoder.tsx similarity index 100% rename from meteor/client/ui/ClipTrimPanel/TimecodeEncoder.tsx rename to packages/webui/src/client/ui/ClipTrimPanel/TimecodeEncoder.tsx diff --git a/meteor/client/ui/ClipTrimPanel/VideoEditMonitor.scss b/packages/webui/src/client/ui/ClipTrimPanel/VideoEditMonitor.scss similarity index 100% rename from meteor/client/ui/ClipTrimPanel/VideoEditMonitor.scss rename to packages/webui/src/client/ui/ClipTrimPanel/VideoEditMonitor.scss diff --git a/meteor/client/ui/ClipTrimPanel/VideoEditMonitor.tsx b/packages/webui/src/client/ui/ClipTrimPanel/VideoEditMonitor.tsx similarity index 100% rename from meteor/client/ui/ClipTrimPanel/VideoEditMonitor.tsx rename to packages/webui/src/client/ui/ClipTrimPanel/VideoEditMonitor.tsx diff --git a/meteor/client/ui/ClockView/CameraScreen/CameraScreen.scss b/packages/webui/src/client/ui/ClockView/CameraScreen/CameraScreen.scss similarity index 100% rename from meteor/client/ui/ClockView/CameraScreen/CameraScreen.scss rename to packages/webui/src/client/ui/ClockView/CameraScreen/CameraScreen.scss diff --git a/meteor/client/ui/ClockView/CameraScreen/OrderedPartsProvider.tsx b/packages/webui/src/client/ui/ClockView/CameraScreen/OrderedPartsProvider.tsx similarity index 100% rename from meteor/client/ui/ClockView/CameraScreen/OrderedPartsProvider.tsx rename to packages/webui/src/client/ui/ClockView/CameraScreen/OrderedPartsProvider.tsx diff --git a/meteor/client/ui/ClockView/CameraScreen/Part.tsx b/packages/webui/src/client/ui/ClockView/CameraScreen/Part.tsx similarity index 98% rename from meteor/client/ui/ClockView/CameraScreen/Part.tsx rename to packages/webui/src/client/ui/ClockView/CameraScreen/Part.tsx index 66acb6c325..168bc661ed 100644 --- a/meteor/client/ui/ClockView/CameraScreen/Part.tsx +++ b/packages/webui/src/client/ui/ClockView/CameraScreen/Part.tsx @@ -1,6 +1,6 @@ import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import classNames from 'classnames' -import React, { useContext } from 'react' +import { useContext } from 'react' import { AreaZoom } from '.' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { PieceExtended } from '../../../lib/RundownResolver' diff --git a/meteor/client/ui/ClockView/CameraScreen/Piece.tsx b/packages/webui/src/client/ui/ClockView/CameraScreen/Piece.tsx similarity index 100% rename from meteor/client/ui/ClockView/CameraScreen/Piece.tsx rename to packages/webui/src/client/ui/ClockView/CameraScreen/Piece.tsx diff --git a/meteor/client/ui/ClockView/CameraScreen/Rundown.tsx b/packages/webui/src/client/ui/ClockView/CameraScreen/Rundown.tsx similarity index 98% rename from meteor/client/ui/ClockView/CameraScreen/Rundown.tsx rename to packages/webui/src/client/ui/ClockView/CameraScreen/Rundown.tsx index 2001cfd9b1..7ca58e87b7 100644 --- a/meteor/client/ui/ClockView/CameraScreen/Rundown.tsx +++ b/packages/webui/src/client/ui/ClockView/CameraScreen/Rundown.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react' +import { useContext } from 'react' import { useSubscription, useTracker } from '../../../lib/ReactMeteorData/ReactMeteorData' import { Rundown as RundownObj } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { MeteorPubSub } from '../../../../lib/api/pubsub' diff --git a/meteor/client/ui/ClockView/CameraScreen/Segment.tsx b/packages/webui/src/client/ui/ClockView/CameraScreen/Segment.tsx similarity index 98% rename from meteor/client/ui/ClockView/CameraScreen/Segment.tsx rename to packages/webui/src/client/ui/ClockView/CameraScreen/Segment.tsx index be7d7d087b..971126281f 100644 --- a/meteor/client/ui/ClockView/CameraScreen/Segment.tsx +++ b/packages/webui/src/client/ui/ClockView/CameraScreen/Segment.tsx @@ -1,6 +1,6 @@ import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import classNames from 'classnames' -import React, { useContext, useMemo } from 'react' +import { useContext, useMemo } from 'react' import { ActivePartInstancesContext, PieceFilter } from '.' import { withResolvedSegment, diff --git a/meteor/client/ui/ClockView/CameraScreen/index.tsx b/packages/webui/src/client/ui/ClockView/CameraScreen/index.tsx similarity index 100% rename from meteor/client/ui/ClockView/CameraScreen/index.tsx rename to packages/webui/src/client/ui/ClockView/CameraScreen/index.tsx diff --git a/meteor/client/ui/ClockView/CameraScreen/useWakeLock.ts b/packages/webui/src/client/ui/ClockView/CameraScreen/useWakeLock.ts similarity index 94% rename from meteor/client/ui/ClockView/CameraScreen/useWakeLock.ts rename to packages/webui/src/client/ui/ClockView/CameraScreen/useWakeLock.ts index 778e4a41c2..36576bf00f 100644 --- a/meteor/client/ui/ClockView/CameraScreen/useWakeLock.ts +++ b/packages/webui/src/client/ui/ClockView/CameraScreen/useWakeLock.ts @@ -24,7 +24,7 @@ export function useWakeLock(): void { return () => { if (wakeLockRef.current !== null) { - wakeLockRef.current.release() + wakeLockRef.current.release().catch(() => null) wakeLockRef.current = null } diff --git a/meteor/client/ui/ClockView/ClockView.tsx b/packages/webui/src/client/ui/ClockView/ClockView.tsx similarity index 98% rename from meteor/client/ui/ClockView/ClockView.tsx rename to packages/webui/src/client/ui/ClockView/ClockView.tsx index dcdddf5ede..3544dd3c67 100644 --- a/meteor/client/ui/ClockView/ClockView.tsx +++ b/packages/webui/src/client/ui/ClockView/ClockView.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { Switch, Route, Redirect } from 'react-router-dom' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/react-meteor-data' diff --git a/meteor/client/ui/ClockView/OverlayScreen.tsx b/packages/webui/src/client/ui/ClockView/OverlayScreen.tsx similarity index 99% rename from meteor/client/ui/ClockView/OverlayScreen.tsx rename to packages/webui/src/client/ui/ClockView/OverlayScreen.tsx index c1b976bfa6..8014b980de 100644 --- a/meteor/client/ui/ClockView/OverlayScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/OverlayScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import { useEffect } from 'react' import Moment from 'react-moment' import { useTranslation } from 'react-i18next' import { WithTiming, withTiming } from '../RundownView/RundownTiming/withTiming' diff --git a/meteor/client/ui/ClockView/OverlayScreenSaver.tsx b/packages/webui/src/client/ui/ClockView/OverlayScreenSaver.tsx similarity index 98% rename from meteor/client/ui/ClockView/OverlayScreenSaver.tsx rename to packages/webui/src/client/ui/ClockView/OverlayScreenSaver.tsx index 473f21a37b..02e5abf052 100644 --- a/meteor/client/ui/ClockView/OverlayScreenSaver.tsx +++ b/packages/webui/src/client/ui/ClockView/OverlayScreenSaver.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react' +import { useEffect, useRef } from 'react' import { Clock } from '../StudioScreenSaver/Clock' import { useTracker, useSubscription } from '../../lib/ReactMeteorData/ReactMeteorData' import { MeteorPubSub } from '../../../lib/api/pubsub' diff --git a/meteor/client/ui/ClockView/PresenterScreen.tsx b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx similarity index 99% rename from meteor/client/ui/ClockView/PresenterScreen.tsx rename to packages/webui/src/client/ui/ClockView/PresenterScreen.tsx index 88b1f73cbb..50417dfb69 100644 --- a/meteor/client/ui/ClockView/PresenterScreen.tsx +++ b/packages/webui/src/client/ui/ClockView/PresenterScreen.tsx @@ -1,4 +1,3 @@ -import React from 'react' import ClassNames from 'classnames' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { PartUi } from '../SegmentTimeline/SegmentTimelineContainer' diff --git a/meteor/client/ui/ClockView/Timediff.tsx b/packages/webui/src/client/ui/ClockView/Timediff.tsx similarity index 100% rename from meteor/client/ui/ClockView/Timediff.tsx rename to packages/webui/src/client/ui/ClockView/Timediff.tsx diff --git a/meteor/client/ui/Collections.tsx b/packages/webui/src/client/ui/Collections.tsx similarity index 100% rename from meteor/client/ui/Collections.tsx rename to packages/webui/src/client/ui/Collections.tsx diff --git a/meteor/client/ui/DragDropItemTypes.ts b/packages/webui/src/client/ui/DragDropItemTypes.ts similarity index 100% rename from meteor/client/ui/DragDropItemTypes.ts rename to packages/webui/src/client/ui/DragDropItemTypes.ts diff --git a/meteor/client/ui/FloatingInspector.tsx b/packages/webui/src/client/ui/FloatingInspector.tsx similarity index 100% rename from meteor/client/ui/FloatingInspector.tsx rename to packages/webui/src/client/ui/FloatingInspector.tsx diff --git a/meteor/client/ui/FloatingInspectors/FloatingInspectorHelpers/FloatingInspectorTimeInformationRow.tsx b/packages/webui/src/client/ui/FloatingInspectors/FloatingInspectorHelpers/FloatingInspectorTimeInformationRow.tsx similarity index 100% rename from meteor/client/ui/FloatingInspectors/FloatingInspectorHelpers/FloatingInspectorTimeInformationRow.tsx rename to packages/webui/src/client/ui/FloatingInspectors/FloatingInspectorHelpers/FloatingInspectorTimeInformationRow.tsx diff --git a/meteor/client/ui/FloatingInspectors/IFloatingInspectorPosition.ts b/packages/webui/src/client/ui/FloatingInspectors/IFloatingInspectorPosition.ts similarity index 100% rename from meteor/client/ui/FloatingInspectors/IFloatingInspectorPosition.ts rename to packages/webui/src/client/ui/FloatingInspectors/IFloatingInspectorPosition.ts diff --git a/meteor/client/ui/FloatingInspectors/InvalidFloatingInspector.tsx b/packages/webui/src/client/ui/FloatingInspectors/InvalidFloatingInspector.tsx similarity index 100% rename from meteor/client/ui/FloatingInspectors/InvalidFloatingInspector.tsx rename to packages/webui/src/client/ui/FloatingInspectors/InvalidFloatingInspector.tsx diff --git a/meteor/client/ui/FloatingInspectors/L3rdFloatingInspector.tsx b/packages/webui/src/client/ui/FloatingInspectors/L3rdFloatingInspector.tsx similarity index 100% rename from meteor/client/ui/FloatingInspectors/L3rdFloatingInspector.tsx rename to packages/webui/src/client/ui/FloatingInspectors/L3rdFloatingInspector.tsx diff --git a/meteor/client/ui/FloatingInspectors/MicFloatingInspector.tsx b/packages/webui/src/client/ui/FloatingInspectors/MicFloatingInspector.tsx similarity index 100% rename from meteor/client/ui/FloatingInspectors/MicFloatingInspector.tsx rename to packages/webui/src/client/ui/FloatingInspectors/MicFloatingInspector.tsx diff --git a/meteor/client/ui/FloatingInspectors/NoraFloatingInspector.tsx b/packages/webui/src/client/ui/FloatingInspectors/NoraFloatingInspector.tsx similarity index 100% rename from meteor/client/ui/FloatingInspectors/NoraFloatingInspector.tsx rename to packages/webui/src/client/ui/FloatingInspectors/NoraFloatingInspector.tsx diff --git a/meteor/client/ui/FloatingInspectors/SplitsFloatingInspector.tsx b/packages/webui/src/client/ui/FloatingInspectors/SplitsFloatingInspector.tsx similarity index 100% rename from meteor/client/ui/FloatingInspectors/SplitsFloatingInspector.tsx rename to packages/webui/src/client/ui/FloatingInspectors/SplitsFloatingInspector.tsx diff --git a/meteor/client/ui/FloatingInspectors/VTFloatingInspector.tsx b/packages/webui/src/client/ui/FloatingInspectors/VTFloatingInspector.tsx similarity index 100% rename from meteor/client/ui/FloatingInspectors/VTFloatingInspector.tsx rename to packages/webui/src/client/ui/FloatingInspectors/VTFloatingInspector.tsx diff --git a/meteor/client/ui/Header.tsx b/packages/webui/src/client/ui/Header.tsx similarity index 100% rename from meteor/client/ui/Header.tsx rename to packages/webui/src/client/ui/Header.tsx diff --git a/meteor/client/ui/MediaStatus/MediaStatus.tsx b/packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx similarity index 100% rename from meteor/client/ui/MediaStatus/MediaStatus.tsx rename to packages/webui/src/client/ui/MediaStatus/MediaStatus.tsx diff --git a/meteor/client/ui/MediaStatus/MediaStatusIndicator.tsx b/packages/webui/src/client/ui/MediaStatus/MediaStatusIndicator.tsx similarity index 100% rename from meteor/client/ui/MediaStatus/MediaStatusIndicator.tsx rename to packages/webui/src/client/ui/MediaStatus/MediaStatusIndicator.tsx diff --git a/meteor/client/ui/MediaStatus/SortOrderButton.tsx b/packages/webui/src/client/ui/MediaStatus/SortOrderButton.tsx similarity index 95% rename from meteor/client/ui/MediaStatus/SortOrderButton.tsx rename to packages/webui/src/client/ui/MediaStatus/SortOrderButton.tsx index 2e8c46b2bb..26750314b2 100644 --- a/meteor/client/ui/MediaStatus/SortOrderButton.tsx +++ b/packages/webui/src/client/ui/MediaStatus/SortOrderButton.tsx @@ -1,4 +1,4 @@ -import React, { JSX } from 'react' +import { JSX } from 'react' import { assertNever } from '@sofie-automation/corelib/dist/lib' import { SortAscending, SortDescending, SortDisabled } from '../../lib/ui/icons/sorting' diff --git a/meteor/client/ui/PieceIcons/IconColors.scss b/packages/webui/src/client/ui/PieceIcons/IconColors.scss similarity index 100% rename from meteor/client/ui/PieceIcons/IconColors.scss rename to packages/webui/src/client/ui/PieceIcons/IconColors.scss diff --git a/meteor/client/ui/PieceIcons/PieceCountdown.tsx b/packages/webui/src/client/ui/PieceIcons/PieceCountdown.tsx similarity index 99% rename from meteor/client/ui/PieceIcons/PieceCountdown.tsx rename to packages/webui/src/client/ui/PieceIcons/PieceCountdown.tsx index 6c2b384b84..fc49a7a533 100644 --- a/meteor/client/ui/PieceIcons/PieceCountdown.tsx +++ b/packages/webui/src/client/ui/PieceIcons/PieceCountdown.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { SourceLayerType, VTContent } from '@sofie-automation/blueprints-integration' import { MeteorPubSub } from '../../../lib/api/pubsub' diff --git a/meteor/client/ui/PieceIcons/PieceIcon.tsx b/packages/webui/src/client/ui/PieceIcons/PieceIcon.tsx similarity index 99% rename from meteor/client/ui/PieceIcons/PieceIcon.tsx rename to packages/webui/src/client/ui/PieceIcons/PieceIcon.tsx index a4c7949cce..551d638c1b 100644 --- a/meteor/client/ui/PieceIcons/PieceIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/PieceIcon.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { SourceLayerType, diff --git a/meteor/client/ui/PieceIcons/PieceName.tsx b/packages/webui/src/client/ui/PieceIcons/PieceName.tsx similarity index 98% rename from meteor/client/ui/PieceIcons/PieceName.tsx rename to packages/webui/src/client/ui/PieceIcons/PieceName.tsx index 610cf7af41..978c0e63db 100644 --- a/meteor/client/ui/PieceIcons/PieceName.tsx +++ b/packages/webui/src/client/ui/PieceIcons/PieceName.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { EvsContent, SourceLayerType } from '@sofie-automation/blueprints-integration' diff --git a/meteor/client/ui/PieceIcons/Renderers/CamInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx similarity index 100% rename from meteor/client/ui/PieceIcons/Renderers/CamInputIcon.tsx rename to packages/webui/src/client/ui/PieceIcons/Renderers/CamInputIcon.tsx diff --git a/meteor/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx similarity index 100% rename from meteor/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx rename to packages/webui/src/client/ui/PieceIcons/Renderers/GraphicsInputIcon.tsx diff --git a/meteor/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx similarity index 100% rename from meteor/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx rename to packages/webui/src/client/ui/PieceIcons/Renderers/LiveSpeakInputIcon.tsx diff --git a/meteor/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx similarity index 91% rename from meteor/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx rename to packages/webui/src/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx index b2717f0b62..00356ed2e1 100644 --- a/meteor/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx +++ b/packages/webui/src/client/ui/PieceIcons/Renderers/LocalInputIcon.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { BaseRemoteInputIcon } from './RemoteInputIcon' export default function LocalInputIcon(props: Readonly<{ inputIndex?: string; abbreviation?: string }>): JSX.Element { diff --git a/meteor/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx similarity index 100% rename from meteor/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx rename to packages/webui/src/client/ui/PieceIcons/Renderers/RemoteInputIcon.tsx diff --git a/meteor/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx similarity index 100% rename from meteor/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx rename to packages/webui/src/client/ui/PieceIcons/Renderers/SplitInputIcon.tsx diff --git a/meteor/client/ui/PieceIcons/Renderers/UnknownInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/UnknownInputIcon.tsx similarity index 100% rename from meteor/client/ui/PieceIcons/Renderers/UnknownInputIcon.tsx rename to packages/webui/src/client/ui/PieceIcons/Renderers/UnknownInputIcon.tsx diff --git a/meteor/client/ui/PieceIcons/Renderers/VTInputIcon.tsx b/packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx similarity index 100% rename from meteor/client/ui/PieceIcons/Renderers/VTInputIcon.tsx rename to packages/webui/src/client/ui/PieceIcons/Renderers/VTInputIcon.tsx diff --git a/meteor/client/ui/PieceIcons/utils.ts b/packages/webui/src/client/ui/PieceIcons/utils.ts similarity index 100% rename from meteor/client/ui/PieceIcons/utils.ts rename to packages/webui/src/client/ui/PieceIcons/utils.ts diff --git a/meteor/client/ui/Prompter/OverUnderTimer.tsx b/packages/webui/src/client/ui/Prompter/OverUnderTimer.tsx similarity index 100% rename from meteor/client/ui/Prompter/OverUnderTimer.tsx rename to packages/webui/src/client/ui/Prompter/OverUnderTimer.tsx diff --git a/meteor/client/ui/Prompter/PrompterView.tsx b/packages/webui/src/client/ui/Prompter/PrompterView.tsx similarity index 100% rename from meteor/client/ui/Prompter/PrompterView.tsx rename to packages/webui/src/client/ui/Prompter/PrompterView.tsx diff --git a/meteor/client/ui/Prompter/controller/joycon-device.ts b/packages/webui/src/client/ui/Prompter/controller/joycon-device.ts similarity index 100% rename from meteor/client/ui/Prompter/controller/joycon-device.ts rename to packages/webui/src/client/ui/Prompter/controller/joycon-device.ts diff --git a/meteor/client/ui/Prompter/controller/keyboard-device.ts b/packages/webui/src/client/ui/Prompter/controller/keyboard-device.ts similarity index 100% rename from meteor/client/ui/Prompter/controller/keyboard-device.ts rename to packages/webui/src/client/ui/Prompter/controller/keyboard-device.ts diff --git a/meteor/client/ui/Prompter/controller/lib.ts b/packages/webui/src/client/ui/Prompter/controller/lib.ts similarity index 100% rename from meteor/client/ui/Prompter/controller/lib.ts rename to packages/webui/src/client/ui/Prompter/controller/lib.ts diff --git a/meteor/client/ui/Prompter/controller/manager.ts b/packages/webui/src/client/ui/Prompter/controller/manager.ts similarity index 100% rename from meteor/client/ui/Prompter/controller/manager.ts rename to packages/webui/src/client/ui/Prompter/controller/manager.ts diff --git a/meteor/client/ui/Prompter/controller/midi-pedal-device.ts b/packages/webui/src/client/ui/Prompter/controller/midi-pedal-device.ts similarity index 100% rename from meteor/client/ui/Prompter/controller/midi-pedal-device.ts rename to packages/webui/src/client/ui/Prompter/controller/midi-pedal-device.ts diff --git a/meteor/client/ui/Prompter/controller/mouse-ish-device.ts b/packages/webui/src/client/ui/Prompter/controller/mouse-ish-device.ts similarity index 100% rename from meteor/client/ui/Prompter/controller/mouse-ish-device.ts rename to packages/webui/src/client/ui/Prompter/controller/mouse-ish-device.ts diff --git a/meteor/client/ui/Prompter/controller/shuttle-keyboard-device.ts b/packages/webui/src/client/ui/Prompter/controller/shuttle-keyboard-device.ts similarity index 100% rename from meteor/client/ui/Prompter/controller/shuttle-keyboard-device.ts rename to packages/webui/src/client/ui/Prompter/controller/shuttle-keyboard-device.ts diff --git a/meteor/client/ui/Prompter/prompter.ts b/packages/webui/src/client/ui/Prompter/prompter.ts similarity index 100% rename from meteor/client/ui/Prompter/prompter.ts rename to packages/webui/src/client/ui/Prompter/prompter.ts diff --git a/meteor/client/ui/RundownList.tsx b/packages/webui/src/client/ui/RundownList.tsx similarity index 99% rename from meteor/client/ui/RundownList.tsx rename to packages/webui/src/client/ui/RundownList.tsx index 4eace8ca18..54cb1ea35e 100644 --- a/meteor/client/ui/RundownList.tsx +++ b/packages/webui/src/client/ui/RundownList.tsx @@ -1,5 +1,4 @@ import Tooltip from 'rc-tooltip' -import * as React from 'react' import { MeteorPubSub } from '../../lib/api/pubsub' import { GENESIS_SYSTEM_VERSION } from '../../lib/collections/CoreSystem' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' diff --git a/meteor/client/ui/RundownList/ActiveProgressBar.tsx b/packages/webui/src/client/ui/RundownList/ActiveProgressBar.tsx similarity index 96% rename from meteor/client/ui/RundownList/ActiveProgressBar.tsx rename to packages/webui/src/client/ui/RundownList/ActiveProgressBar.tsx index c6c4b93a71..7f35f21d2f 100644 --- a/meteor/client/ui/RundownList/ActiveProgressBar.tsx +++ b/packages/webui/src/client/ui/RundownList/ActiveProgressBar.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { PlaylistTiming } from '@sofie-automation/corelib/dist/playout/rundownTiming' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { useCurrentTime } from '../../lib/lib' diff --git a/meteor/client/ui/RundownList/CreateAdlibTestingRundownPanel.tsx b/packages/webui/src/client/ui/RundownList/CreateAdlibTestingRundownPanel.tsx similarity index 100% rename from meteor/client/ui/RundownList/CreateAdlibTestingRundownPanel.tsx rename to packages/webui/src/client/ui/RundownList/CreateAdlibTestingRundownPanel.tsx diff --git a/meteor/client/ui/RundownList/DisplayFormattedTime.tsx b/packages/webui/src/client/ui/RundownList/DisplayFormattedTime.tsx similarity index 95% rename from meteor/client/ui/RundownList/DisplayFormattedTime.tsx rename to packages/webui/src/client/ui/RundownList/DisplayFormattedTime.tsx index 2e7184b7fc..8b90fa40f3 100644 --- a/meteor/client/ui/RundownList/DisplayFormattedTime.tsx +++ b/packages/webui/src/client/ui/RundownList/DisplayFormattedTime.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { TFunction } from 'i18next' import { DisplayFormattedTimeInner } from './DisplayFormattedTimeInner' diff --git a/meteor/client/ui/RundownList/DisplayFormattedTimeInner.ts b/packages/webui/src/client/ui/RundownList/DisplayFormattedTimeInner.ts similarity index 100% rename from meteor/client/ui/RundownList/DisplayFormattedTimeInner.ts rename to packages/webui/src/client/ui/RundownList/DisplayFormattedTimeInner.ts diff --git a/meteor/client/ui/RundownList/DragAndDropTypes.ts b/packages/webui/src/client/ui/RundownList/DragAndDropTypes.ts similarity index 87% rename from meteor/client/ui/RundownList/DragAndDropTypes.ts rename to packages/webui/src/client/ui/RundownList/DragAndDropTypes.ts index ca2d88c837..81781e4495 100644 --- a/meteor/client/ui/RundownList/DragAndDropTypes.ts +++ b/packages/webui/src/client/ui/RundownList/DragAndDropTypes.ts @@ -53,11 +53,6 @@ function isRundownPlaylistUiAction(obj: unknown): obj is IRundownPlaylistUiActio return typeof type === 'string' && type in RundownPlaylistUiActionTypes } -export { - IRundownDragObject, - IRundownPlaylistUiAction, - isRundownDragObject, - isRundownPlaylistUiAction, - RundownListDragDropTypes, - RundownPlaylistUiActionTypes, -} +export { isRundownDragObject, isRundownPlaylistUiAction, RundownListDragDropTypes, RundownPlaylistUiActionTypes } + +export type { IRundownDragObject, IRundownPlaylistUiAction } diff --git a/meteor/client/ui/RundownList/GettingStarted.tsx b/packages/webui/src/client/ui/RundownList/GettingStarted.tsx similarity index 97% rename from meteor/client/ui/RundownList/GettingStarted.tsx rename to packages/webui/src/client/ui/RundownList/GettingStarted.tsx index d702928c26..0d13cd9f12 100644 --- a/meteor/client/ui/RundownList/GettingStarted.tsx +++ b/packages/webui/src/client/ui/RundownList/GettingStarted.tsx @@ -1,4 +1,3 @@ -import React from 'react' import Tooltip from 'rc-tooltip' import { useTranslation } from 'react-i18next' import { ToolTipStep } from '../RundownList' diff --git a/meteor/client/ui/RundownList/PlaylistRankResetButton.tsx b/packages/webui/src/client/ui/RundownList/PlaylistRankResetButton.tsx similarity index 97% rename from meteor/client/ui/RundownList/PlaylistRankResetButton.tsx rename to packages/webui/src/client/ui/RundownList/PlaylistRankResetButton.tsx index 951b201530..476a712539 100644 --- a/meteor/client/ui/RundownList/PlaylistRankResetButton.tsx +++ b/packages/webui/src/client/ui/RundownList/PlaylistRankResetButton.tsx @@ -1,4 +1,3 @@ -import React from 'react' import Tooltip from 'rc-tooltip' import { useTranslation } from 'react-i18next' import { TOOLTIP_DEFAULT_DELAY } from '../../lib/lib' diff --git a/meteor/client/ui/RundownList/RegisterHelp.tsx b/packages/webui/src/client/ui/RundownList/RegisterHelp.tsx similarity index 100% rename from meteor/client/ui/RundownList/RegisterHelp.tsx rename to packages/webui/src/client/ui/RundownList/RegisterHelp.tsx diff --git a/meteor/client/ui/RundownList/RundownDropZone.tsx b/packages/webui/src/client/ui/RundownList/RundownDropZone.tsx similarity index 97% rename from meteor/client/ui/RundownList/RundownDropZone.tsx rename to packages/webui/src/client/ui/RundownList/RundownDropZone.tsx index d41968cf30..7d2815adef 100644 --- a/meteor/client/ui/RundownList/RundownDropZone.tsx +++ b/packages/webui/src/client/ui/RundownList/RundownDropZone.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { useDrop } from 'react-dnd' import { useTranslation } from 'react-i18next' import { MeteorCall } from '../../../lib/api/methods' diff --git a/meteor/client/ui/RundownList/RundownListFooter.tsx b/packages/webui/src/client/ui/RundownList/RundownListFooter.tsx similarity index 90% rename from meteor/client/ui/RundownList/RundownListFooter.tsx rename to packages/webui/src/client/ui/RundownList/RundownListFooter.tsx index 99945f74ae..1bdde77bf5 100644 --- a/meteor/client/ui/RundownList/RundownListFooter.tsx +++ b/packages/webui/src/client/ui/RundownList/RundownListFooter.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import Tooltip from 'rc-tooltip' import { getHelpMode } from '../../lib/localStorage' import { StatusResponse } from '../../../lib/api/systemStatus' @@ -7,8 +7,6 @@ import { useTranslation } from 'react-i18next' import { MeteorCall } from '../../../lib/api/methods' import { NoticeLevel, Notification, NotificationCenter } from '../../../lib/notifications/notifications' -const PackageInfo = require('../../../package.json') as Record - export function RundownListFooter(): JSX.Element { const { t } = useTranslation() @@ -41,8 +39,8 @@ export function RundownListFooter(): JSX.Element { } }, []) - const version = PackageInfo.version || 'UNSTABLE' - const versionExtended = PackageInfo.versionExtended || version + const version = __APP_VERSION__ || 'UNSTABLE' + const versionExtended = __APP_VERSION_EXTENDED__ || version return (
diff --git a/meteor/client/ui/RundownList/RundownListItem.tsx b/packages/webui/src/client/ui/RundownList/RundownListItem.tsx similarity index 99% rename from meteor/client/ui/RundownList/RundownListItem.tsx rename to packages/webui/src/client/ui/RundownList/RundownListItem.tsx index 5eaf623d08..7d5946e75d 100644 --- a/meteor/client/ui/RundownList/RundownListItem.tsx +++ b/packages/webui/src/client/ui/RundownList/RundownListItem.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import { useEffect } from 'react' import classNames from 'classnames' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { getAllowConfigure, getAllowService, getAllowStudio } from '../../lib/localStorage' diff --git a/meteor/client/ui/RundownList/RundownListItemProblems.tsx b/packages/webui/src/client/ui/RundownList/RundownListItemProblems.tsx similarity index 96% rename from meteor/client/ui/RundownList/RundownListItemProblems.tsx rename to packages/webui/src/client/ui/RundownList/RundownListItemProblems.tsx index d0684ea043..3024dde3a3 100644 --- a/meteor/client/ui/RundownList/RundownListItemProblems.tsx +++ b/packages/webui/src/client/ui/RundownList/RundownListItemProblems.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { INoteBase } from '@sofie-automation/corelib/dist/dataModel/Notes' import { CriticalIconSmall, WarningIconSmall } from '../../lib/ui/icons/notifications' diff --git a/meteor/client/ui/RundownList/RundownListItemView.tsx b/packages/webui/src/client/ui/RundownList/RundownListItemView.tsx similarity index 100% rename from meteor/client/ui/RundownList/RundownListItemView.tsx rename to packages/webui/src/client/ui/RundownList/RundownListItemView.tsx diff --git a/meteor/client/ui/RundownList/RundownPlaylistDragLayer.tsx b/packages/webui/src/client/ui/RundownList/RundownPlaylistDragLayer.tsx similarity index 100% rename from meteor/client/ui/RundownList/RundownPlaylistDragLayer.tsx rename to packages/webui/src/client/ui/RundownList/RundownPlaylistDragLayer.tsx diff --git a/meteor/client/ui/RundownList/RundownPlaylistUi.tsx b/packages/webui/src/client/ui/RundownList/RundownPlaylistUi.tsx similarity index 99% rename from meteor/client/ui/RundownList/RundownPlaylistUi.tsx rename to packages/webui/src/client/ui/RundownList/RundownPlaylistUi.tsx index ee745d9ca4..a544f0e2d9 100644 --- a/meteor/client/ui/RundownList/RundownPlaylistUi.tsx +++ b/packages/webui/src/client/ui/RundownList/RundownPlaylistUi.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import Tooltip from 'rc-tooltip' import ClassNames from 'classnames' import { useTranslation } from 'react-i18next' diff --git a/meteor/client/ui/RundownList/RundownViewLayoutSelection.tsx b/packages/webui/src/client/ui/RundownList/RundownViewLayoutSelection.tsx similarity index 100% rename from meteor/client/ui/RundownList/RundownViewLayoutSelection.tsx rename to packages/webui/src/client/ui/RundownList/RundownViewLayoutSelection.tsx diff --git a/meteor/client/ui/RundownList/__tests__/DisplayFormattedTime.test.ts b/packages/webui/src/client/ui/RundownList/__tests__/DisplayFormattedTime.test.ts similarity index 100% rename from meteor/client/ui/RundownList/__tests__/DisplayFormattedTime.test.ts rename to packages/webui/src/client/ui/RundownList/__tests__/DisplayFormattedTime.test.ts diff --git a/meteor/client/ui/RundownList/__tests__/DragAndDropTypes.test.ts b/packages/webui/src/client/ui/RundownList/__tests__/DragAndDropTypes.test.ts similarity index 100% rename from meteor/client/ui/RundownList/__tests__/DragAndDropTypes.test.ts rename to packages/webui/src/client/ui/RundownList/__tests__/DragAndDropTypes.test.ts diff --git a/meteor/client/ui/RundownList/icons.tsx b/packages/webui/src/client/ui/RundownList/icons.tsx similarity index 100% rename from meteor/client/ui/RundownList/icons.tsx rename to packages/webui/src/client/ui/RundownList/icons.tsx diff --git a/meteor/client/ui/RundownList/util.ts b/packages/webui/src/client/ui/RundownList/util.ts similarity index 100% rename from meteor/client/ui/RundownList/util.ts rename to packages/webui/src/client/ui/RundownList/util.ts diff --git a/meteor/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx similarity index 99% rename from meteor/client/ui/RundownView.tsx rename to packages/webui/src/client/ui/RundownView.tsx index a6ce8517e3..0e7b8b1532 100644 --- a/meteor/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -13,7 +13,7 @@ import { import { VTContent, TSR, NoteSeverity, ISourceLayer } from '@sofie-automation/blueprints-integration' import { withTranslation, WithTranslation } from 'react-i18next' import timer from 'react-timer-hoc' -import CoreIcon from '@nrk/core-icons/jsx' +import * as CoreIcon from '@nrk/core-icons/jsx' import { Spinner } from '../lib/Spinner' import ClassNames from 'classnames' import * as _ from 'underscore' @@ -154,8 +154,7 @@ import { isTranslatableMessage, translateMessage } from '@sofie-automation/corel import { i18nTranslator } from './i18n' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { useRundownAndShowStyleIdsForPlaylist } from './util/useRundownAndShowStyleIdsForPlaylist' - -export const MAGIC_TIME_SCALE_FACTOR = 0.03 +import { MAGIC_TIME_SCALE_FACTOR } from './SegmentTimeline/Constants' const REHEARSAL_MARGIN = 1 * 60 * 1000 const HIDE_NOTIFICATIONS_AFTER_MOUNT: number | undefined = 5000 @@ -1891,7 +1890,6 @@ const RundownViewContent = translateWithTracker( } // Setup by React.Component constructor - context!: { + declare context: { durations: RundownTimingContext syncedDurations: RundownTimingContext } diff --git a/meteor/client/ui/RundownView/RundownViewShelf.tsx b/packages/webui/src/client/ui/RundownView/RundownViewShelf.tsx similarity index 100% rename from meteor/client/ui/RundownView/RundownViewShelf.tsx rename to packages/webui/src/client/ui/RundownView/RundownViewShelf.tsx diff --git a/meteor/client/ui/RundownView/Rundown_Fullscreen_Stage_01.json b/packages/webui/src/client/ui/RundownView/Rundown_Fullscreen_Stage_01.json similarity index 100% rename from meteor/client/ui/RundownView/Rundown_Fullscreen_Stage_01.json rename to packages/webui/src/client/ui/RundownView/Rundown_Fullscreen_Stage_01.json diff --git a/meteor/client/ui/RundownView/Rundown_Fullscreen_Stage_02.json b/packages/webui/src/client/ui/RundownView/Rundown_Fullscreen_Stage_02.json similarity index 100% rename from meteor/client/ui/RundownView/Rundown_Fullscreen_Stage_02.json rename to packages/webui/src/client/ui/RundownView/Rundown_Fullscreen_Stage_02.json diff --git a/meteor/client/ui/RundownView/StudioContext.tsx b/packages/webui/src/client/ui/RundownView/StudioContext.tsx similarity index 100% rename from meteor/client/ui/RundownView/StudioContext.tsx rename to packages/webui/src/client/ui/RundownView/StudioContext.tsx diff --git a/meteor/client/ui/RundownView/SwitchboardPopUp.tsx b/packages/webui/src/client/ui/RundownView/SwitchboardPopUp.tsx similarity index 100% rename from meteor/client/ui/RundownView/SwitchboardPopUp.tsx rename to packages/webui/src/client/ui/RundownView/SwitchboardPopUp.tsx diff --git a/meteor/client/ui/RundownView/Windowed_MouseOut.json b/packages/webui/src/client/ui/RundownView/Windowed_MouseOut.json similarity index 100% rename from meteor/client/ui/RundownView/Windowed_MouseOut.json rename to packages/webui/src/client/ui/RundownView/Windowed_MouseOut.json diff --git a/meteor/client/ui/RundownView/Windowed_MouseOver.json b/packages/webui/src/client/ui/RundownView/Windowed_MouseOver.json similarity index 100% rename from meteor/client/ui/RundownView/Windowed_MouseOver.json rename to packages/webui/src/client/ui/RundownView/Windowed_MouseOver.json diff --git a/meteor/client/ui/SegmentAdlibTesting/SegmentAdlibTesting.scss b/packages/webui/src/client/ui/SegmentAdlibTesting/SegmentAdlibTesting.scss similarity index 100% rename from meteor/client/ui/SegmentAdlibTesting/SegmentAdlibTesting.scss rename to packages/webui/src/client/ui/SegmentAdlibTesting/SegmentAdlibTesting.scss diff --git a/meteor/client/ui/SegmentAdlibTesting/SegmentAdlibTesting.tsx b/packages/webui/src/client/ui/SegmentAdlibTesting/SegmentAdlibTesting.tsx similarity index 100% rename from meteor/client/ui/SegmentAdlibTesting/SegmentAdlibTesting.tsx rename to packages/webui/src/client/ui/SegmentAdlibTesting/SegmentAdlibTesting.tsx diff --git a/meteor/client/ui/SegmentAdlibTesting/SegmentAdlibTestingContainer.tsx b/packages/webui/src/client/ui/SegmentAdlibTesting/SegmentAdlibTestingContainer.tsx similarity index 98% rename from meteor/client/ui/SegmentAdlibTesting/SegmentAdlibTestingContainer.tsx rename to packages/webui/src/client/ui/SegmentAdlibTesting/SegmentAdlibTestingContainer.tsx index 3b62d974b5..a9af151d73 100644 --- a/meteor/client/ui/SegmentAdlibTesting/SegmentAdlibTestingContainer.tsx +++ b/packages/webui/src/client/ui/SegmentAdlibTesting/SegmentAdlibTestingContainer.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { meteorSubscribe } from '../../../lib/api/pubsub' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { @@ -10,7 +10,7 @@ import { import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { SegmentAdlibTesting } from './SegmentAdlibTesting' import { unprotectString } from '../../../lib/lib' -import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/SegmentTimelineContainer' +import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/Constants' import { PartInstances, Parts, Segments } from '../../collections' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' import { MongoFieldSpecifierOnes } from '@sofie-automation/corelib/dist/mongo' diff --git a/meteor/client/ui/SegmentContainer/PieceElement.tsx b/packages/webui/src/client/ui/SegmentContainer/PieceElement.tsx similarity index 100% rename from meteor/client/ui/SegmentContainer/PieceElement.tsx rename to packages/webui/src/client/ui/SegmentContainer/PieceElement.tsx diff --git a/meteor/client/ui/SegmentContainer/PieceMultistepChevron.tsx b/packages/webui/src/client/ui/SegmentContainer/PieceMultistepChevron.tsx similarity index 100% rename from meteor/client/ui/SegmentContainer/PieceMultistepChevron.tsx rename to packages/webui/src/client/ui/SegmentContainer/PieceMultistepChevron.tsx diff --git a/meteor/client/ui/SegmentContainer/SegmentViewModes.ts b/packages/webui/src/client/ui/SegmentContainer/SegmentViewModes.ts similarity index 100% rename from meteor/client/ui/SegmentContainer/SegmentViewModes.ts rename to packages/webui/src/client/ui/SegmentContainer/SegmentViewModes.ts diff --git a/meteor/client/ui/SegmentContainer/SwitchViewModeButton.tsx b/packages/webui/src/client/ui/SegmentContainer/SwitchViewModeButton.tsx similarity index 98% rename from meteor/client/ui/SegmentContainer/SwitchViewModeButton.tsx rename to packages/webui/src/client/ui/SegmentContainer/SwitchViewModeButton.tsx index c7f66ff882..f454198a4f 100644 --- a/meteor/client/ui/SegmentContainer/SwitchViewModeButton.tsx +++ b/packages/webui/src/client/ui/SegmentContainer/SwitchViewModeButton.tsx @@ -1,5 +1,4 @@ import Tooltip from 'rc-tooltip' -import React from 'react' import { useTranslation } from 'react-i18next' import { SegmentViewMode as SegmentViewModeIcon } from '../../lib/ui/icons/listView' diff --git a/meteor/client/ui/SegmentContainer/getReactivePieceNoteCountsForSegment.tsx b/packages/webui/src/client/ui/SegmentContainer/getReactivePieceNoteCountsForSegment.tsx similarity index 100% rename from meteor/client/ui/SegmentContainer/getReactivePieceNoteCountsForSegment.tsx rename to packages/webui/src/client/ui/SegmentContainer/getReactivePieceNoteCountsForSegment.tsx diff --git a/meteor/client/ui/SegmentContainer/getSplitItems.tsx b/packages/webui/src/client/ui/SegmentContainer/getSplitItems.tsx similarity index 97% rename from meteor/client/ui/SegmentContainer/getSplitItems.tsx rename to packages/webui/src/client/ui/SegmentContainer/getSplitItems.tsx index 033682e769..7166032867 100644 --- a/meteor/client/ui/SegmentContainer/getSplitItems.tsx +++ b/packages/webui/src/client/ui/SegmentContainer/getSplitItems.tsx @@ -1,4 +1,3 @@ -import React from 'react' import classNames from 'classnames' import { SplitsContent } from '@sofie-automation/blueprints-integration' import { RundownUtils } from '../../lib/rundown' diff --git a/meteor/client/ui/SegmentContainer/withResolvedSegment.ts b/packages/webui/src/client/ui/SegmentContainer/withResolvedSegment.ts similarity index 97% rename from meteor/client/ui/SegmentContainer/withResolvedSegment.ts rename to packages/webui/src/client/ui/SegmentContainer/withResolvedSegment.ts index b1270a028a..d06343785f 100644 --- a/meteor/client/ui/SegmentContainer/withResolvedSegment.ts +++ b/packages/webui/src/client/ui/SegmentContainer/withResolvedSegment.ts @@ -118,13 +118,9 @@ export interface ITrackedResolvedSegmentProps { showCountdownToSegment: boolean } -type IWrappedComponent = - | React.ComponentClass - | ((props: IProps & TrackedProps) => JSX.Element | null) - export function withResolvedSegment( - WrappedComponent: IWrappedComponent -): new (props: T) => React.Component { + WrappedComponent: React.ComponentType +): React.ComponentType { return withTracker( (props: T) => { const segment = Segments.findOne(props.segmentId) as SegmentUi | undefined diff --git a/meteor/client/ui/SegmentList/LinePart.tsx b/packages/webui/src/client/ui/SegmentList/LinePart.tsx similarity index 100% rename from meteor/client/ui/SegmentList/LinePart.tsx rename to packages/webui/src/client/ui/SegmentList/LinePart.tsx diff --git a/meteor/client/ui/SegmentList/LinePartIdentifier.tsx b/packages/webui/src/client/ui/SegmentList/LinePartIdentifier.tsx similarity index 89% rename from meteor/client/ui/SegmentList/LinePartIdentifier.tsx rename to packages/webui/src/client/ui/SegmentList/LinePartIdentifier.tsx index 5ed3cae0a4..f8de70096e 100644 --- a/meteor/client/ui/SegmentList/LinePartIdentifier.tsx +++ b/packages/webui/src/client/ui/SegmentList/LinePartIdentifier.tsx @@ -1,5 +1,3 @@ -import React from 'react' - export function LinePartIdentifier({ identifier }: Readonly<{ identifier: string }>): JSX.Element { return (
diff --git a/meteor/client/ui/SegmentList/LinePartMainPiece/LinePartMainPiece.scss b/packages/webui/src/client/ui/SegmentList/LinePartMainPiece/LinePartMainPiece.scss similarity index 100% rename from meteor/client/ui/SegmentList/LinePartMainPiece/LinePartMainPiece.scss rename to packages/webui/src/client/ui/SegmentList/LinePartMainPiece/LinePartMainPiece.scss diff --git a/meteor/client/ui/SegmentList/LinePartMainPiece/LinePartMainPiece.tsx b/packages/webui/src/client/ui/SegmentList/LinePartMainPiece/LinePartMainPiece.tsx similarity index 100% rename from meteor/client/ui/SegmentList/LinePartMainPiece/LinePartMainPiece.tsx rename to packages/webui/src/client/ui/SegmentList/LinePartMainPiece/LinePartMainPiece.tsx diff --git a/meteor/client/ui/SegmentList/LinePartPieceIndicator/LinePartAdLibIndicator.tsx b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartAdLibIndicator.tsx similarity index 100% rename from meteor/client/ui/SegmentList/LinePartPieceIndicator/LinePartAdLibIndicator.tsx rename to packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartAdLibIndicator.tsx diff --git a/meteor/client/ui/SegmentList/LinePartPieceIndicator/LinePartIndicator.tsx b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartIndicator.tsx similarity index 100% rename from meteor/client/ui/SegmentList/LinePartPieceIndicator/LinePartIndicator.tsx rename to packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartIndicator.tsx diff --git a/meteor/client/ui/SegmentList/LinePartPieceIndicator/LinePartPieceIndicator.scss b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartPieceIndicator.scss similarity index 100% rename from meteor/client/ui/SegmentList/LinePartPieceIndicator/LinePartPieceIndicator.scss rename to packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartPieceIndicator.scss diff --git a/meteor/client/ui/SegmentList/LinePartPieceIndicator/LinePartPieceIndicator.tsx b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartPieceIndicator.tsx similarity index 100% rename from meteor/client/ui/SegmentList/LinePartPieceIndicator/LinePartPieceIndicator.tsx rename to packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartPieceIndicator.tsx diff --git a/meteor/client/ui/SegmentList/LinePartPieceIndicator/LinePartScriptPiece.tsx b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartScriptPiece.tsx similarity index 97% rename from meteor/client/ui/SegmentList/LinePartPieceIndicator/LinePartScriptPiece.tsx rename to packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartScriptPiece.tsx index 5c7ebc376b..23d638c1c3 100644 --- a/meteor/client/ui/SegmentList/LinePartPieceIndicator/LinePartScriptPiece.tsx +++ b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartScriptPiece.tsx @@ -1,5 +1,5 @@ import { ScriptContent, SourceLayerType } from '@sofie-automation/blueprints-integration' -import React, { useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useLayoutEffect, useMemo, useRef, useState } from 'react' import { PieceExtended } from '../../../lib/RundownResolver' import { IFloatingInspectorPosition } from '../../FloatingInspectors/IFloatingInspectorPosition' import { MicFloatingInspector } from '../../FloatingInspectors/MicFloatingInspector' diff --git a/meteor/client/ui/SegmentList/LinePartPieceIndicator/PieceIndicatorMenu.scss b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/PieceIndicatorMenu.scss similarity index 100% rename from meteor/client/ui/SegmentList/LinePartPieceIndicator/PieceIndicatorMenu.scss rename to packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/PieceIndicatorMenu.scss diff --git a/meteor/client/ui/SegmentList/LinePartPieceIndicator/PieceIndicatorMenu.tsx b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/PieceIndicatorMenu.tsx similarity index 100% rename from meteor/client/ui/SegmentList/LinePartPieceIndicator/PieceIndicatorMenu.tsx rename to packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/PieceIndicatorMenu.tsx diff --git a/meteor/client/ui/SegmentList/LinePartPieceIndicators.tsx b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicators.tsx similarity index 100% rename from meteor/client/ui/SegmentList/LinePartPieceIndicators.tsx rename to packages/webui/src/client/ui/SegmentList/LinePartPieceIndicators.tsx diff --git a/meteor/client/ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.scss b/packages/webui/src/client/ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.scss similarity index 100% rename from meteor/client/ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.scss rename to packages/webui/src/client/ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.scss diff --git a/meteor/client/ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.tsx b/packages/webui/src/client/ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.tsx similarity index 100% rename from meteor/client/ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.tsx rename to packages/webui/src/client/ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.tsx diff --git a/meteor/client/ui/SegmentList/LinePartTimeline.tsx b/packages/webui/src/client/ui/SegmentList/LinePartTimeline.tsx similarity index 100% rename from meteor/client/ui/SegmentList/LinePartTimeline.tsx rename to packages/webui/src/client/ui/SegmentList/LinePartTimeline.tsx diff --git a/meteor/client/ui/SegmentList/LinePartTitle.tsx b/packages/webui/src/client/ui/SegmentList/LinePartTitle.tsx similarity index 94% rename from meteor/client/ui/SegmentList/LinePartTitle.tsx rename to packages/webui/src/client/ui/SegmentList/LinePartTitle.tsx index 1af80793ec..cd5c788829 100644 --- a/meteor/client/ui/SegmentList/LinePartTitle.tsx +++ b/packages/webui/src/client/ui/SegmentList/LinePartTitle.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useLayoutEffect, useState } from 'react' +import { useRef, useLayoutEffect, useState } from 'react' import Tooltip from 'rc-tooltip' import { TOOLTIP_DEFAULT_DELAY } from '../../lib/lib' import { TooltipProps } from 'rc-tooltip/lib/Tooltip' diff --git a/meteor/client/ui/SegmentList/LinePartTransitionPiece/LinePartTransitionPiece.scss b/packages/webui/src/client/ui/SegmentList/LinePartTransitionPiece/LinePartTransitionPiece.scss similarity index 100% rename from meteor/client/ui/SegmentList/LinePartTransitionPiece/LinePartTransitionPiece.scss rename to packages/webui/src/client/ui/SegmentList/LinePartTransitionPiece/LinePartTransitionPiece.scss diff --git a/meteor/client/ui/SegmentList/LinePartTransitionPiece/LinePartTransitionPiece.tsx b/packages/webui/src/client/ui/SegmentList/LinePartTransitionPiece/LinePartTransitionPiece.tsx similarity index 100% rename from meteor/client/ui/SegmentList/LinePartTransitionPiece/LinePartTransitionPiece.tsx rename to packages/webui/src/client/ui/SegmentList/LinePartTransitionPiece/LinePartTransitionPiece.tsx diff --git a/meteor/client/ui/SegmentList/OnAirLine.tsx b/packages/webui/src/client/ui/SegmentList/OnAirLine.tsx similarity index 99% rename from meteor/client/ui/SegmentList/OnAirLine.tsx rename to packages/webui/src/client/ui/SegmentList/OnAirLine.tsx index c77410c606..0377244756 100644 --- a/meteor/client/ui/SegmentList/OnAirLine.tsx +++ b/packages/webui/src/client/ui/SegmentList/OnAirLine.tsx @@ -5,7 +5,7 @@ import { WithTiming, withTiming, } from '../RundownView/RundownTiming/withTiming' -import { SIMULATED_PLAYBACK_HARD_MARGIN } from '../SegmentTimeline/SegmentTimelineContainer' +import { SIMULATED_PLAYBACK_HARD_MARGIN } from '../SegmentTimeline/Constants' import { PartInstanceLimited } from '../../lib/RundownResolver' import { useTranslation } from 'react-i18next' import { getAllowSpeaking, getAllowVibrating } from '../../lib/localStorage' diff --git a/meteor/client/ui/SegmentList/OvertimeShadow.tsx b/packages/webui/src/client/ui/SegmentList/OvertimeShadow.tsx similarity index 100% rename from meteor/client/ui/SegmentList/OvertimeShadow.tsx rename to packages/webui/src/client/ui/SegmentList/OvertimeShadow.tsx diff --git a/meteor/client/ui/SegmentList/PartAutoNextMarker.tsx b/packages/webui/src/client/ui/SegmentList/PartAutoNextMarker.tsx similarity index 100% rename from meteor/client/ui/SegmentList/PartAutoNextMarker.tsx rename to packages/webui/src/client/ui/SegmentList/PartAutoNextMarker.tsx diff --git a/meteor/client/ui/SegmentList/PieceHoverInspector.tsx b/packages/webui/src/client/ui/SegmentList/PieceHoverInspector.tsx similarity index 99% rename from meteor/client/ui/SegmentList/PieceHoverInspector.tsx rename to packages/webui/src/client/ui/SegmentList/PieceHoverInspector.tsx index 811f250593..a0310a6961 100644 --- a/meteor/client/ui/SegmentList/PieceHoverInspector.tsx +++ b/packages/webui/src/client/ui/SegmentList/PieceHoverInspector.tsx @@ -6,7 +6,6 @@ import { VTContent, } from '@sofie-automation/blueprints-integration' import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' -import React from 'react' import { UIStudio } from '../../../lib/api/studios' import { getNoticeLevelForPieceStatus } from '../../../lib/notifications/notifications' import { RundownUtils } from '../../lib/rundown' diff --git a/meteor/client/ui/SegmentList/SegmentList.scss b/packages/webui/src/client/ui/SegmentList/SegmentList.scss similarity index 100% rename from meteor/client/ui/SegmentList/SegmentList.scss rename to packages/webui/src/client/ui/SegmentList/SegmentList.scss diff --git a/meteor/client/ui/SegmentList/SegmentList.tsx b/packages/webui/src/client/ui/SegmentList/SegmentList.tsx similarity index 100% rename from meteor/client/ui/SegmentList/SegmentList.tsx rename to packages/webui/src/client/ui/SegmentList/SegmentList.tsx diff --git a/meteor/client/ui/SegmentList/SegmentListContainer.tsx b/packages/webui/src/client/ui/SegmentList/SegmentListContainer.tsx similarity index 98% rename from meteor/client/ui/SegmentList/SegmentListContainer.tsx rename to packages/webui/src/client/ui/SegmentList/SegmentListContainer.tsx index 576163c41b..d134860cf9 100644 --- a/meteor/client/ui/SegmentList/SegmentListContainer.tsx +++ b/packages/webui/src/client/ui/SegmentList/SegmentListContainer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react' +import { useEffect, useRef } from 'react' import { meteorSubscribe } from '../../../lib/api/pubsub' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { @@ -9,7 +9,7 @@ import { import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { SegmentList } from './SegmentList' import { unprotectString } from '../../../lib/lib' -import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/SegmentTimelineContainer' +import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/Constants' import { PartInstances, Parts, Segments } from '../../collections' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' diff --git a/meteor/client/ui/SegmentList/SegmentListHeader.tsx b/packages/webui/src/client/ui/SegmentList/SegmentListHeader.tsx similarity index 99% rename from meteor/client/ui/SegmentList/SegmentListHeader.tsx rename to packages/webui/src/client/ui/SegmentList/SegmentListHeader.tsx index 0a54e1e537..55ecb5308f 100644 --- a/meteor/client/ui/SegmentList/SegmentListHeader.tsx +++ b/packages/webui/src/client/ui/SegmentList/SegmentListHeader.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { ContextMenuTrigger } from '@jstarpl/react-contextmenu' import classNames from 'classnames' // import { InView } from 'react-intersection-observer' diff --git a/meteor/client/ui/SegmentList/TakeLine.tsx b/packages/webui/src/client/ui/SegmentList/TakeLine.tsx similarity index 100% rename from meteor/client/ui/SegmentList/TakeLine.tsx rename to packages/webui/src/client/ui/SegmentList/TakeLine.tsx diff --git a/meteor/client/ui/SegmentList/utils/LiveLineIsPast.tsx b/packages/webui/src/client/ui/SegmentList/utils/LiveLineIsPast.tsx similarity index 100% rename from meteor/client/ui/SegmentList/utils/LiveLineIsPast.tsx rename to packages/webui/src/client/ui/SegmentList/utils/LiveLineIsPast.tsx diff --git a/meteor/client/ui/SegmentStoryboard/SegmentScrollbar.tsx b/packages/webui/src/client/ui/SegmentStoryboard/SegmentScrollbar.tsx similarity index 100% rename from meteor/client/ui/SegmentStoryboard/SegmentScrollbar.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/SegmentScrollbar.tsx diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss b/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboard.scss similarity index 100% rename from meteor/client/ui/SegmentStoryboard/SegmentStoryboard.scss rename to packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboard.scss diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboard.tsx b/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboard.tsx similarity index 100% rename from meteor/client/ui/SegmentStoryboard/SegmentStoryboard.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboard.tsx diff --git a/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx similarity index 98% rename from meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx index 7ce171cc48..f8feed8fc3 100644 --- a/meteor/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboardContainer.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { meteorSubscribe } from '../../../lib/api/pubsub' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { @@ -10,7 +10,7 @@ import { import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { SegmentStoryboard } from './SegmentStoryboard' import { unprotectString } from '../../../lib/lib' -import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/SegmentTimelineContainer' +import { LIVELINE_HISTORY_SIZE as TIMELINE_LIVELINE_HISTORY_SIZE } from '../SegmentTimeline/Constants' import { PartInstances, Parts, Segments } from '../../collections' import { literal } from '@sofie-automation/shared-lib/dist/lib/lib' import { MongoFieldSpecifierOnes } from '@sofie-automation/corelib/dist/mongo' diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPart.tsx similarity index 100% rename from meteor/client/ui/SegmentStoryboard/StoryboardPart.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPart.tsx diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/DefaultRenderer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/DefaultRenderer.tsx similarity index 96% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/DefaultRenderer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/DefaultRenderer.tsx index f778d2104d..e06081be0b 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/DefaultRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/DefaultRenderer.tsx @@ -1,6 +1,5 @@ import { ISourceLayer } from '@sofie-automation/blueprints-integration' import { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import React from 'react' import { UIStudio } from '../../../../../lib/api/studios' import { PieceUi } from '../../../SegmentContainer/withResolvedSegment' diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/GraphicsRenderer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/GraphicsRenderer.tsx similarity index 98% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/GraphicsRenderer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/GraphicsRenderer.tsx index 92478c629d..7b911b4875 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/GraphicsRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/GraphicsRenderer.tsx @@ -1,5 +1,4 @@ import { GraphicsContent, NoraContent } from '@sofie-automation/blueprints-integration' -import React from 'react' import { L3rdFloatingInspector } from '../../../FloatingInspectors/L3rdFloatingInspector' import { PieceMultistepChevron } from '../../../SegmentContainer/PieceMultistepChevron' import { IDefaultRendererProps } from './DefaultRenderer' diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/ScriptRenderer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/ScriptRenderer.tsx similarity index 97% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/ScriptRenderer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/ScriptRenderer.tsx index 91445cfd65..45d0dfa866 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/ScriptRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/ScriptRenderer.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { MicFloatingInspector } from '../../../FloatingInspectors/MicFloatingInspector' import { ScriptContent } from '@sofie-automation/blueprints-integration' import { IDefaultRendererProps } from './DefaultRenderer' diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/SplitsRenderer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/SplitsRenderer.tsx similarity index 97% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/SplitsRenderer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/SplitsRenderer.tsx index 8902d9c6b6..371ff70fff 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/SplitsRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/SplitsRenderer.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { SplitsContent } from '@sofie-automation/blueprints-integration' import { IDefaultRendererProps } from './DefaultRenderer' import { SplitsFloatingInspector } from '../../../FloatingInspectors/SplitsFloatingInspector' diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/VTRenderer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/VTRenderer.tsx similarity index 98% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/VTRenderer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/VTRenderer.tsx index f21d010cf7..8fac483e3e 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/VTRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/Renderers/VTRenderer.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { VTContent } from '@sofie-automation/blueprints-integration' import { VTFloatingInspector } from '../../../FloatingInspectors/VTFloatingInspector' import { IDefaultRendererProps } from './DefaultRenderer' diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardPartSecondaryPieces.scss b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardPartSecondaryPieces.scss similarity index 100% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardPartSecondaryPieces.scss rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardPartSecondaryPieces.scss diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardPartSecondaryPieces.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardPartSecondaryPieces.tsx similarity index 100% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardPartSecondaryPieces.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardPartSecondaryPieces.tsx diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardSecondaryPiece.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardSecondaryPiece.tsx similarity index 100% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardSecondaryPiece.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardSecondaryPiece.tsx diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardSourceLayer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardSourceLayer.tsx similarity index 100% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardSourceLayer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardSourceLayer.tsx diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/CameraThumbnailRenderer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/CameraThumbnailRenderer.tsx similarity index 94% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/CameraThumbnailRenderer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/CameraThumbnailRenderer.tsx index a4d038da4c..e07dd91076 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/CameraThumbnailRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/CameraThumbnailRenderer.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { getSizeClassForLabel } from '../../utils/getLabelClass' import { IProps } from './ThumbnailRendererFactory' diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/DefaultThumbnailRenderer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/DefaultThumbnailRenderer.tsx similarity index 88% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/DefaultThumbnailRenderer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/DefaultThumbnailRenderer.tsx index e01ccb8150..928b202566 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/DefaultThumbnailRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/DefaultThumbnailRenderer.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { IProps } from './ThumbnailRendererFactory' export function DefaultThumbnailRenderer({ pieceInstance }: Readonly): JSX.Element { diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/GraphicsThumbnailRenderer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/GraphicsThumbnailRenderer.tsx similarity index 98% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/GraphicsThumbnailRenderer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/GraphicsThumbnailRenderer.tsx index 19cf3eb408..5f2727ab93 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/GraphicsThumbnailRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/GraphicsThumbnailRenderer.tsx @@ -1,5 +1,4 @@ import { GraphicsContent, NoraContent } from '@sofie-automation/blueprints-integration' -import React from 'react' import { RundownUtils } from '../../../../lib/rundown' import { L3rdFloatingInspector } from '../../../FloatingInspectors/L3rdFloatingInspector' import { PieceMultistepChevron } from '../../../SegmentContainer/PieceMultistepChevron' diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/LocalThumbnailRenderer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/LocalThumbnailRenderer.tsx similarity index 96% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/LocalThumbnailRenderer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/LocalThumbnailRenderer.tsx index 23e7828ebe..5d024d2f7e 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/LocalThumbnailRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/LocalThumbnailRenderer.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { EvsContent } from '@sofie-automation/blueprints-integration' import { IProps } from './ThumbnailRendererFactory' import { getSizeClassForLabel } from '../../utils/getLabelClass' diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/SplitsThumbnailRenderer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/SplitsThumbnailRenderer.tsx similarity index 97% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/SplitsThumbnailRenderer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/SplitsThumbnailRenderer.tsx index dad24db64f..c9b7cc6ddf 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/SplitsThumbnailRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/SplitsThumbnailRenderer.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { SplitsContent } from '@sofie-automation/blueprints-integration' import { IProps } from './ThumbnailRendererFactory' import { RundownUtils } from '../../../../lib/rundown' diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/ThumbnailRendererFactory.ts b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/ThumbnailRendererFactory.ts similarity index 100% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/ThumbnailRendererFactory.ts rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/ThumbnailRendererFactory.ts diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/VTThumbnailRenderer.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/VTThumbnailRenderer.tsx similarity index 99% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/VTThumbnailRenderer.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/VTThumbnailRenderer.tsx index 6c3a1c1bc5..ca5f8b6141 100644 --- a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/VTThumbnailRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/Renderers/VTThumbnailRenderer.tsx @@ -1,4 +1,3 @@ -import React from 'react' import classNames from 'classnames' import { VTContent } from '@sofie-automation/blueprints-integration' import { VTFloatingInspector } from '../../../FloatingInspectors/VTFloatingInspector' diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnail.scss b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnail.scss similarity index 100% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnail.scss rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnail.scss diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnail.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnail.tsx similarity index 100% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnail.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnail.tsx diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnailInner.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnailInner.tsx similarity index 100% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnailInner.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnailInner.tsx diff --git a/meteor/client/ui/SegmentStoryboard/StoryboardPartTransitions.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartTransitions.tsx similarity index 100% rename from meteor/client/ui/SegmentStoryboard/StoryboardPartTransitions.tsx rename to packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartTransitions.tsx diff --git a/meteor/client/ui/SegmentStoryboard/utils/getLabelClass.ts b/packages/webui/src/client/ui/SegmentStoryboard/utils/getLabelClass.ts similarity index 100% rename from meteor/client/ui/SegmentStoryboard/utils/getLabelClass.ts rename to packages/webui/src/client/ui/SegmentStoryboard/utils/getLabelClass.ts diff --git a/meteor/client/ui/SegmentTimeline/BreakSegment.tsx b/packages/webui/src/client/ui/SegmentTimeline/BreakSegment.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/BreakSegment.tsx rename to packages/webui/src/client/ui/SegmentTimeline/BreakSegment.tsx diff --git a/packages/webui/src/client/ui/SegmentTimeline/Constants.tsx b/packages/webui/src/client/ui/SegmentTimeline/Constants.tsx new file mode 100644 index 0000000000..f3f02a95f3 --- /dev/null +++ b/packages/webui/src/client/ui/SegmentTimeline/Constants.tsx @@ -0,0 +1,16 @@ +import { Settings } from '../../../lib/Settings' + +export const MAGIC_TIME_SCALE_FACTOR = 0.03 + +export const SIMULATED_PLAYBACK_SOFT_MARGIN = 0 +export const SIMULATED_PLAYBACK_HARD_MARGIN = 3500 + +export const LIVE_LINE_TIME_PADDING = 150 +export const LIVELINE_HISTORY_SIZE = 100 +export const TIMELINE_RIGHT_PADDING = + // TODO: This is only temporary, for hands-on tweaking -- Jan Starzak, 2021-06-01 + parseInt(localStorage.getItem('EXP_timeline_right_padding')!) || LIVELINE_HISTORY_SIZE + LIVE_LINE_TIME_PADDING +export const FALLBACK_ZOOM_FACTOR = MAGIC_TIME_SCALE_FACTOR + +export const MINIMUM_ZOOM_FACTOR = // TODO: This is only temporary, for hands-on tweaking -- Jan Starzak, 2021-06-01 + parseInt(localStorage.getItem('EXP_timeline_min_time_scale')!) || MAGIC_TIME_SCALE_FACTOR * Settings.defaultTimeScale diff --git a/meteor/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx similarity index 99% rename from meteor/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx index 1e915dc106..9abfeed783 100644 --- a/meteor/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Parts/FlattenedSourceLayers.tsx @@ -1,4 +1,3 @@ -import React from 'react' import * as _ from 'underscore' import { unprotectString } from '../../../../lib/lib' import { ISourceLayerUi } from '../SegmentTimelineContainer' diff --git a/meteor/client/ui/SegmentTimeline/Parts/InvalidPartCover.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/InvalidPartCover.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/Parts/InvalidPartCover.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Parts/InvalidPartCover.tsx diff --git a/meteor/client/ui/SegmentTimeline/Parts/OutputGroup.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/OutputGroup.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/Parts/OutputGroup.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Parts/OutputGroup.tsx diff --git a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx similarity index 99% rename from meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index a4678aca5a..5c3efadd75 100644 --- a/meteor/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -4,7 +4,7 @@ import { withTranslation, WithTranslation, TFunction } from 'react-i18next' import ClassNames from 'classnames' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' -import { SegmentUi, PartUi, IOutputLayerUi, PieceUi, LIVE_LINE_TIME_PADDING } from '../SegmentTimelineContainer' +import { SegmentUi, PartUi, IOutputLayerUi, PieceUi } from '../SegmentTimelineContainer' import { TimingDataResolution, TimingTickResolution, @@ -34,6 +34,7 @@ import { OutputGroup } from './OutputGroup' import { InvalidPartCover } from './InvalidPartCover' import { ISourceLayer } from '@sofie-automation/blueprints-integration' import { UIStudio } from '../../../../lib/api/studios' +import { LIVE_LINE_TIME_PADDING } from '../Constants' export const SegmentTimelineLineElementId = 'rundown__segment__line__' export const SegmentTimelinePartElementId = 'rundown__segment__part__' diff --git a/meteor/client/ui/SegmentTimeline/Parts/SourceLayer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/SourceLayer.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/Parts/SourceLayer.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Parts/SourceLayer.tsx diff --git a/meteor/client/ui/SegmentTimeline/Renderers/CustomLayerItemRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/CustomLayerItemRenderer.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/Renderers/CustomLayerItemRenderer.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Renderers/CustomLayerItemRenderer.tsx diff --git a/meteor/client/ui/SegmentTimeline/Renderers/DefaultLayerItemRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/DefaultLayerItemRenderer.tsx similarity index 98% rename from meteor/client/ui/SegmentTimeline/Renderers/DefaultLayerItemRenderer.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Renderers/DefaultLayerItemRenderer.tsx index 4b27975ead..2e05836de1 100644 --- a/meteor/client/ui/SegmentTimeline/Renderers/DefaultLayerItemRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Renderers/DefaultLayerItemRenderer.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import ClassNames from 'classnames' import { getElementWidth } from '../../../utils/dimensions' diff --git a/meteor/client/ui/SegmentTimeline/Renderers/L3rdSourceRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/L3rdSourceRenderer.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/Renderers/L3rdSourceRenderer.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Renderers/L3rdSourceRenderer.tsx diff --git a/meteor/client/ui/SegmentTimeline/Renderers/LocalLayerItemRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/LocalLayerItemRenderer.tsx similarity index 98% rename from meteor/client/ui/SegmentTimeline/Renderers/LocalLayerItemRenderer.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Renderers/LocalLayerItemRenderer.tsx index b6d6445006..9a30b69280 100644 --- a/meteor/client/ui/SegmentTimeline/Renderers/LocalLayerItemRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Renderers/LocalLayerItemRenderer.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { getElementWidth } from '../../../utils/dimensions' import { EvsContent } from '@sofie-automation/blueprints-integration' diff --git a/meteor/client/ui/SegmentTimeline/Renderers/MicSourceRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/MicSourceRenderer.tsx similarity index 99% rename from meteor/client/ui/SegmentTimeline/Renderers/MicSourceRenderer.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Renderers/MicSourceRenderer.tsx index 0558047945..8589dfef38 100644 --- a/meteor/client/ui/SegmentTimeline/Renderers/MicSourceRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Renderers/MicSourceRenderer.tsx @@ -1,5 +1,3 @@ -import * as React from 'react' - import ClassNames from 'classnames' import { ScriptContent } from '@sofie-automation/blueprints-integration' import { CustomLayerItemRenderer, ICustomLayerItemProps } from './CustomLayerItemRenderer' diff --git a/meteor/client/ui/SegmentTimeline/Renderers/STKSourceRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/STKSourceRenderer.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/Renderers/STKSourceRenderer.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Renderers/STKSourceRenderer.tsx diff --git a/meteor/client/ui/SegmentTimeline/Renderers/SplitsSourceRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/SplitsSourceRenderer.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/Renderers/SplitsSourceRenderer.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Renderers/SplitsSourceRenderer.tsx diff --git a/meteor/client/ui/SegmentTimeline/Renderers/TransitionSourceRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/TransitionSourceRenderer.tsx similarity index 98% rename from meteor/client/ui/SegmentTimeline/Renderers/TransitionSourceRenderer.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Renderers/TransitionSourceRenderer.tsx index a5cb8ec5b1..1a25c25b74 100644 --- a/meteor/client/ui/SegmentTimeline/Renderers/TransitionSourceRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Renderers/TransitionSourceRenderer.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react' +import { useRef } from 'react' import { getElementWidth } from '../../../utils/dimensions' import { TransitionContent } from '@sofie-automation/blueprints-integration' diff --git a/meteor/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx rename to packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx diff --git a/meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx b/packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/SegmentContextMenu.tsx rename to packages/webui/src/client/ui/SegmentTimeline/SegmentContextMenu.tsx diff --git a/meteor/client/ui/SegmentTimeline/SegmentNextPreview.tsx b/packages/webui/src/client/ui/SegmentTimeline/SegmentNextPreview.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/SegmentNextPreview.tsx rename to packages/webui/src/client/ui/SegmentTimeline/SegmentNextPreview.tsx diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimeline.scss b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.scss similarity index 98% rename from meteor/client/ui/SegmentTimeline/SegmentTimeline.scss rename to packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.scss index c778af78d8..7fb2faa8c5 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimeline.scss +++ b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.scss @@ -1,7 +1,7 @@ $timeline-layer-height: 1em; .segment-timeline { - display: block; + // display: block; width: 100%; max-width: 100%; @@ -115,7 +115,7 @@ $timeline-layer-height: 1em; height: 100%; &.is-live { - will-change: transform + will-change: transform; } } diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx similarity index 99% rename from meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx rename to packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx index 7f69ff10d2..704ccce932 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimeline.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx @@ -129,7 +129,7 @@ const SegmentTimelineZoom = class SegmentTimelineZoom extends React.Component< durations: PropTypes.object.isRequired, } - context: + declare context: | { durations: RundownTimingContext } diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx similarity index 96% rename from meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx rename to packages/webui/src/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx index 5833deb330..620db9e3c7 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimelineContainer.tsx @@ -5,13 +5,11 @@ import { SegmentTimeline, SegmentTimelineClass } from './SegmentTimeline' import { computeSegmentDisplayDuration, RundownTiming, TimingEvent } from '../RundownView/RundownTiming/RundownTiming' import { UIStateStorage } from '../../lib/UIStateStorage' import { PartExtended } from '../../lib/RundownResolver' -import { MAGIC_TIME_SCALE_FACTOR } from '../RundownView' import { SpeechSynthesiser } from '../../lib/speechSynthesis' import { getElementWidth } from '../../utils/dimensions' import { isMaintainingFocus, scrollToSegment, getHeaderHeight } from '../../lib/viewPort' import { equivalentArrays, unprotectString } from '../../../lib/lib' import { Settings } from '../../../lib/Settings' -import { Meteor } from 'meteor/meteor' import RundownViewEventBus, { RundownViewEvents, GoToPartEvent, @@ -33,26 +31,22 @@ import { catchError, useDebounce } from '../../lib/lib' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { logger } from '../../../lib/logging' +import { + FALLBACK_ZOOM_FACTOR, + LIVELINE_HISTORY_SIZE, + MINIMUM_ZOOM_FACTOR, + SIMULATED_PLAYBACK_HARD_MARGIN, + TIMELINE_RIGHT_PADDING, +} from './Constants' // Kept for backwards compatibility -export { SegmentUi, PartUi, PieceUi, ISourceLayerUi, IOutputLayerUi } from '../SegmentContainer/withResolvedSegment' - -export const SIMULATED_PLAYBACK_SOFT_MARGIN = 0 -export const SIMULATED_PLAYBACK_HARD_MARGIN = 3500 - -export const LIVE_LINE_TIME_PADDING = 150 -export const LIVELINE_HISTORY_SIZE = 100 -export const TIMELINE_RIGHT_PADDING = - // TODO: This is only temporary, for hands-on tweaking -- Jan Starzak, 2021-06-01 - parseInt(localStorage.getItem('EXP_timeline_right_padding')!) || LIVELINE_HISTORY_SIZE + LIVE_LINE_TIME_PADDING -const FALLBACK_ZOOM_FACTOR = MAGIC_TIME_SCALE_FACTOR -export let MINIMUM_ZOOM_FACTOR = FALLBACK_ZOOM_FACTOR - -Meteor.startup(() => { - MINIMUM_ZOOM_FACTOR = // TODO: This is only temporary, for hands-on tweaking -- Jan Starzak, 2021-06-01 - parseInt(localStorage.getItem('EXP_timeline_min_time_scale')!) || - MAGIC_TIME_SCALE_FACTOR * Settings.defaultTimeScale -}) +export type { + SegmentUi, + PartUi, + PieceUi, + ISourceLayerUi, + IOutputLayerUi, +} from '../SegmentContainer/withResolvedSegment' interface IState { scrollLeft: number @@ -159,7 +153,7 @@ const SegmentTimelineContainerContent = withResolvedSegment( nextPartOffset = 0 // Setup by React.Component constructor - context!: { + declare context: { durations: RundownTimingContext syncedDurations: RundownTimingContext } diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineDebugMode.ts b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimelineDebugMode.ts similarity index 100% rename from meteor/client/ui/SegmentTimeline/SegmentTimelineDebugMode.ts rename to packages/webui/src/client/ui/SegmentTimeline/SegmentTimelineDebugMode.ts diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineZoomButtons.tsx b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimelineZoomButtons.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/SegmentTimelineZoomButtons.tsx rename to packages/webui/src/client/ui/SegmentTimeline/SegmentTimelineZoomButtons.tsx diff --git a/meteor/client/ui/SegmentTimeline/SegmentTimelineZoomControls.tsx b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimelineZoomControls.tsx similarity index 98% rename from meteor/client/ui/SegmentTimeline/SegmentTimelineZoomControls.tsx rename to packages/webui/src/client/ui/SegmentTimeline/SegmentTimelineZoomControls.tsx index c94fafad86..7f484dac78 100644 --- a/meteor/client/ui/SegmentTimeline/SegmentTimelineZoomControls.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimelineZoomControls.tsx @@ -3,7 +3,7 @@ import { getElementWidth } from '../../utils/dimensions' import { getElementDocumentOffset } from '../../utils/positions' import { onElementResize, offElementResize } from '../../lib/resizeObserver' import { LeftArrow, RightArrow } from '../../lib/ui/icons/segment' -import { LIVELINE_HISTORY_SIZE } from './SegmentTimelineContainer' +import { LIVELINE_HISTORY_SIZE } from './Constants' interface IPropsHeader { scrollLeft: number diff --git a/meteor/client/ui/SegmentTimeline/SmallParts/SegmentTimelinePartHoverPreview.tsx b/packages/webui/src/client/ui/SegmentTimeline/SmallParts/SegmentTimelinePartHoverPreview.tsx similarity index 98% rename from meteor/client/ui/SegmentTimeline/SmallParts/SegmentTimelinePartHoverPreview.tsx rename to packages/webui/src/client/ui/SegmentTimeline/SmallParts/SegmentTimelinePartHoverPreview.tsx index c229587862..74b6aa2836 100644 --- a/meteor/client/ui/SegmentTimeline/SmallParts/SegmentTimelinePartHoverPreview.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SmallParts/SegmentTimelinePartHoverPreview.tsx @@ -1,4 +1,4 @@ -import React, { useState, useLayoutEffect } from 'react' +import { useState, useLayoutEffect } from 'react' import { useTranslation } from 'react-i18next' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { unprotectString } from '../../../../lib/lib' diff --git a/meteor/client/ui/SegmentTimeline/SmallParts/SegmentTimelineSmallPartFlag.tsx b/packages/webui/src/client/ui/SegmentTimeline/SmallParts/SegmentTimelineSmallPartFlag.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/SmallParts/SegmentTimelineSmallPartFlag.tsx rename to packages/webui/src/client/ui/SegmentTimeline/SmallParts/SegmentTimelineSmallPartFlag.tsx diff --git a/meteor/client/ui/SegmentTimeline/SmallParts/SegmentTimelineSmallPartFlagIcon.tsx b/packages/webui/src/client/ui/SegmentTimeline/SmallParts/SegmentTimelineSmallPartFlagIcon.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/SmallParts/SegmentTimelineSmallPartFlagIcon.tsx rename to packages/webui/src/client/ui/SegmentTimeline/SmallParts/SegmentTimelineSmallPartFlagIcon.tsx diff --git a/meteor/client/ui/SegmentTimeline/SourceLayerItem.tsx b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/SourceLayerItem.tsx rename to packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx diff --git a/meteor/client/ui/SegmentTimeline/SourceLayerItemContainer.tsx b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItemContainer.tsx similarity index 94% rename from meteor/client/ui/SegmentTimeline/SourceLayerItemContainer.tsx rename to packages/webui/src/client/ui/SegmentTimeline/SourceLayerItemContainer.tsx index 4030b906bd..4864f065fc 100644 --- a/meteor/client/ui/SegmentTimeline/SourceLayerItemContainer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItemContainer.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { ISourceLayerItemProps, SourceLayerItem } from './SourceLayerItem' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { withMediaObjectStatus } from './withMediaObjectStatus' diff --git a/meteor/client/ui/SegmentTimeline/TimelineGrid.tsx b/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx similarity index 99% rename from meteor/client/ui/SegmentTimeline/TimelineGrid.tsx rename to packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx index 28e98bad07..90f68f989e 100644 --- a/meteor/client/ui/SegmentTimeline/TimelineGrid.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx @@ -52,7 +52,7 @@ export class TimelineGrid extends React.Component { durations: PropTypes.object.isRequired, } - context: + declare context: | { durations: RundownTimingContext } diff --git a/meteor/client/ui/SegmentTimeline/Zoom_In_MouseOut.json b/packages/webui/src/client/ui/SegmentTimeline/Zoom_In_MouseOut.json similarity index 100% rename from meteor/client/ui/SegmentTimeline/Zoom_In_MouseOut.json rename to packages/webui/src/client/ui/SegmentTimeline/Zoom_In_MouseOut.json diff --git a/meteor/client/ui/SegmentTimeline/Zoom_In_MouseOver.json b/packages/webui/src/client/ui/SegmentTimeline/Zoom_In_MouseOver.json similarity index 100% rename from meteor/client/ui/SegmentTimeline/Zoom_In_MouseOver.json rename to packages/webui/src/client/ui/SegmentTimeline/Zoom_In_MouseOver.json diff --git a/meteor/client/ui/SegmentTimeline/Zoom_Normal_MouseOut.json b/packages/webui/src/client/ui/SegmentTimeline/Zoom_Normal_MouseOut.json similarity index 100% rename from meteor/client/ui/SegmentTimeline/Zoom_Normal_MouseOut.json rename to packages/webui/src/client/ui/SegmentTimeline/Zoom_Normal_MouseOut.json diff --git a/meteor/client/ui/SegmentTimeline/Zoom_Normal_MouseOver.json b/packages/webui/src/client/ui/SegmentTimeline/Zoom_Normal_MouseOver.json similarity index 100% rename from meteor/client/ui/SegmentTimeline/Zoom_Normal_MouseOver.json rename to packages/webui/src/client/ui/SegmentTimeline/Zoom_Normal_MouseOver.json diff --git a/meteor/client/ui/SegmentTimeline/Zoom_Out_MouseOut.json b/packages/webui/src/client/ui/SegmentTimeline/Zoom_Out_MouseOut.json similarity index 100% rename from meteor/client/ui/SegmentTimeline/Zoom_Out_MouseOut.json rename to packages/webui/src/client/ui/SegmentTimeline/Zoom_Out_MouseOut.json diff --git a/meteor/client/ui/SegmentTimeline/Zoom_Out_MouseOver.json b/packages/webui/src/client/ui/SegmentTimeline/Zoom_Out_MouseOver.json similarity index 100% rename from meteor/client/ui/SegmentTimeline/Zoom_Out_MouseOver.json rename to packages/webui/src/client/ui/SegmentTimeline/Zoom_Out_MouseOver.json diff --git a/meteor/client/ui/SegmentTimeline/withMediaObjectStatus.tsx b/packages/webui/src/client/ui/SegmentTimeline/withMediaObjectStatus.tsx similarity index 100% rename from meteor/client/ui/SegmentTimeline/withMediaObjectStatus.tsx rename to packages/webui/src/client/ui/SegmentTimeline/withMediaObjectStatus.tsx diff --git a/meteor/client/ui/Settings.tsx b/packages/webui/src/client/ui/Settings.tsx similarity index 98% rename from meteor/client/ui/Settings.tsx rename to packages/webui/src/client/ui/Settings.tsx index 81bd7c0e37..dff20ea6d7 100644 --- a/meteor/client/ui/Settings.tsx +++ b/packages/webui/src/client/ui/Settings.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import { useEffect } from 'react' import { useSubscription, useTracker } from '../lib/ReactMeteorData/react-meteor-data' import { Route, Switch, Redirect, useHistory } from 'react-router-dom' import { ErrorBoundary } from '../lib/ErrorBoundary' diff --git a/meteor/client/ui/Settings/BlueprintConfigSchema/CategoryEntry.tsx b/packages/webui/src/client/ui/Settings/BlueprintConfigSchema/CategoryEntry.tsx similarity index 98% rename from meteor/client/ui/Settings/BlueprintConfigSchema/CategoryEntry.tsx rename to packages/webui/src/client/ui/Settings/BlueprintConfigSchema/CategoryEntry.tsx index d16de6ff07..1a48db871a 100644 --- a/meteor/client/ui/Settings/BlueprintConfigSchema/CategoryEntry.tsx +++ b/packages/webui/src/client/ui/Settings/BlueprintConfigSchema/CategoryEntry.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react' +import { useCallback } from 'react' import ClassNames from 'classnames' import { useTranslation } from 'react-i18next' import { SchemaFormWithOverrides } from '../../../lib/forms/SchemaFormWithOverrides' diff --git a/meteor/client/ui/Settings/BlueprintConfigSchema/index.tsx b/packages/webui/src/client/ui/Settings/BlueprintConfigSchema/index.tsx similarity index 99% rename from meteor/client/ui/Settings/BlueprintConfigSchema/index.tsx rename to packages/webui/src/client/ui/Settings/BlueprintConfigSchema/index.tsx index 8328300b7b..09f25953db 100644 --- a/meteor/client/ui/Settings/BlueprintConfigSchema/index.tsx +++ b/packages/webui/src/client/ui/Settings/BlueprintConfigSchema/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { MappingExt, MappingsExt } from '@sofie-automation/corelib/dist/dataModel/Studio' import { IBlueprintConfig, ISourceLayer, SchemaFormUIField } from '@sofie-automation/blueprints-integration' import { groupByToMapFunc, literal } from '../../../../lib/lib' diff --git a/meteor/client/ui/Settings/BlueprintSettings.tsx b/packages/webui/src/client/ui/Settings/BlueprintSettings.tsx similarity index 100% rename from meteor/client/ui/Settings/BlueprintSettings.tsx rename to packages/webui/src/client/ui/Settings/BlueprintSettings.tsx diff --git a/meteor/client/ui/Settings/DevicePackageManagerSettings.tsx b/packages/webui/src/client/ui/Settings/DevicePackageManagerSettings.tsx similarity index 100% rename from meteor/client/ui/Settings/DevicePackageManagerSettings.tsx rename to packages/webui/src/client/ui/Settings/DevicePackageManagerSettings.tsx diff --git a/meteor/client/ui/Settings/DeviceSettings.tsx b/packages/webui/src/client/ui/Settings/DeviceSettings.tsx similarity index 100% rename from meteor/client/ui/Settings/DeviceSettings.tsx rename to packages/webui/src/client/ui/Settings/DeviceSettings.tsx diff --git a/meteor/client/ui/Settings/Forms.scss b/packages/webui/src/client/ui/Settings/Forms.scss similarity index 100% rename from meteor/client/ui/Settings/Forms.scss rename to packages/webui/src/client/ui/Settings/Forms.scss diff --git a/meteor/client/ui/Settings/Migration.tsx b/packages/webui/src/client/ui/Settings/Migration.tsx similarity index 100% rename from meteor/client/ui/Settings/Migration.tsx rename to packages/webui/src/client/ui/Settings/Migration.tsx diff --git a/meteor/client/ui/Settings/RundownLayoutEditor.tsx b/packages/webui/src/client/ui/Settings/RundownLayoutEditor.tsx similarity index 100% rename from meteor/client/ui/Settings/RundownLayoutEditor.tsx rename to packages/webui/src/client/ui/Settings/RundownLayoutEditor.tsx diff --git a/meteor/client/ui/Settings/SettingsMenu.tsx b/packages/webui/src/client/ui/Settings/SettingsMenu.tsx similarity index 100% rename from meteor/client/ui/Settings/SettingsMenu.tsx rename to packages/webui/src/client/ui/Settings/SettingsMenu.tsx diff --git a/meteor/client/ui/Settings/ShowStyle/BlueprintConfiguration/SelectBlueprint.tsx b/packages/webui/src/client/ui/Settings/ShowStyle/BlueprintConfiguration/SelectBlueprint.tsx similarity index 98% rename from meteor/client/ui/Settings/ShowStyle/BlueprintConfiguration/SelectBlueprint.tsx rename to packages/webui/src/client/ui/Settings/ShowStyle/BlueprintConfiguration/SelectBlueprint.tsx index 3178c0c4af..333f1e3ea0 100644 --- a/meteor/client/ui/Settings/ShowStyle/BlueprintConfiguration/SelectBlueprint.tsx +++ b/packages/webui/src/client/ui/Settings/ShowStyle/BlueprintConfiguration/SelectBlueprint.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import { useMemo } from 'react' import { useTracker } from '../../../../lib/ReactMeteorData/react-meteor-data' import { BlueprintManifestType } from '@sofie-automation/blueprints-integration' import { Blueprints, ShowStyleBases } from '../../../../collections' diff --git a/meteor/client/ui/Settings/ShowStyle/BlueprintConfiguration/SelectConfigPreset.tsx b/packages/webui/src/client/ui/Settings/ShowStyle/BlueprintConfiguration/SelectConfigPreset.tsx similarity index 98% rename from meteor/client/ui/Settings/ShowStyle/BlueprintConfiguration/SelectConfigPreset.tsx rename to packages/webui/src/client/ui/Settings/ShowStyle/BlueprintConfiguration/SelectConfigPreset.tsx index c59ed65287..c8763f18cc 100644 --- a/meteor/client/ui/Settings/ShowStyle/BlueprintConfiguration/SelectConfigPreset.tsx +++ b/packages/webui/src/client/ui/Settings/ShowStyle/BlueprintConfiguration/SelectConfigPreset.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import { useMemo } from 'react' import { useTracker } from '../../../../lib/ReactMeteorData/react-meteor-data' import { BlueprintManifestType, IShowStyleConfigPreset } from '@sofie-automation/blueprints-integration' import { Blueprints, ShowStyleBases } from '../../../../collections' diff --git a/meteor/client/ui/Settings/ShowStyle/BlueprintConfiguration/index.tsx b/packages/webui/src/client/ui/Settings/ShowStyle/BlueprintConfiguration/index.tsx similarity index 98% rename from meteor/client/ui/Settings/ShowStyle/BlueprintConfiguration/index.tsx rename to packages/webui/src/client/ui/Settings/ShowStyle/BlueprintConfiguration/index.tsx index 67524a3947..c8bbea4bef 100644 --- a/meteor/client/ui/Settings/ShowStyle/BlueprintConfiguration/index.tsx +++ b/packages/webui/src/client/ui/Settings/ShowStyle/BlueprintConfiguration/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { JSONSchema } from '@sofie-automation/blueprints-integration' import { BlueprintConfigSchemaSettings } from '../../BlueprintConfigSchema' import { SomeObjectOverrideOp } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' diff --git a/meteor/client/ui/Settings/ShowStyle/DragDropTypesShowStyle.ts b/packages/webui/src/client/ui/Settings/ShowStyle/DragDropTypesShowStyle.ts similarity index 100% rename from meteor/client/ui/Settings/ShowStyle/DragDropTypesShowStyle.ts rename to packages/webui/src/client/ui/Settings/ShowStyle/DragDropTypesShowStyle.ts diff --git a/meteor/client/ui/Settings/ShowStyle/Generic.tsx b/packages/webui/src/client/ui/Settings/ShowStyle/Generic.tsx similarity index 98% rename from meteor/client/ui/Settings/ShowStyle/Generic.tsx rename to packages/webui/src/client/ui/Settings/ShowStyle/Generic.tsx index 014f74d4a0..a18a0c92b8 100644 --- a/meteor/client/ui/Settings/ShowStyle/Generic.tsx +++ b/packages/webui/src/client/ui/Settings/ShowStyle/Generic.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons' import { useTranslation } from 'react-i18next' diff --git a/meteor/client/ui/Settings/ShowStyle/HotkeyLegend.tsx b/packages/webui/src/client/ui/Settings/ShowStyle/HotkeyLegend.tsx similarity index 99% rename from meteor/client/ui/Settings/ShowStyle/HotkeyLegend.tsx rename to packages/webui/src/client/ui/Settings/ShowStyle/HotkeyLegend.tsx index 2bdf30fc6b..783e72d184 100644 --- a/meteor/client/ui/Settings/ShowStyle/HotkeyLegend.tsx +++ b/packages/webui/src/client/ui/Settings/ShowStyle/HotkeyLegend.tsx @@ -5,7 +5,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { SourceLayerType } from '@sofie-automation/blueprints-integration' import { HotkeyDefinition } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { literal, getRandomString } from '@sofie-automation/corelib/dist/lib' -import { Random } from 'meteor/random' import { withTranslation } from 'react-i18next' import { DBShowStyleBase } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' import { AHKBaseHeader, AHKModifierMap, AHKKeyboardMap, useAHKComboTemplate } from '../../../../lib/tv2/AHKkeyboardMap' @@ -190,7 +189,7 @@ export const HotkeyLegendSettings = withTranslation()( const conformedConfig: Array = [] _.forEach(newConfig, (entry) => { const newEntry: HotkeyDefinition = { - _id: Random.id(), + _id: getRandomString(), key: entry.key || '', label: entry.label || '', sourceLayerType: entry.sourceLayerType, diff --git a/meteor/client/ui/Settings/ShowStyle/OutputLayer.tsx b/packages/webui/src/client/ui/Settings/ShowStyle/OutputLayer.tsx similarity index 100% rename from meteor/client/ui/Settings/ShowStyle/OutputLayer.tsx rename to packages/webui/src/client/ui/Settings/ShowStyle/OutputLayer.tsx diff --git a/meteor/client/ui/Settings/ShowStyle/SourceLayer.tsx b/packages/webui/src/client/ui/Settings/ShowStyle/SourceLayer.tsx similarity index 100% rename from meteor/client/ui/Settings/ShowStyle/SourceLayer.tsx rename to packages/webui/src/client/ui/Settings/ShowStyle/SourceLayer.tsx diff --git a/meteor/client/ui/Settings/ShowStyle/VariantListItem.tsx b/packages/webui/src/client/ui/Settings/ShowStyle/VariantListItem.tsx similarity index 100% rename from meteor/client/ui/Settings/ShowStyle/VariantListItem.tsx rename to packages/webui/src/client/ui/Settings/ShowStyle/VariantListItem.tsx diff --git a/meteor/client/ui/Settings/ShowStyle/VariantSettings.tsx b/packages/webui/src/client/ui/Settings/ShowStyle/VariantSettings.tsx similarity index 100% rename from meteor/client/ui/Settings/ShowStyle/VariantSettings.tsx rename to packages/webui/src/client/ui/Settings/ShowStyle/VariantSettings.tsx diff --git a/meteor/client/ui/Settings/ShowStyleBaseSettings.tsx b/packages/webui/src/client/ui/Settings/ShowStyleBaseSettings.tsx similarity index 100% rename from meteor/client/ui/Settings/ShowStyleBaseSettings.tsx rename to packages/webui/src/client/ui/Settings/ShowStyleBaseSettings.tsx diff --git a/meteor/client/ui/Settings/SnapshotsView.tsx b/packages/webui/src/client/ui/Settings/SnapshotsView.tsx similarity index 100% rename from meteor/client/ui/Settings/SnapshotsView.tsx rename to packages/webui/src/client/ui/Settings/SnapshotsView.tsx diff --git a/meteor/client/ui/Settings/Studio/Baseline.tsx b/packages/webui/src/client/ui/Settings/Studio/Baseline.tsx similarity index 97% rename from meteor/client/ui/Settings/Studio/Baseline.tsx rename to packages/webui/src/client/ui/Settings/Studio/Baseline.tsx index 8dfc724e13..964d93e3ea 100644 --- a/meteor/client/ui/Settings/Studio/Baseline.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Baseline.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { Meteor } from 'meteor/meteor' diff --git a/meteor/client/ui/Settings/Studio/BlueprintConfiguration/SelectBlueprint.tsx b/packages/webui/src/client/ui/Settings/Studio/BlueprintConfiguration/SelectBlueprint.tsx similarity index 98% rename from meteor/client/ui/Settings/Studio/BlueprintConfiguration/SelectBlueprint.tsx rename to packages/webui/src/client/ui/Settings/Studio/BlueprintConfiguration/SelectBlueprint.tsx index cbca6052cd..4298b48e93 100644 --- a/meteor/client/ui/Settings/Studio/BlueprintConfiguration/SelectBlueprint.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/BlueprintConfiguration/SelectBlueprint.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import { useMemo } from 'react' import { useTracker } from '../../../../lib/ReactMeteorData/react-meteor-data' import { BlueprintManifestType } from '@sofie-automation/blueprints-integration' import { Blueprints, Studios } from '../../../../collections' diff --git a/meteor/client/ui/Settings/Studio/BlueprintConfiguration/SelectConfigPreset.tsx b/packages/webui/src/client/ui/Settings/Studio/BlueprintConfiguration/SelectConfigPreset.tsx similarity index 98% rename from meteor/client/ui/Settings/Studio/BlueprintConfiguration/SelectConfigPreset.tsx rename to packages/webui/src/client/ui/Settings/Studio/BlueprintConfiguration/SelectConfigPreset.tsx index 915b6d8885..e2ad1bf169 100644 --- a/meteor/client/ui/Settings/Studio/BlueprintConfiguration/SelectConfigPreset.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/BlueprintConfiguration/SelectConfigPreset.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import { useMemo } from 'react' import { Studios } from '../../../../collections' import { useTranslation } from 'react-i18next' import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons' diff --git a/meteor/client/ui/Settings/Studio/BlueprintConfiguration/index.tsx b/packages/webui/src/client/ui/Settings/Studio/BlueprintConfiguration/index.tsx similarity index 98% rename from meteor/client/ui/Settings/Studio/BlueprintConfiguration/index.tsx rename to packages/webui/src/client/ui/Settings/Studio/BlueprintConfiguration/index.tsx index 8d4824660a..45d2452c75 100644 --- a/meteor/client/ui/Settings/Studio/BlueprintConfiguration/index.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/BlueprintConfiguration/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { useSubscription, useTracker } from '../../../../lib/ReactMeteorData/react-meteor-data' import { BlueprintManifestType } from '@sofie-automation/blueprints-integration' import { BlueprintConfigSchemaSettings } from '../../BlueprintConfigSchema' diff --git a/meteor/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx similarity index 100% rename from meteor/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx rename to packages/webui/src/client/ui/Settings/Studio/Devices/GenericSubDevices.tsx diff --git a/meteor/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx similarity index 98% rename from meteor/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx rename to packages/webui/src/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx index 57b043887b..ae746424d1 100644 --- a/meteor/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Devices/IngestSubDevices.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { Studios } from '../../../../collections' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { useTracker } from '../../../../lib/ReactMeteorData/ReactMeteorData' diff --git a/meteor/client/ui/Settings/Studio/Devices/InputSubDevices.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/InputSubDevices.tsx similarity index 98% rename from meteor/client/ui/Settings/Studio/Devices/InputSubDevices.tsx rename to packages/webui/src/client/ui/Settings/Studio/Devices/InputSubDevices.tsx index 97cfb45da2..784225c564 100644 --- a/meteor/client/ui/Settings/Studio/Devices/InputSubDevices.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Devices/InputSubDevices.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { Studios } from '../../../../collections' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { useTracker } from '../../../../lib/ReactMeteorData/ReactMeteorData' diff --git a/meteor/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx similarity index 98% rename from meteor/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx rename to packages/webui/src/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx index 9626eb27c7..32c030c53e 100644 --- a/meteor/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Devices/PlayoutSubDevices.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { Studios } from '../../../../collections' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { useTracker } from '../../../../lib/ReactMeteorData/ReactMeteorData' diff --git a/meteor/client/ui/Settings/Studio/Devices/SelectDevices.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/SelectDevices.tsx similarity index 98% rename from meteor/client/ui/Settings/Studio/Devices/SelectDevices.tsx rename to packages/webui/src/client/ui/Settings/Studio/Devices/SelectDevices.tsx index ba4f73cc4d..ec37da3c1c 100644 --- a/meteor/client/ui/Settings/Studio/Devices/SelectDevices.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Devices/SelectDevices.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react' +import { useCallback, useState } from 'react' import Tooltip from 'rc-tooltip' import { doModalDialog } from '../../../../lib/ModalDialog' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' diff --git a/meteor/client/ui/Settings/Studio/Devices/index.tsx b/packages/webui/src/client/ui/Settings/Studio/Devices/index.tsx similarity index 97% rename from meteor/client/ui/Settings/Studio/Devices/index.tsx rename to packages/webui/src/client/ui/Settings/Studio/Devices/index.tsx index 087319758f..b88c4fa2e6 100644 --- a/meteor/client/ui/Settings/Studio/Devices/index.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Devices/index.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { PeripheralDevices } from '../../../../collections' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { useTracker } from '../../../../lib/ReactMeteorData/ReactMeteorData' diff --git a/meteor/client/ui/Settings/Studio/Generic.tsx b/packages/webui/src/client/ui/Settings/Studio/Generic.tsx similarity index 100% rename from meteor/client/ui/Settings/Studio/Generic.tsx rename to packages/webui/src/client/ui/Settings/Studio/Generic.tsx diff --git a/meteor/client/ui/Settings/Studio/Mappings.tsx b/packages/webui/src/client/ui/Settings/Studio/Mappings.tsx similarity index 100% rename from meteor/client/ui/Settings/Studio/Mappings.tsx rename to packages/webui/src/client/ui/Settings/Studio/Mappings.tsx diff --git a/meteor/client/ui/Settings/Studio/PackageManager.tsx b/packages/webui/src/client/ui/Settings/Studio/PackageManager.tsx similarity index 100% rename from meteor/client/ui/Settings/Studio/PackageManager.tsx rename to packages/webui/src/client/ui/Settings/Studio/PackageManager.tsx diff --git a/meteor/client/ui/Settings/Studio/Routings.tsx b/packages/webui/src/client/ui/Settings/Studio/Routings.tsx similarity index 100% rename from meteor/client/ui/Settings/Studio/Routings.tsx rename to packages/webui/src/client/ui/Settings/Studio/Routings.tsx diff --git a/meteor/client/ui/Settings/StudioSettings.tsx b/packages/webui/src/client/ui/Settings/StudioSettings.tsx similarity index 99% rename from meteor/client/ui/Settings/StudioSettings.tsx rename to packages/webui/src/client/ui/Settings/StudioSettings.tsx index 3e0ae25bd0..1adb7c17d9 100644 --- a/meteor/client/ui/Settings/StudioSettings.tsx +++ b/packages/webui/src/client/ui/Settings/StudioSettings.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import { useMemo } from 'react' import { useTracker } from '../../lib/ReactMeteorData/react-meteor-data' import { Spinner } from '../../lib/Spinner' import { PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' diff --git a/meteor/client/ui/Settings/SystemManagement.tsx b/packages/webui/src/client/ui/Settings/SystemManagement.tsx similarity index 100% rename from meteor/client/ui/Settings/SystemManagement.tsx rename to packages/webui/src/client/ui/Settings/SystemManagement.tsx diff --git a/meteor/client/ui/Settings/Upgrades/Components.tsx b/packages/webui/src/client/ui/Settings/Upgrades/Components.tsx similarity index 99% rename from meteor/client/ui/Settings/Upgrades/Components.tsx rename to packages/webui/src/client/ui/Settings/Upgrades/Components.tsx index 2a19966d05..4ba205d129 100644 --- a/meteor/client/ui/Settings/Upgrades/Components.tsx +++ b/packages/webui/src/client/ui/Settings/Upgrades/Components.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react' +import { useCallback } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faDatabase, faEye, faWarning } from '@fortawesome/free-solid-svg-icons' import { MeteorCall } from '../../../../lib/api/methods' diff --git a/meteor/client/ui/Settings/Upgrades/View.tsx b/packages/webui/src/client/ui/Settings/Upgrades/View.tsx similarity index 98% rename from meteor/client/ui/Settings/Upgrades/View.tsx rename to packages/webui/src/client/ui/Settings/Upgrades/View.tsx index e32ed40712..40143eaf81 100644 --- a/meteor/client/ui/Settings/Upgrades/View.tsx +++ b/packages/webui/src/client/ui/Settings/Upgrades/View.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { useTranslation } from 'react-i18next' import { Spinner } from '../../../lib/Spinner' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' diff --git a/meteor/client/ui/Settings/components/ConfigManifestOAuthFlow.tsx b/packages/webui/src/client/ui/Settings/components/ConfigManifestOAuthFlow.tsx similarity index 100% rename from meteor/client/ui/Settings/components/ConfigManifestOAuthFlow.tsx rename to packages/webui/src/client/ui/Settings/components/ConfigManifestOAuthFlow.tsx diff --git a/meteor/client/ui/Settings/components/FilterEditor.tsx b/packages/webui/src/client/ui/Settings/components/FilterEditor.tsx similarity index 100% rename from meteor/client/ui/Settings/components/FilterEditor.tsx rename to packages/webui/src/client/ui/Settings/components/FilterEditor.tsx diff --git a/meteor/client/ui/Settings/components/GenericDeviceSettingsComponent.tsx b/packages/webui/src/client/ui/Settings/components/GenericDeviceSettingsComponent.tsx similarity index 98% rename from meteor/client/ui/Settings/components/GenericDeviceSettingsComponent.tsx rename to packages/webui/src/client/ui/Settings/components/GenericDeviceSettingsComponent.tsx index a8e7ec78fd..d8b40e21a4 100644 --- a/meteor/client/ui/Settings/components/GenericDeviceSettingsComponent.tsx +++ b/packages/webui/src/client/ui/Settings/components/GenericDeviceSettingsComponent.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { PeripheralDevice, PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { DeviceItem } from '../../Status/SystemStatus' diff --git a/meteor/client/ui/Settings/components/rundownLayouts/RundownHeaderLayoutSettings.tsx b/packages/webui/src/client/ui/Settings/components/rundownLayouts/RundownHeaderLayoutSettings.tsx similarity index 100% rename from meteor/client/ui/Settings/components/rundownLayouts/RundownHeaderLayoutSettings.tsx rename to packages/webui/src/client/ui/Settings/components/rundownLayouts/RundownHeaderLayoutSettings.tsx diff --git a/meteor/client/ui/Settings/components/rundownLayouts/RundownViewLayoutSettings.tsx b/packages/webui/src/client/ui/Settings/components/rundownLayouts/RundownViewLayoutSettings.tsx similarity index 99% rename from meteor/client/ui/Settings/components/rundownLayouts/RundownViewLayoutSettings.tsx rename to packages/webui/src/client/ui/Settings/components/rundownLayouts/RundownViewLayoutSettings.tsx index 071f8ca5f7..0d53d8b67e 100644 --- a/meteor/client/ui/Settings/components/rundownLayouts/RundownViewLayoutSettings.tsx +++ b/packages/webui/src/client/ui/Settings/components/rundownLayouts/RundownViewLayoutSettings.tsx @@ -1,6 +1,6 @@ import { ISourceLayer } from '@sofie-automation/blueprints-integration' import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' -import React, { useMemo } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { RundownLayoutsAPI } from '../../../../../lib/api/rundownLayouts' import { RundownLayouts } from '../../../../collections' diff --git a/meteor/client/ui/Settings/components/rundownLayouts/ShelfLayoutSettings.tsx b/packages/webui/src/client/ui/Settings/components/rundownLayouts/ShelfLayoutSettings.tsx similarity index 100% rename from meteor/client/ui/Settings/components/rundownLayouts/ShelfLayoutSettings.tsx rename to packages/webui/src/client/ui/Settings/components/rundownLayouts/ShelfLayoutSettings.tsx diff --git a/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionEntry.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/TriggeredActionEntry.tsx similarity index 100% rename from meteor/client/ui/Settings/components/triggeredActions/TriggeredActionEntry.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/TriggeredActionEntry.tsx diff --git a/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.scss b/packages/webui/src/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.scss similarity index 100% rename from meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.scss rename to packages/webui/src/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.scss diff --git a/meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx similarity index 100% rename from meteor/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/TriggeredActionsEditor.tsx diff --git a/meteor/client/ui/Settings/components/triggeredActions/actionEditors/ActionEditor.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/ActionEditor.tsx similarity index 100% rename from meteor/client/ui/Settings/components/triggeredActions/actionEditors/ActionEditor.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/ActionEditor.tsx diff --git a/meteor/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/ActionSelector.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/ActionSelector.tsx similarity index 100% rename from meteor/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/ActionSelector.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/ActionSelector.tsx diff --git a/meteor/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/actionEditors/AdLibActionEditor.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/actionEditors/AdLibActionEditor.tsx similarity index 98% rename from meteor/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/actionEditors/AdLibActionEditor.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/actionEditors/AdLibActionEditor.tsx index d1f81d4c28..1abad9965b 100644 --- a/meteor/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/actionEditors/AdLibActionEditor.tsx +++ b/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/actionEditors/AdLibActionEditor.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import _ from 'underscore' import { useTranslation } from 'react-i18next' import { PlayoutActions, SomeAction } from '@sofie-automation/blueprints-integration' diff --git a/meteor/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/AdLibFilter.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/AdLibFilter.tsx similarity index 100% rename from meteor/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/AdLibFilter.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/AdLibFilter.tsx diff --git a/meteor/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/FilterEditor.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/FilterEditor.tsx similarity index 100% rename from meteor/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/FilterEditor.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/FilterEditor.tsx diff --git a/meteor/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/RundownPlaylistFilter.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/RundownPlaylistFilter.tsx similarity index 100% rename from meteor/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/RundownPlaylistFilter.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/RundownPlaylistFilter.tsx diff --git a/meteor/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/ViewFilter.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/ViewFilter.tsx similarity index 100% rename from meteor/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/ViewFilter.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/filterPreviews/ViewFilter.tsx diff --git a/meteor/client/ui/Settings/components/triggeredActions/triggerEditors/DeviceEditor.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/triggerEditors/DeviceEditor.tsx similarity index 98% rename from meteor/client/ui/Settings/components/triggeredActions/triggerEditors/DeviceEditor.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/triggerEditors/DeviceEditor.tsx index ee9b362488..8434ee2f09 100644 --- a/meteor/client/ui/Settings/components/triggeredActions/triggerEditors/DeviceEditor.tsx +++ b/packages/webui/src/client/ui/Settings/components/triggeredActions/triggerEditors/DeviceEditor.tsx @@ -1,7 +1,7 @@ import { IBlueprintDeviceTrigger } from '@sofie-automation/blueprints-integration' import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString' import classNames from 'classnames' -import React, { useMemo } from 'react' +import { useMemo } from 'react' import { MeteorPubSub } from '../../../../../../lib/api/pubsub' import { Studios } from '../../../../../collections' import { getCurrentTime } from '../../../../../../lib/lib' diff --git a/meteor/client/ui/Settings/components/triggeredActions/triggerEditors/DeviceTrigger.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/triggerEditors/DeviceTrigger.tsx similarity index 100% rename from meteor/client/ui/Settings/components/triggeredActions/triggerEditors/DeviceTrigger.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/triggerEditors/DeviceTrigger.tsx diff --git a/meteor/client/ui/Settings/components/triggeredActions/triggerEditors/HotkeyEditor.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/triggerEditors/HotkeyEditor.tsx similarity index 100% rename from meteor/client/ui/Settings/components/triggeredActions/triggerEditors/HotkeyEditor.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/triggerEditors/HotkeyEditor.tsx diff --git a/meteor/client/ui/Settings/components/triggeredActions/triggerEditors/HotkeyTrigger.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/triggerEditors/HotkeyTrigger.tsx similarity index 100% rename from meteor/client/ui/Settings/components/triggeredActions/triggerEditors/HotkeyTrigger.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/triggerEditors/HotkeyTrigger.tsx diff --git a/meteor/client/ui/Settings/components/triggeredActions/triggerEditors/TriggerEditor.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/triggerEditors/TriggerEditor.tsx similarity index 98% rename from meteor/client/ui/Settings/components/triggeredActions/triggerEditors/TriggerEditor.tsx rename to packages/webui/src/client/ui/Settings/components/triggeredActions/triggerEditors/TriggerEditor.tsx index 934c418456..017a222d0f 100644 --- a/meteor/client/ui/Settings/components/triggeredActions/triggerEditors/TriggerEditor.tsx +++ b/packages/webui/src/client/ui/Settings/components/triggeredActions/triggerEditors/TriggerEditor.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useState } from 'react' import { TFunction } from 'i18next' import { SomeBlueprintTrigger, TriggerType } from '@sofie-automation/blueprints-integration' import { DBBlueprintTrigger } from '../../../../../../lib/collections/TriggeredActions' diff --git a/meteor/client/ui/Settings/util/OverrideOpHelper.tsx b/packages/webui/src/client/ui/Settings/util/OverrideOpHelper.tsx similarity index 100% rename from meteor/client/ui/Settings/util/OverrideOpHelper.tsx rename to packages/webui/src/client/ui/Settings/util/OverrideOpHelper.tsx diff --git a/meteor/client/ui/Shelf/AdLibListItem.tsx b/packages/webui/src/client/ui/Shelf/AdLibListItem.tsx similarity index 100% rename from meteor/client/ui/Shelf/AdLibListItem.tsx rename to packages/webui/src/client/ui/Shelf/AdLibListItem.tsx diff --git a/meteor/client/ui/Shelf/AdLibListView.tsx b/packages/webui/src/client/ui/Shelf/AdLibListView.tsx similarity index 99% rename from meteor/client/ui/Shelf/AdLibListView.tsx rename to packages/webui/src/client/ui/Shelf/AdLibListView.tsx index 78bc745899..c04ab7a0c7 100644 --- a/meteor/client/ui/Shelf/AdLibListView.tsx +++ b/packages/webui/src/client/ui/Shelf/AdLibListView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import classNames from 'classnames' import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' import { RundownUtils } from '../../lib/rundown' diff --git a/meteor/client/ui/Shelf/AdLibPanel.tsx b/packages/webui/src/client/ui/Shelf/AdLibPanel.tsx similarity index 99% rename from meteor/client/ui/Shelf/AdLibPanel.tsx rename to packages/webui/src/client/ui/Shelf/AdLibPanel.tsx index 1491b5b3ca..24ed242da1 100644 --- a/meteor/client/ui/Shelf/AdLibPanel.tsx +++ b/packages/webui/src/client/ui/Shelf/AdLibPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect } from 'react' +import { useState, useCallback, useEffect } from 'react' import _ from 'underscore' import { Meteor } from 'meteor/meteor' import { useTracker } from '../../lib/ReactMeteorData/react-meteor-data' diff --git a/meteor/client/ui/Shelf/AdLibPanelToolbar.tsx b/packages/webui/src/client/ui/Shelf/AdLibPanelToolbar.tsx similarity index 100% rename from meteor/client/ui/Shelf/AdLibPanelToolbar.tsx rename to packages/webui/src/client/ui/Shelf/AdLibPanelToolbar.tsx diff --git a/meteor/client/ui/Shelf/AdLibRegionPanel.tsx b/packages/webui/src/client/ui/Shelf/AdLibRegionPanel.tsx similarity index 99% rename from meteor/client/ui/Shelf/AdLibRegionPanel.tsx rename to packages/webui/src/client/ui/Shelf/AdLibRegionPanel.tsx index e4333f385d..0b0b4ca158 100644 --- a/meteor/client/ui/Shelf/AdLibRegionPanel.tsx +++ b/packages/webui/src/client/ui/Shelf/AdLibRegionPanel.tsx @@ -229,7 +229,7 @@ export const AdLibRegionPanelWithStatus = withMediaObjectStatus< >()(AdLibRegionPanelBase) export const AdLibRegionPanel = translateWithTracker< - Translated, + IAdLibPanelProps & IAdLibRegionPanelProps, IState, AdLibFetchAndFilterProps & IAdLibRegionPanelTrackedProps >( diff --git a/meteor/client/ui/Shelf/BucketPanel.tsx b/packages/webui/src/client/ui/Shelf/BucketPanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/BucketPanel.tsx rename to packages/webui/src/client/ui/Shelf/BucketPanel.tsx diff --git a/meteor/client/ui/Shelf/BucketPieceButton.tsx b/packages/webui/src/client/ui/Shelf/BucketPieceButton.tsx similarity index 100% rename from meteor/client/ui/Shelf/BucketPieceButton.tsx rename to packages/webui/src/client/ui/Shelf/BucketPieceButton.tsx diff --git a/meteor/client/ui/Shelf/ColoredBoxPanel.tsx b/packages/webui/src/client/ui/Shelf/ColoredBoxPanel.tsx similarity index 96% rename from meteor/client/ui/Shelf/ColoredBoxPanel.tsx rename to packages/webui/src/client/ui/Shelf/ColoredBoxPanel.tsx index 40b3c55f20..43db3a9920 100644 --- a/meteor/client/ui/Shelf/ColoredBoxPanel.tsx +++ b/packages/webui/src/client/ui/Shelf/ColoredBoxPanel.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { DashboardLayoutColoredBox, RundownLayoutBase, diff --git a/meteor/client/ui/Shelf/DashboardActionButton.tsx b/packages/webui/src/client/ui/Shelf/DashboardActionButton.tsx similarity index 100% rename from meteor/client/ui/Shelf/DashboardActionButton.tsx rename to packages/webui/src/client/ui/Shelf/DashboardActionButton.tsx diff --git a/meteor/client/ui/Shelf/DashboardActionButtonGroup.tsx b/packages/webui/src/client/ui/Shelf/DashboardActionButtonGroup.tsx similarity index 100% rename from meteor/client/ui/Shelf/DashboardActionButtonGroup.tsx rename to packages/webui/src/client/ui/Shelf/DashboardActionButtonGroup.tsx diff --git a/meteor/client/ui/Shelf/DashboardPanel.tsx b/packages/webui/src/client/ui/Shelf/DashboardPanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/DashboardPanel.tsx rename to packages/webui/src/client/ui/Shelf/DashboardPanel.tsx diff --git a/meteor/client/ui/Shelf/DashboardPieceButton.tsx b/packages/webui/src/client/ui/Shelf/DashboardPieceButton.tsx similarity index 100% rename from meteor/client/ui/Shelf/DashboardPieceButton.tsx rename to packages/webui/src/client/ui/Shelf/DashboardPieceButton.tsx diff --git a/meteor/client/ui/Shelf/DashboardPieceButtonSplitPreview.tsx b/packages/webui/src/client/ui/Shelf/DashboardPieceButtonSplitPreview.tsx similarity index 95% rename from meteor/client/ui/Shelf/DashboardPieceButtonSplitPreview.tsx rename to packages/webui/src/client/ui/Shelf/DashboardPieceButtonSplitPreview.tsx index aaa2b99e5a..7d30950d79 100644 --- a/meteor/client/ui/Shelf/DashboardPieceButtonSplitPreview.tsx +++ b/packages/webui/src/client/ui/Shelf/DashboardPieceButtonSplitPreview.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { SplitsContent } from '@sofie-automation/blueprints-integration' import { PieceGeneric } from '@sofie-automation/corelib/dist/dataModel/Piece' import { getSplitPreview } from '../../lib/ui/splitPreview' diff --git a/meteor/client/ui/Shelf/EndWordsPanel.tsx b/packages/webui/src/client/ui/Shelf/EndWordsPanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/EndWordsPanel.tsx rename to packages/webui/src/client/ui/Shelf/EndWordsPanel.tsx diff --git a/meteor/client/ui/Shelf/ExternalFramePanel.tsx b/packages/webui/src/client/ui/Shelf/ExternalFramePanel.tsx similarity index 99% rename from meteor/client/ui/Shelf/ExternalFramePanel.tsx rename to packages/webui/src/client/ui/Shelf/ExternalFramePanel.tsx index d0694303d5..f4cfa64490 100644 --- a/meteor/client/ui/Shelf/ExternalFramePanel.tsx +++ b/packages/webui/src/client/ui/Shelf/ExternalFramePanel.tsx @@ -35,8 +35,6 @@ import { RundownPlaylistCollectionUtil } from '../../../lib/collections/rundownP import { logger } from '../../../lib/logging' import RundownViewEventBus, { ItemDroppedEvent, RundownViewEvents } from '../../../lib/api/triggers/RundownViewEventBus' -const PackageInfo = require('../../../package.json') - interface IProps { layout: RundownLayoutBase panel: RundownLayoutExternalFrame @@ -309,7 +307,7 @@ export const ExternalFramePanel = withTranslation()( type: SofieExternalMessageType.WELCOME, payload: { host: 'Sofie Automation System', - version: PackageInfo.version, + version: __APP_VERSION__, rundownPlaylistId: this.props.playlist._id, }, }), diff --git a/meteor/client/ui/Shelf/GlobalAdLibPanel.tsx b/packages/webui/src/client/ui/Shelf/GlobalAdLibPanel.tsx similarity index 98% rename from meteor/client/ui/Shelf/GlobalAdLibPanel.tsx rename to packages/webui/src/client/ui/Shelf/GlobalAdLibPanel.tsx index 0a9da1676d..337a402784 100644 --- a/meteor/client/ui/Shelf/GlobalAdLibPanel.tsx +++ b/packages/webui/src/client/ui/Shelf/GlobalAdLibPanel.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import { useMemo } from 'react' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { IAdLibListItem } from './AdLibListItem' import { AdLibPanel } from './AdLibPanel' diff --git a/meteor/client/ui/Shelf/HotkeyHelpPanel.tsx b/packages/webui/src/client/ui/Shelf/HotkeyHelpPanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/HotkeyHelpPanel.tsx rename to packages/webui/src/client/ui/Shelf/HotkeyHelpPanel.tsx diff --git a/meteor/client/ui/Shelf/Inspector/ItemRenderers/ActionItemRenderer.tsx b/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/ActionItemRenderer.tsx similarity index 100% rename from meteor/client/ui/Shelf/Inspector/ItemRenderers/ActionItemRenderer.tsx rename to packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/ActionItemRenderer.tsx diff --git a/meteor/client/ui/Shelf/Inspector/ItemRenderers/DefaultItemRenderer.tsx b/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/DefaultItemRenderer.tsx similarity index 98% rename from meteor/client/ui/Shelf/Inspector/ItemRenderers/DefaultItemRenderer.tsx rename to packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/DefaultItemRenderer.tsx index d09f2f3713..0b5d9a814b 100644 --- a/meteor/client/ui/Shelf/Inspector/ItemRenderers/DefaultItemRenderer.tsx +++ b/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/DefaultItemRenderer.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { PieceUi } from '../../../SegmentTimeline/SegmentTimelineContainer' import { IAdLibListItem } from '../../AdLibListItem' import { RundownUtils } from '../../../../lib/rundown' diff --git a/meteor/client/ui/Shelf/Inspector/ItemRenderers/InspectorTitle.tsx b/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/InspectorTitle.tsx similarity index 98% rename from meteor/client/ui/Shelf/Inspector/ItemRenderers/InspectorTitle.tsx rename to packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/InspectorTitle.tsx index 6dc6987579..1413179063 100644 --- a/meteor/client/ui/Shelf/Inspector/ItemRenderers/InspectorTitle.tsx +++ b/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/InspectorTitle.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import ClassNames from 'classnames' import { PieceUi } from '../../../SegmentTimeline/SegmentTimelineContainer' import { BucketAdLibUi, BucketAdLibActionUi } from '../../RundownViewBuckets' diff --git a/meteor/client/ui/Shelf/Inspector/ItemRenderers/ItemRendererFactory.ts b/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/ItemRendererFactory.ts similarity index 100% rename from meteor/client/ui/Shelf/Inspector/ItemRenderers/ItemRendererFactory.ts rename to packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/ItemRendererFactory.ts diff --git a/meteor/client/ui/Shelf/Inspector/ItemRenderers/NoraItemEditor.tsx b/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/NoraItemEditor.tsx similarity index 100% rename from meteor/client/ui/Shelf/Inspector/ItemRenderers/NoraItemEditor.tsx rename to packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/NoraItemEditor.tsx diff --git a/meteor/client/ui/Shelf/Inspector/ItemRenderers/NoraItemRenderer.tsx b/packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/NoraItemRenderer.tsx similarity index 100% rename from meteor/client/ui/Shelf/Inspector/ItemRenderers/NoraItemRenderer.tsx rename to packages/webui/src/client/ui/Shelf/Inspector/ItemRenderers/NoraItemRenderer.tsx diff --git a/meteor/client/ui/Shelf/Inspector/ShelfInspector.tsx b/packages/webui/src/client/ui/Shelf/Inspector/ShelfInspector.tsx similarity index 100% rename from meteor/client/ui/Shelf/Inspector/ShelfInspector.tsx rename to packages/webui/src/client/ui/Shelf/Inspector/ShelfInspector.tsx diff --git a/meteor/client/ui/Shelf/MiniRundownPanel.tsx b/packages/webui/src/client/ui/Shelf/MiniRundownPanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/MiniRundownPanel.tsx rename to packages/webui/src/client/ui/Shelf/MiniRundownPanel.tsx diff --git a/meteor/client/ui/Shelf/NextBreakTimingPanel.tsx b/packages/webui/src/client/ui/Shelf/NextBreakTimingPanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/NextBreakTimingPanel.tsx rename to packages/webui/src/client/ui/Shelf/NextBreakTimingPanel.tsx diff --git a/meteor/client/ui/Shelf/NextInfoPanel.tsx b/packages/webui/src/client/ui/Shelf/NextInfoPanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/NextInfoPanel.tsx rename to packages/webui/src/client/ui/Shelf/NextInfoPanel.tsx diff --git a/meteor/client/ui/Shelf/OverflowingContainer.tsx b/packages/webui/src/client/ui/Shelf/OverflowingContainer.tsx similarity index 100% rename from meteor/client/ui/Shelf/OverflowingContainer.tsx rename to packages/webui/src/client/ui/Shelf/OverflowingContainer.tsx diff --git a/meteor/client/ui/Shelf/PartNamePanel.tsx b/packages/webui/src/client/ui/Shelf/PartNamePanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/PartNamePanel.tsx rename to packages/webui/src/client/ui/Shelf/PartNamePanel.tsx diff --git a/meteor/client/ui/Shelf/PartTimingPanel.tsx b/packages/webui/src/client/ui/Shelf/PartTimingPanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/PartTimingPanel.tsx rename to packages/webui/src/client/ui/Shelf/PartTimingPanel.tsx diff --git a/meteor/client/ui/Shelf/PieceCountdownPanel.tsx b/packages/webui/src/client/ui/Shelf/PieceCountdownPanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/PieceCountdownPanel.tsx rename to packages/webui/src/client/ui/Shelf/PieceCountdownPanel.tsx diff --git a/meteor/client/ui/Shelf/PlaylistEndTimerPanel.tsx b/packages/webui/src/client/ui/Shelf/PlaylistEndTimerPanel.tsx similarity index 98% rename from meteor/client/ui/Shelf/PlaylistEndTimerPanel.tsx rename to packages/webui/src/client/ui/Shelf/PlaylistEndTimerPanel.tsx index f1512f532d..5a95c5742c 100644 --- a/meteor/client/ui/Shelf/PlaylistEndTimerPanel.tsx +++ b/packages/webui/src/client/ui/Shelf/PlaylistEndTimerPanel.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import ClassNames from 'classnames' import { DashboardLayoutPlaylistEndTimer, diff --git a/meteor/client/ui/Shelf/PlaylistNamePanel.tsx b/packages/webui/src/client/ui/Shelf/PlaylistNamePanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/PlaylistNamePanel.tsx rename to packages/webui/src/client/ui/Shelf/PlaylistNamePanel.tsx diff --git a/meteor/client/ui/Shelf/PlaylistStartTimerPanel.tsx b/packages/webui/src/client/ui/Shelf/PlaylistStartTimerPanel.tsx similarity index 97% rename from meteor/client/ui/Shelf/PlaylistStartTimerPanel.tsx rename to packages/webui/src/client/ui/Shelf/PlaylistStartTimerPanel.tsx index 935e38f476..f62403ad89 100644 --- a/meteor/client/ui/Shelf/PlaylistStartTimerPanel.tsx +++ b/packages/webui/src/client/ui/Shelf/PlaylistStartTimerPanel.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import ClassNames from 'classnames' import { DashboardLayoutPlaylistStartTimer, diff --git a/meteor/client/ui/Shelf/Renderers/DefaultListItemRenderer.tsx b/packages/webui/src/client/ui/Shelf/Renderers/DefaultListItemRenderer.tsx similarity index 100% rename from meteor/client/ui/Shelf/Renderers/DefaultListItemRenderer.tsx rename to packages/webui/src/client/ui/Shelf/Renderers/DefaultListItemRenderer.tsx diff --git a/meteor/client/ui/Shelf/Renderers/ItemRendererFactory.ts b/packages/webui/src/client/ui/Shelf/Renderers/ItemRendererFactory.ts similarity index 100% rename from meteor/client/ui/Shelf/Renderers/ItemRendererFactory.ts rename to packages/webui/src/client/ui/Shelf/Renderers/ItemRendererFactory.ts diff --git a/meteor/client/ui/Shelf/Renderers/L3rdListItemRenderer.tsx b/packages/webui/src/client/ui/Shelf/Renderers/L3rdListItemRenderer.tsx similarity index 100% rename from meteor/client/ui/Shelf/Renderers/L3rdListItemRenderer.tsx rename to packages/webui/src/client/ui/Shelf/Renderers/L3rdListItemRenderer.tsx diff --git a/meteor/client/ui/Shelf/Renderers/VTListItemRenderer.tsx b/packages/webui/src/client/ui/Shelf/Renderers/VTListItemRenderer.tsx similarity index 100% rename from meteor/client/ui/Shelf/Renderers/VTListItemRenderer.tsx rename to packages/webui/src/client/ui/Shelf/Renderers/VTListItemRenderer.tsx diff --git a/meteor/client/ui/Shelf/RundownViewBuckets.tsx b/packages/webui/src/client/ui/Shelf/RundownViewBuckets.tsx similarity index 100% rename from meteor/client/ui/Shelf/RundownViewBuckets.tsx rename to packages/webui/src/client/ui/Shelf/RundownViewBuckets.tsx diff --git a/meteor/client/ui/Shelf/SegmentNamePanel.tsx b/packages/webui/src/client/ui/Shelf/SegmentNamePanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/SegmentNamePanel.tsx rename to packages/webui/src/client/ui/Shelf/SegmentNamePanel.tsx diff --git a/meteor/client/ui/Shelf/SegmentTimingPanel.tsx b/packages/webui/src/client/ui/Shelf/SegmentTimingPanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/SegmentTimingPanel.tsx rename to packages/webui/src/client/ui/Shelf/SegmentTimingPanel.tsx diff --git a/meteor/client/ui/Shelf/Shelf.tsx b/packages/webui/src/client/ui/Shelf/Shelf.tsx similarity index 100% rename from meteor/client/ui/Shelf/Shelf.tsx rename to packages/webui/src/client/ui/Shelf/Shelf.tsx diff --git a/meteor/client/ui/Shelf/ShelfContextMenu.tsx b/packages/webui/src/client/ui/Shelf/ShelfContextMenu.tsx similarity index 100% rename from meteor/client/ui/Shelf/ShelfContextMenu.tsx rename to packages/webui/src/client/ui/Shelf/ShelfContextMenu.tsx diff --git a/meteor/client/ui/Shelf/ShelfDashboardLayout.tsx b/packages/webui/src/client/ui/Shelf/ShelfDashboardLayout.tsx similarity index 99% rename from meteor/client/ui/Shelf/ShelfDashboardLayout.tsx rename to packages/webui/src/client/ui/Shelf/ShelfDashboardLayout.tsx index 0d562644c4..f769675986 100644 --- a/meteor/client/ui/Shelf/ShelfDashboardLayout.tsx +++ b/packages/webui/src/client/ui/Shelf/ShelfDashboardLayout.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { DashboardLayout, DashboardLayoutFilter } from '../../../lib/collections/RundownLayouts' import { RundownLayoutsAPI } from '../../../lib/api/rundownLayouts' import { TimelineDashboardPanel } from './TimelineDashboardPanel' diff --git a/meteor/client/ui/Shelf/ShelfRundownLayout.tsx b/packages/webui/src/client/ui/Shelf/ShelfRundownLayout.tsx similarity index 99% rename from meteor/client/ui/Shelf/ShelfRundownLayout.tsx rename to packages/webui/src/client/ui/Shelf/ShelfRundownLayout.tsx index 83128c4ea1..8eebc01124 100644 --- a/meteor/client/ui/Shelf/ShelfRundownLayout.tsx +++ b/packages/webui/src/client/ui/Shelf/ShelfRundownLayout.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { RundownLayout } from '../../../lib/collections/RundownLayouts' import { RundownLayoutsAPI } from '../../../lib/api/rundownLayouts' import { ExternalFramePanel } from './ExternalFramePanel' diff --git a/meteor/client/ui/Shelf/ShowStylePanel.tsx b/packages/webui/src/client/ui/Shelf/ShowStylePanel.tsx similarity index 98% rename from meteor/client/ui/Shelf/ShowStylePanel.tsx rename to packages/webui/src/client/ui/Shelf/ShowStylePanel.tsx index b72a7ccd20..77f2140b79 100644 --- a/meteor/client/ui/Shelf/ShowStylePanel.tsx +++ b/packages/webui/src/client/ui/Shelf/ShowStylePanel.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { DashboardLayoutShowStyleDisplay, RundownLayoutBase, diff --git a/meteor/client/ui/Shelf/StudioNamePanel.tsx b/packages/webui/src/client/ui/Shelf/StudioNamePanel.tsx similarity index 97% rename from meteor/client/ui/Shelf/StudioNamePanel.tsx rename to packages/webui/src/client/ui/Shelf/StudioNamePanel.tsx index 1ab8f892d1..1faa3c72ea 100644 --- a/meteor/client/ui/Shelf/StudioNamePanel.tsx +++ b/packages/webui/src/client/ui/Shelf/StudioNamePanel.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import ClassNames from 'classnames' import { DashboardLayoutStudioName, diff --git a/meteor/client/ui/Shelf/SystemStatusPanel.tsx b/packages/webui/src/client/ui/Shelf/SystemStatusPanel.tsx similarity index 98% rename from meteor/client/ui/Shelf/SystemStatusPanel.tsx rename to packages/webui/src/client/ui/Shelf/SystemStatusPanel.tsx index 577f10cfee..1bd6bdbdb5 100644 --- a/meteor/client/ui/Shelf/SystemStatusPanel.tsx +++ b/packages/webui/src/client/ui/Shelf/SystemStatusPanel.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import ClassNames from 'classnames' import { DashboardLayoutSystemStatus, diff --git a/meteor/client/ui/Shelf/TextLabelPanel.tsx b/packages/webui/src/client/ui/Shelf/TextLabelPanel.tsx similarity index 97% rename from meteor/client/ui/Shelf/TextLabelPanel.tsx rename to packages/webui/src/client/ui/Shelf/TextLabelPanel.tsx index c212fe468b..31aa38527f 100644 --- a/meteor/client/ui/Shelf/TextLabelPanel.tsx +++ b/packages/webui/src/client/ui/Shelf/TextLabelPanel.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import ClassNames from 'classnames' import { DashboardLayoutTextLabel, diff --git a/meteor/client/ui/Shelf/TimeOfDayPanel.tsx b/packages/webui/src/client/ui/Shelf/TimeOfDayPanel.tsx similarity index 97% rename from meteor/client/ui/Shelf/TimeOfDayPanel.tsx rename to packages/webui/src/client/ui/Shelf/TimeOfDayPanel.tsx index 27b0f85163..8b837ed93f 100644 --- a/meteor/client/ui/Shelf/TimeOfDayPanel.tsx +++ b/packages/webui/src/client/ui/Shelf/TimeOfDayPanel.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { DashboardLayoutTimeOfDay, RundownLayoutBase, diff --git a/meteor/client/ui/Shelf/TimelineDashboardPanel.tsx b/packages/webui/src/client/ui/Shelf/TimelineDashboardPanel.tsx similarity index 100% rename from meteor/client/ui/Shelf/TimelineDashboardPanel.tsx rename to packages/webui/src/client/ui/Shelf/TimelineDashboardPanel.tsx diff --git a/meteor/client/ui/Status.tsx b/packages/webui/src/client/ui/Status.tsx similarity index 99% rename from meteor/client/ui/Status.tsx rename to packages/webui/src/client/ui/Status.tsx index ab58ff2c56..d3560d15d6 100644 --- a/meteor/client/ui/Status.tsx +++ b/packages/webui/src/client/ui/Status.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { useSubscription } from '../lib/ReactMeteorData/react-meteor-data' import { useTranslation } from 'react-i18next' import { Route, Switch, Redirect, NavLink } from 'react-router-dom' diff --git a/meteor/client/ui/Status/DebugState.tsx b/packages/webui/src/client/ui/Status/DebugState.tsx similarity index 100% rename from meteor/client/ui/Status/DebugState.tsx rename to packages/webui/src/client/ui/Status/DebugState.tsx diff --git a/meteor/client/ui/Status/Evaluations.tsx b/packages/webui/src/client/ui/Status/Evaluations.tsx similarity index 98% rename from meteor/client/ui/Status/Evaluations.tsx rename to packages/webui/src/client/ui/Status/Evaluations.tsx index 029e2fd6c6..df021653ba 100644 --- a/meteor/client/ui/Status/Evaluations.tsx +++ b/packages/webui/src/client/ui/Status/Evaluations.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/react-meteor-data' import Moment from 'react-moment' import { Time, unprotectString } from '../../../lib/lib' diff --git a/meteor/client/ui/Status/ExternalMessages.tsx b/packages/webui/src/client/ui/Status/ExternalMessages.tsx similarity index 100% rename from meteor/client/ui/Status/ExternalMessages.tsx rename to packages/webui/src/client/ui/Status/ExternalMessages.tsx diff --git a/meteor/client/ui/Status/MediaManager.tsx b/packages/webui/src/client/ui/Status/MediaManager.tsx similarity index 99% rename from meteor/client/ui/Status/MediaManager.tsx rename to packages/webui/src/client/ui/Status/MediaManager.tsx index c8a47260f6..47addb485c 100644 --- a/meteor/client/ui/Status/MediaManager.tsx +++ b/packages/webui/src/client/ui/Status/MediaManager.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react' -import CoreIcons from '@nrk/core-icons/jsx' +import * as CoreIcons from '@nrk/core-icons/jsx' import { faChevronDown, faChevronRight, faCheck, faStopCircle, faRedo, faFlag } from '@fortawesome/free-solid-svg-icons' // @ts-expect-error No types available import * as VelocityReact from 'velocity-react' diff --git a/meteor/client/ui/Status/StatusCodePill.tsx b/packages/webui/src/client/ui/Status/StatusCodePill.tsx similarity index 100% rename from meteor/client/ui/Status/StatusCodePill.tsx rename to packages/webui/src/client/ui/Status/StatusCodePill.tsx diff --git a/meteor/client/ui/Status/SystemStatus.tsx b/packages/webui/src/client/ui/Status/SystemStatus.tsx similarity index 99% rename from meteor/client/ui/Status/SystemStatus.tsx rename to packages/webui/src/client/ui/Status/SystemStatus.tsx index 569f034890..0781914534 100644 --- a/meteor/client/ui/Status/SystemStatus.tsx +++ b/packages/webui/src/client/ui/Status/SystemStatus.tsx @@ -372,8 +372,6 @@ interface ICoreItemProps { interface ICoreItemState {} -const PackageInfo = require('../../../package.json') - export const CoreItem = reacti18next.withTranslation()( class CoreItem extends React.Component, ICoreItemState> { constructor(props: Translated) { @@ -422,7 +420,7 @@ export const CoreItem = reacti18next.withTranslation()(
-
{PackageInfo.version || 'UNSTABLE'}
+
{__APP_VERSION__ || 'UNSTABLE'}
{(getAllowConfigure() || getAllowDeveloper()) && ( diff --git a/meteor/client/ui/Status/UserActivity.tsx b/packages/webui/src/client/ui/Status/UserActivity.tsx similarity index 100% rename from meteor/client/ui/Status/UserActivity.tsx rename to packages/webui/src/client/ui/Status/UserActivity.tsx diff --git a/meteor/client/ui/Status/media-status/MediaStatusList.scss b/packages/webui/src/client/ui/Status/media-status/MediaStatusList.scss similarity index 100% rename from meteor/client/ui/Status/media-status/MediaStatusList.scss rename to packages/webui/src/client/ui/Status/media-status/MediaStatusList.scss diff --git a/meteor/client/ui/Status/media-status/MediaStatusListHeader.scss b/packages/webui/src/client/ui/Status/media-status/MediaStatusListHeader.scss similarity index 100% rename from meteor/client/ui/Status/media-status/MediaStatusListHeader.scss rename to packages/webui/src/client/ui/Status/media-status/MediaStatusListHeader.scss diff --git a/meteor/client/ui/Status/media-status/MediaStatusListHeader.tsx b/packages/webui/src/client/ui/Status/media-status/MediaStatusListHeader.tsx similarity index 98% rename from meteor/client/ui/Status/media-status/MediaStatusListHeader.tsx rename to packages/webui/src/client/ui/Status/media-status/MediaStatusListHeader.tsx index 300bfbee60..35279f3d60 100644 --- a/meteor/client/ui/Status/media-status/MediaStatusListHeader.tsx +++ b/packages/webui/src/client/ui/Status/media-status/MediaStatusListHeader.tsx @@ -1,4 +1,4 @@ -import React, { JSX } from 'react' +import { JSX } from 'react' import classNames from 'classnames' import { useTranslation } from 'react-i18next' import { SortOrderButton } from '../../MediaStatus/SortOrderButton' diff --git a/meteor/client/ui/Status/media-status/MediaStatusListItem.scss b/packages/webui/src/client/ui/Status/media-status/MediaStatusListItem.scss similarity index 100% rename from meteor/client/ui/Status/media-status/MediaStatusListItem.scss rename to packages/webui/src/client/ui/Status/media-status/MediaStatusListItem.scss diff --git a/meteor/client/ui/Status/media-status/MediaStatusListItem.tsx b/packages/webui/src/client/ui/Status/media-status/MediaStatusListItem.tsx similarity index 98% rename from meteor/client/ui/Status/media-status/MediaStatusListItem.tsx rename to packages/webui/src/client/ui/Status/media-status/MediaStatusListItem.tsx index 8d1f1d7588..1d12ce68d5 100644 --- a/meteor/client/ui/Status/media-status/MediaStatusListItem.tsx +++ b/packages/webui/src/client/ui/Status/media-status/MediaStatusListItem.tsx @@ -1,4 +1,4 @@ -import React, { JSX } from 'react' +import { JSX } from 'react' import { SourceLayerType } from '@sofie-automation/blueprints-integration' import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { NavLink } from 'react-router-dom' diff --git a/meteor/client/ui/Status/media-status/index.tsx b/packages/webui/src/client/ui/Status/media-status/index.tsx similarity index 98% rename from meteor/client/ui/Status/media-status/index.tsx rename to packages/webui/src/client/ui/Status/media-status/index.tsx index f3bad5c805..019d836810 100644 --- a/meteor/client/ui/Status/media-status/index.tsx +++ b/packages/webui/src/client/ui/Status/media-status/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState, JSX, CSSProperties } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, JSX, CSSProperties } from 'react' import { MediaStatus as MediaStatusComponent, MediaStatusListItem as IMediaStatusListItem, diff --git a/meteor/client/ui/Status/package-status/JobStatusIcon.tsx b/packages/webui/src/client/ui/Status/package-status/JobStatusIcon.tsx similarity index 100% rename from meteor/client/ui/Status/package-status/JobStatusIcon.tsx rename to packages/webui/src/client/ui/Status/package-status/JobStatusIcon.tsx diff --git a/meteor/client/ui/Status/package-status/PackageContainerStatus.tsx b/packages/webui/src/client/ui/Status/package-status/PackageContainerStatus.tsx similarity index 100% rename from meteor/client/ui/Status/package-status/PackageContainerStatus.tsx rename to packages/webui/src/client/ui/Status/package-status/PackageContainerStatus.tsx diff --git a/meteor/client/ui/Status/package-status/PackageStatus.tsx b/packages/webui/src/client/ui/Status/package-status/PackageStatus.tsx similarity index 100% rename from meteor/client/ui/Status/package-status/PackageStatus.tsx rename to packages/webui/src/client/ui/Status/package-status/PackageStatus.tsx diff --git a/meteor/client/ui/Status/package-status/PackageWorkStatus.tsx b/packages/webui/src/client/ui/Status/package-status/PackageWorkStatus.tsx similarity index 100% rename from meteor/client/ui/Status/package-status/PackageWorkStatus.tsx rename to packages/webui/src/client/ui/Status/package-status/PackageWorkStatus.tsx diff --git a/meteor/client/ui/Status/package-status/index.tsx b/packages/webui/src/client/ui/Status/package-status/index.tsx similarity index 100% rename from meteor/client/ui/Status/package-status/index.tsx rename to packages/webui/src/client/ui/Status/package-status/index.tsx diff --git a/meteor/client/ui/Status/package-status/package-status.scss b/packages/webui/src/client/ui/Status/package-status/package-status.scss similarity index 100% rename from meteor/client/ui/Status/package-status/package-status.scss rename to packages/webui/src/client/ui/Status/package-status/package-status.scss diff --git a/meteor/client/ui/StudioScreenSaver/Clock.tsx b/packages/webui/src/client/ui/StudioScreenSaver/Clock.tsx similarity index 91% rename from meteor/client/ui/StudioScreenSaver/Clock.tsx rename to packages/webui/src/client/ui/StudioScreenSaver/Clock.tsx index b59aa2872a..e0db0c7116 100644 --- a/meteor/client/ui/StudioScreenSaver/Clock.tsx +++ b/packages/webui/src/client/ui/StudioScreenSaver/Clock.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import Moment from 'react-moment' import { useCurrentTime } from '../../lib/lib' diff --git a/meteor/client/ui/StudioScreenSaver/Countdown.tsx b/packages/webui/src/client/ui/StudioScreenSaver/Countdown.tsx similarity index 98% rename from meteor/client/ui/StudioScreenSaver/Countdown.tsx rename to packages/webui/src/client/ui/StudioScreenSaver/Countdown.tsx index 0c32fbcb30..82a7f6d031 100644 --- a/meteor/client/ui/StudioScreenSaver/Countdown.tsx +++ b/packages/webui/src/client/ui/StudioScreenSaver/Countdown.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { useTranslation } from 'react-i18next' import { useCurrentTime } from '../../lib/lib' diff --git a/meteor/client/ui/StudioScreenSaver/StudioScreenSaver.tsx b/packages/webui/src/client/ui/StudioScreenSaver/StudioScreenSaver.tsx similarity index 100% rename from meteor/client/ui/StudioScreenSaver/StudioScreenSaver.tsx rename to packages/webui/src/client/ui/StudioScreenSaver/StudioScreenSaver.tsx diff --git a/meteor/client/ui/SupportPopUp.tsx b/packages/webui/src/client/ui/SupportPopUp.tsx similarity index 100% rename from meteor/client/ui/SupportPopUp.tsx rename to packages/webui/src/client/ui/SupportPopUp.tsx diff --git a/meteor/client/ui/TestTools/DeviceTriggers.tsx b/packages/webui/src/client/ui/TestTools/DeviceTriggers.tsx similarity index 100% rename from meteor/client/ui/TestTools/DeviceTriggers.tsx rename to packages/webui/src/client/ui/TestTools/DeviceTriggers.tsx diff --git a/meteor/client/ui/TestTools/Mappings.tsx b/packages/webui/src/client/ui/TestTools/Mappings.tsx similarity index 99% rename from meteor/client/ui/TestTools/Mappings.tsx rename to packages/webui/src/client/ui/TestTools/Mappings.tsx index 95541df490..89c4429112 100644 --- a/meteor/client/ui/TestTools/Mappings.tsx +++ b/packages/webui/src/client/ui/TestTools/Mappings.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/react-meteor-data' import * as _ from 'underscore' import { omit, unprotectString } from '../../../lib/lib' diff --git a/meteor/client/ui/TestTools/StudioSelect.tsx b/packages/webui/src/client/ui/TestTools/StudioSelect.tsx similarity index 97% rename from meteor/client/ui/TestTools/StudioSelect.tsx rename to packages/webui/src/client/ui/TestTools/StudioSelect.tsx index bb8857c92c..8271a21ba9 100644 --- a/meteor/client/ui/TestTools/StudioSelect.tsx +++ b/packages/webui/src/client/ui/TestTools/StudioSelect.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { useTracker } from '../../lib/ReactMeteorData/react-meteor-data' import { Link } from 'react-router-dom' import { unprotectString } from '../../../lib/lib' diff --git a/meteor/client/ui/TestTools/Timeline.tsx b/packages/webui/src/client/ui/TestTools/Timeline.tsx similarity index 100% rename from meteor/client/ui/TestTools/Timeline.tsx rename to packages/webui/src/client/ui/TestTools/Timeline.tsx diff --git a/meteor/client/ui/TestTools/TimelineDatastore.tsx b/packages/webui/src/client/ui/TestTools/TimelineDatastore.tsx similarity index 98% rename from meteor/client/ui/TestTools/TimelineDatastore.tsx rename to packages/webui/src/client/ui/TestTools/TimelineDatastore.tsx index afdcd77925..cabb4f7e1a 100644 --- a/meteor/client/ui/TestTools/TimelineDatastore.tsx +++ b/packages/webui/src/client/ui/TestTools/TimelineDatastore.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { useSubscription, useTracker } from '../../lib/ReactMeteorData/react-meteor-data' import { StudioSelect } from './StudioSelect' import { Mongo } from 'meteor/mongo' diff --git a/meteor/client/ui/TestTools/index.tsx b/packages/webui/src/client/ui/TestTools/index.tsx similarity index 99% rename from meteor/client/ui/TestTools/index.tsx rename to packages/webui/src/client/ui/TestTools/index.tsx index 73e1e3891a..77102062f9 100644 --- a/meteor/client/ui/TestTools/index.tsx +++ b/packages/webui/src/client/ui/TestTools/index.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { useTranslation } from 'react-i18next' import { useSubscription } from '../../lib/ReactMeteorData/react-meteor-data' import { Route, Switch, NavLink, Redirect } from 'react-router-dom' diff --git a/meteor/client/ui/examples.tsx b/packages/webui/src/client/ui/examples.tsx similarity index 100% rename from meteor/client/ui/examples.tsx rename to packages/webui/src/client/ui/examples.tsx diff --git a/meteor/client/ui/globals/keyboardGlobals.d.ts b/packages/webui/src/client/ui/globals/keyboardGlobals.d.ts similarity index 100% rename from meteor/client/ui/globals/keyboardGlobals.d.ts rename to packages/webui/src/client/ui/globals/keyboardGlobals.d.ts diff --git a/meteor/client/ui/globals/wakeLockGlobals.d.ts b/packages/webui/src/client/ui/globals/wakeLockGlobals.d.ts similarity index 91% rename from meteor/client/ui/globals/wakeLockGlobals.d.ts rename to packages/webui/src/client/ui/globals/wakeLockGlobals.d.ts index f24709ea57..9141c2c4d7 100644 --- a/meteor/client/ui/globals/wakeLockGlobals.d.ts +++ b/packages/webui/src/client/ui/globals/wakeLockGlobals.d.ts @@ -1,7 +1,7 @@ type WakeLockType = 'screen' declare type WakeLockSentinel = { - readonly release(): void + release(): void readonly released: boolean readonly type: WakeLockType } diff --git a/meteor/client/ui/i18n.ts b/packages/webui/src/client/ui/i18n.ts similarity index 100% rename from meteor/client/ui/i18n.ts rename to packages/webui/src/client/ui/i18n.ts diff --git a/meteor/client/ui/util/useRundownAndShowStyleIdsForPlaylist.ts b/packages/webui/src/client/ui/util/useRundownAndShowStyleIdsForPlaylist.ts similarity index 100% rename from meteor/client/ui/util/useRundownAndShowStyleIdsForPlaylist.ts rename to packages/webui/src/client/ui/util/useRundownAndShowStyleIdsForPlaylist.ts diff --git a/meteor/client/ui/util/useSetDocumentClass.ts b/packages/webui/src/client/ui/util/useSetDocumentClass.ts similarity index 100% rename from meteor/client/ui/util/useSetDocumentClass.ts rename to packages/webui/src/client/ui/util/useSetDocumentClass.ts diff --git a/meteor/client/ui/util/useToggleExpandHelper.tsx b/packages/webui/src/client/ui/util/useToggleExpandHelper.tsx similarity index 100% rename from meteor/client/ui/util/useToggleExpandHelper.tsx rename to packages/webui/src/client/ui/util/useToggleExpandHelper.tsx diff --git a/meteor/client/utils/__tests__/dimensions.test.ts b/packages/webui/src/client/utils/__tests__/dimensions.test.ts similarity index 100% rename from meteor/client/utils/__tests__/dimensions.test.ts rename to packages/webui/src/client/utils/__tests__/dimensions.test.ts diff --git a/meteor/client/utils/__tests__/positions.test.ts b/packages/webui/src/client/utils/__tests__/positions.test.ts similarity index 100% rename from meteor/client/utils/__tests__/positions.test.ts rename to packages/webui/src/client/utils/__tests__/positions.test.ts diff --git a/meteor/client/utils/dimensions.ts b/packages/webui/src/client/utils/dimensions.ts similarity index 100% rename from meteor/client/utils/dimensions.ts rename to packages/webui/src/client/utils/dimensions.ts diff --git a/meteor/client/utils/positions.ts b/packages/webui/src/client/utils/positions.ts similarity index 100% rename from meteor/client/utils/positions.ts rename to packages/webui/src/client/utils/positions.ts diff --git a/meteor/public/fonts/Overpass/OFL.txt b/packages/webui/src/fonts/Overpass/OFL.txt similarity index 100% rename from meteor/public/fonts/Overpass/OFL.txt rename to packages/webui/src/fonts/Overpass/OFL.txt diff --git a/meteor/public/fonts/Overpass/Overpass-Black.ttf b/packages/webui/src/fonts/Overpass/Overpass-Black.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-Black.ttf rename to packages/webui/src/fonts/Overpass/Overpass-Black.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-BlackItalic.ttf b/packages/webui/src/fonts/Overpass/Overpass-BlackItalic.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-BlackItalic.ttf rename to packages/webui/src/fonts/Overpass/Overpass-BlackItalic.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-Bold.ttf b/packages/webui/src/fonts/Overpass/Overpass-Bold.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-Bold.ttf rename to packages/webui/src/fonts/Overpass/Overpass-Bold.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-BoldItalic.ttf b/packages/webui/src/fonts/Overpass/Overpass-BoldItalic.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-BoldItalic.ttf rename to packages/webui/src/fonts/Overpass/Overpass-BoldItalic.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-ExtraBold.ttf b/packages/webui/src/fonts/Overpass/Overpass-ExtraBold.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-ExtraBold.ttf rename to packages/webui/src/fonts/Overpass/Overpass-ExtraBold.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-ExtraBoldItalic.ttf b/packages/webui/src/fonts/Overpass/Overpass-ExtraBoldItalic.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-ExtraBoldItalic.ttf rename to packages/webui/src/fonts/Overpass/Overpass-ExtraBoldItalic.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-ExtraLight.ttf b/packages/webui/src/fonts/Overpass/Overpass-ExtraLight.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-ExtraLight.ttf rename to packages/webui/src/fonts/Overpass/Overpass-ExtraLight.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-ExtraLightItalic.ttf b/packages/webui/src/fonts/Overpass/Overpass-ExtraLightItalic.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-ExtraLightItalic.ttf rename to packages/webui/src/fonts/Overpass/Overpass-ExtraLightItalic.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-Italic.ttf b/packages/webui/src/fonts/Overpass/Overpass-Italic.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-Italic.ttf rename to packages/webui/src/fonts/Overpass/Overpass-Italic.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-Light.ttf b/packages/webui/src/fonts/Overpass/Overpass-Light.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-Light.ttf rename to packages/webui/src/fonts/Overpass/Overpass-Light.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-LightItalic.ttf b/packages/webui/src/fonts/Overpass/Overpass-LightItalic.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-LightItalic.ttf rename to packages/webui/src/fonts/Overpass/Overpass-LightItalic.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-Regular.ttf b/packages/webui/src/fonts/Overpass/Overpass-Regular.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-Regular.ttf rename to packages/webui/src/fonts/Overpass/Overpass-Regular.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-Regular.woff b/packages/webui/src/fonts/Overpass/Overpass-Regular.woff similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-Regular.woff rename to packages/webui/src/fonts/Overpass/Overpass-Regular.woff diff --git a/meteor/public/fonts/Overpass/Overpass-SemiBold.ttf b/packages/webui/src/fonts/Overpass/Overpass-SemiBold.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-SemiBold.ttf rename to packages/webui/src/fonts/Overpass/Overpass-SemiBold.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-SemiBoldItalic.ttf b/packages/webui/src/fonts/Overpass/Overpass-SemiBoldItalic.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-SemiBoldItalic.ttf rename to packages/webui/src/fonts/Overpass/Overpass-SemiBoldItalic.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-Thin.ttf b/packages/webui/src/fonts/Overpass/Overpass-Thin.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-Thin.ttf rename to packages/webui/src/fonts/Overpass/Overpass-Thin.ttf diff --git a/meteor/public/fonts/Overpass/Overpass-ThinItalic.ttf b/packages/webui/src/fonts/Overpass/Overpass-ThinItalic.ttf similarity index 100% rename from meteor/public/fonts/Overpass/Overpass-ThinItalic.ttf rename to packages/webui/src/fonts/Overpass/Overpass-ThinItalic.ttf diff --git a/meteor/public/fonts/Source_Serif_Pro/OFL.txt b/packages/webui/src/fonts/Source_Serif_Pro/OFL.txt similarity index 100% rename from meteor/public/fonts/Source_Serif_Pro/OFL.txt rename to packages/webui/src/fonts/Source_Serif_Pro/OFL.txt diff --git a/meteor/public/fonts/Source_Serif_Pro/SourceSerifPro-Bold.ttf b/packages/webui/src/fonts/Source_Serif_Pro/SourceSerifPro-Bold.ttf similarity index 100% rename from meteor/public/fonts/Source_Serif_Pro/SourceSerifPro-Bold.ttf rename to packages/webui/src/fonts/Source_Serif_Pro/SourceSerifPro-Bold.ttf diff --git a/meteor/public/fonts/Source_Serif_Pro/SourceSerifPro-Bold.woff b/packages/webui/src/fonts/Source_Serif_Pro/SourceSerifPro-Bold.woff similarity index 100% rename from meteor/public/fonts/Source_Serif_Pro/SourceSerifPro-Bold.woff rename to packages/webui/src/fonts/Source_Serif_Pro/SourceSerifPro-Bold.woff diff --git a/meteor/public/fonts/Source_Serif_Pro/SourceSerifPro-Regular.ttf b/packages/webui/src/fonts/Source_Serif_Pro/SourceSerifPro-Regular.ttf similarity index 100% rename from meteor/public/fonts/Source_Serif_Pro/SourceSerifPro-Regular.ttf rename to packages/webui/src/fonts/Source_Serif_Pro/SourceSerifPro-Regular.ttf diff --git a/meteor/public/fonts/Source_Serif_Pro/SourceSerifPro-Regular.woff b/packages/webui/src/fonts/Source_Serif_Pro/SourceSerifPro-Regular.woff similarity index 100% rename from meteor/public/fonts/Source_Serif_Pro/SourceSerifPro-Regular.woff rename to packages/webui/src/fonts/Source_Serif_Pro/SourceSerifPro-Regular.woff diff --git a/meteor/public/fonts/Source_Serif_Pro/SourceSerifPro-Semibold.ttf b/packages/webui/src/fonts/Source_Serif_Pro/SourceSerifPro-Semibold.ttf similarity index 100% rename from meteor/public/fonts/Source_Serif_Pro/SourceSerifPro-Semibold.ttf rename to packages/webui/src/fonts/Source_Serif_Pro/SourceSerifPro-Semibold.ttf diff --git a/meteor/public/fonts/Source_Serif_Pro/SourceSerifPro-Semibold.woff b/packages/webui/src/fonts/Source_Serif_Pro/SourceSerifPro-Semibold.woff similarity index 100% rename from meteor/public/fonts/Source_Serif_Pro/SourceSerifPro-Semibold.woff rename to packages/webui/src/fonts/Source_Serif_Pro/SourceSerifPro-Semibold.woff diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/COPYRIGHT.txt b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/COPYRIGHT.txt similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/COPYRIGHT.txt rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/COPYRIGHT.txt diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/LICENSE.txt b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/LICENSE.txt similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/LICENSE.txt rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/LICENSE.txt diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.eot b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.eot similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.eot rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.eot diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.svg b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.svg similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.svg rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.svg diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.ttf b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.ttf similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.ttf rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.ttf diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.woff b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.woff similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.woff rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.woff diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.woff2 b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.woff2 similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.woff2 rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300.woff2 diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.eot b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.eot similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.eot rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.eot diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.svg b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.svg similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.svg rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.svg diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.ttf b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.ttf similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.ttf rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.ttf diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.woff b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.woff similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.woff rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.woff diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.woff2 b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.woff2 similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.woff2 rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-300italic.woff2 diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.eot b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.eot similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.eot rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.eot diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.svg b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.svg similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.svg rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.svg diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.ttf b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.ttf similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.ttf rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.ttf diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.woff b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.woff similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.woff rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.woff diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.woff2 b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.woff2 similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.woff2 rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700.woff2 diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.eot b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.eot similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.eot rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.eot diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.svg b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.svg similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.svg rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.svg diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.ttf b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.ttf similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.ttf rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.ttf diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.woff b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.woff similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.woff rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.woff diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.woff2 b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.woff2 similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.woff2 rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-700italic.woff2 diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.eot b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.eot similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.eot rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.eot diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.svg b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.svg similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.svg rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.svg diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.ttf b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.ttf similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.ttf rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.ttf diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.woff b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.woff similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.woff rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.woff diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.woff2 b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.woff2 similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.woff2 rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-italic.woff2 diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.eot b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.eot similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.eot rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.eot diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.svg b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.svg similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.svg rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.svg diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.ttf b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.ttf similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.ttf rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.ttf diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.woff b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.woff similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.woff rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.woff diff --git a/meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.woff2 b/packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.woff2 similarity index 100% rename from meteor/public/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.woff2 rename to packages/webui/src/fonts/roboto-condensed-v18-latin_latin-ext/roboto-condensed-v18-latin_latin-ext-regular.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/README.md b/packages/webui/src/fonts/roboto-gh-pages/README.md similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/README.md rename to packages/webui/src/fonts/roboto-gh-pages/README.md diff --git a/meteor/public/fonts/roboto-gh-pages/bower.json b/packages/webui/src/fonts/roboto-gh-pages/bower.json similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/bower.json rename to packages/webui/src/fonts/roboto-gh-pages/bower.json diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Black/Roboto-Black.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/Black/Roboto-Black.ttf similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Black/Roboto-Black.ttf rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Black/Roboto-Black.ttf diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Black/Roboto-Black.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/Black/Roboto-Black.woff similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Black/Roboto-Black.woff rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Black/Roboto-Black.woff diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Black/Roboto-Black.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/Black/Roboto-Black.woff2 similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Black/Roboto-Black.woff2 rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Black/Roboto-Black.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/BlackItalic/Roboto-BlackItalic.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/BlackItalic/Roboto-BlackItalic.ttf similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/BlackItalic/Roboto-BlackItalic.ttf rename to packages/webui/src/fonts/roboto-gh-pages/fonts/BlackItalic/Roboto-BlackItalic.ttf diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/BlackItalic/Roboto-BlackItalic.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/BlackItalic/Roboto-BlackItalic.woff similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/BlackItalic/Roboto-BlackItalic.woff rename to packages/webui/src/fonts/roboto-gh-pages/fonts/BlackItalic/Roboto-BlackItalic.woff diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/BlackItalic/Roboto-BlackItalic.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/BlackItalic/Roboto-BlackItalic.woff2 similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/BlackItalic/Roboto-BlackItalic.woff2 rename to packages/webui/src/fonts/roboto-gh-pages/fonts/BlackItalic/Roboto-BlackItalic.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Bold/Roboto-Bold.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/Bold/Roboto-Bold.ttf similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Bold/Roboto-Bold.ttf rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Bold/Roboto-Bold.ttf diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Bold/Roboto-Bold.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/Bold/Roboto-Bold.woff similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Bold/Roboto-Bold.woff rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Bold/Roboto-Bold.woff diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Bold/Roboto-Bold.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/Bold/Roboto-Bold.woff2 similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Bold/Roboto-Bold.woff2 rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Bold/Roboto-Bold.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/BoldItalic/Roboto-BoldItalic.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/BoldItalic/Roboto-BoldItalic.ttf similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/BoldItalic/Roboto-BoldItalic.ttf rename to packages/webui/src/fonts/roboto-gh-pages/fonts/BoldItalic/Roboto-BoldItalic.ttf diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/BoldItalic/Roboto-BoldItalic.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/BoldItalic/Roboto-BoldItalic.woff similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/BoldItalic/Roboto-BoldItalic.woff rename to packages/webui/src/fonts/roboto-gh-pages/fonts/BoldItalic/Roboto-BoldItalic.woff diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/BoldItalic/Roboto-BoldItalic.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/BoldItalic/Roboto-BoldItalic.woff2 similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/BoldItalic/Roboto-BoldItalic.woff2 rename to packages/webui/src/fonts/roboto-gh-pages/fonts/BoldItalic/Roboto-BoldItalic.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/COPYRIGHT.txt b/packages/webui/src/fonts/roboto-gh-pages/fonts/COPYRIGHT.txt similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/COPYRIGHT.txt rename to packages/webui/src/fonts/roboto-gh-pages/fonts/COPYRIGHT.txt diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/DESCRIPTION.en_us.html b/packages/webui/src/fonts/roboto-gh-pages/fonts/DESCRIPTION.en_us.html similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/DESCRIPTION.en_us.html rename to packages/webui/src/fonts/roboto-gh-pages/fonts/DESCRIPTION.en_us.html diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Italic/Roboto-Italic.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/Italic/Roboto-Italic.ttf similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Italic/Roboto-Italic.ttf rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Italic/Roboto-Italic.ttf diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Italic/Roboto-Italic.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/Italic/Roboto-Italic.woff similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Italic/Roboto-Italic.woff rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Italic/Roboto-Italic.woff diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Italic/Roboto-Italic.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/Italic/Roboto-Italic.woff2 similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Italic/Roboto-Italic.woff2 rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Italic/Roboto-Italic.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/LICENSE.txt b/packages/webui/src/fonts/roboto-gh-pages/fonts/LICENSE.txt similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/LICENSE.txt rename to packages/webui/src/fonts/roboto-gh-pages/fonts/LICENSE.txt diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Light/Roboto-Light.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/Light/Roboto-Light.ttf similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Light/Roboto-Light.ttf rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Light/Roboto-Light.ttf diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Light/Roboto-Light.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/Light/Roboto-Light.woff similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Light/Roboto-Light.woff rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Light/Roboto-Light.woff diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Light/Roboto-Light.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/Light/Roboto-Light.woff2 similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Light/Roboto-Light.woff2 rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Light/Roboto-Light.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/LightItalic/Roboto-LightItalic.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/LightItalic/Roboto-LightItalic.ttf similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/LightItalic/Roboto-LightItalic.ttf rename to packages/webui/src/fonts/roboto-gh-pages/fonts/LightItalic/Roboto-LightItalic.ttf diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/LightItalic/Roboto-LightItalic.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/LightItalic/Roboto-LightItalic.woff similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/LightItalic/Roboto-LightItalic.woff rename to packages/webui/src/fonts/roboto-gh-pages/fonts/LightItalic/Roboto-LightItalic.woff diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/LightItalic/Roboto-LightItalic.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/LightItalic/Roboto-LightItalic.woff2 similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/LightItalic/Roboto-LightItalic.woff2 rename to packages/webui/src/fonts/roboto-gh-pages/fonts/LightItalic/Roboto-LightItalic.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/METADATA.pb b/packages/webui/src/fonts/roboto-gh-pages/fonts/METADATA.pb similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/METADATA.pb rename to packages/webui/src/fonts/roboto-gh-pages/fonts/METADATA.pb diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Medium/Roboto-Medium.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/Medium/Roboto-Medium.ttf similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Medium/Roboto-Medium.ttf rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Medium/Roboto-Medium.ttf diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Medium/Roboto-Medium.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/Medium/Roboto-Medium.woff similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Medium/Roboto-Medium.woff rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Medium/Roboto-Medium.woff diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Medium/Roboto-Medium.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/Medium/Roboto-Medium.woff2 similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Medium/Roboto-Medium.woff2 rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Medium/Roboto-Medium.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/MediumItalic/Roboto-MediumItalic.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/MediumItalic/Roboto-MediumItalic.ttf similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/MediumItalic/Roboto-MediumItalic.ttf rename to packages/webui/src/fonts/roboto-gh-pages/fonts/MediumItalic/Roboto-MediumItalic.ttf diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/MediumItalic/Roboto-MediumItalic.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/MediumItalic/Roboto-MediumItalic.woff similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/MediumItalic/Roboto-MediumItalic.woff rename to packages/webui/src/fonts/roboto-gh-pages/fonts/MediumItalic/Roboto-MediumItalic.woff diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/MediumItalic/Roboto-MediumItalic.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/MediumItalic/Roboto-MediumItalic.woff2 similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/MediumItalic/Roboto-MediumItalic.woff2 rename to packages/webui/src/fonts/roboto-gh-pages/fonts/MediumItalic/Roboto-MediumItalic.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Regular/Roboto-Regular.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/Roboto-Regular.ttf similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Regular/Roboto-Regular.ttf rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/Roboto-Regular.ttf diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Regular/Roboto-Regular.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/Roboto-Regular.woff similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Regular/Roboto-Regular.woff rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/Roboto-Regular.woff diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Regular/Roboto-Regular.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/Roboto-Regular.woff2 similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Regular/Roboto-Regular.woff2 rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Regular/Roboto-Regular.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Thin/Roboto-Thin.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/Thin/Roboto-Thin.ttf similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Thin/Roboto-Thin.ttf rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Thin/Roboto-Thin.ttf diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Thin/Roboto-Thin.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/Thin/Roboto-Thin.woff similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Thin/Roboto-Thin.woff rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Thin/Roboto-Thin.woff diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/Thin/Roboto-Thin.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/Thin/Roboto-Thin.woff2 similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/Thin/Roboto-Thin.woff2 rename to packages/webui/src/fonts/roboto-gh-pages/fonts/Thin/Roboto-Thin.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/ThinItalic/Roboto-ThinItalic.ttf b/packages/webui/src/fonts/roboto-gh-pages/fonts/ThinItalic/Roboto-ThinItalic.ttf similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/ThinItalic/Roboto-ThinItalic.ttf rename to packages/webui/src/fonts/roboto-gh-pages/fonts/ThinItalic/Roboto-ThinItalic.ttf diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/ThinItalic/Roboto-ThinItalic.woff b/packages/webui/src/fonts/roboto-gh-pages/fonts/ThinItalic/Roboto-ThinItalic.woff similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/ThinItalic/Roboto-ThinItalic.woff rename to packages/webui/src/fonts/roboto-gh-pages/fonts/ThinItalic/Roboto-ThinItalic.woff diff --git a/meteor/public/fonts/roboto-gh-pages/fonts/ThinItalic/Roboto-ThinItalic.woff2 b/packages/webui/src/fonts/roboto-gh-pages/fonts/ThinItalic/Roboto-ThinItalic.woff2 similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/fonts/ThinItalic/Roboto-ThinItalic.woff2 rename to packages/webui/src/fonts/roboto-gh-pages/fonts/ThinItalic/Roboto-ThinItalic.woff2 diff --git a/meteor/public/fonts/roboto-gh-pages/index.html b/packages/webui/src/fonts/roboto-gh-pages/index.html similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/index.html rename to packages/webui/src/fonts/roboto-gh-pages/index.html diff --git a/meteor/public/fonts/roboto-gh-pages/less/_Black.less b/packages/webui/src/fonts/roboto-gh-pages/less/_Black.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_Black.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_Black.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_BlackItalic.less b/packages/webui/src/fonts/roboto-gh-pages/less/_BlackItalic.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_BlackItalic.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_BlackItalic.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_Bold.less b/packages/webui/src/fonts/roboto-gh-pages/less/_Bold.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_Bold.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_Bold.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_BoldItalic.less b/packages/webui/src/fonts/roboto-gh-pages/less/_BoldItalic.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_BoldItalic.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_BoldItalic.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_Italic.less b/packages/webui/src/fonts/roboto-gh-pages/less/_Italic.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_Italic.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_Italic.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_Light.less b/packages/webui/src/fonts/roboto-gh-pages/less/_Light.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_Light.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_Light.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_LightItalic.less b/packages/webui/src/fonts/roboto-gh-pages/less/_LightItalic.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_LightItalic.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_LightItalic.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_Medium.less b/packages/webui/src/fonts/roboto-gh-pages/less/_Medium.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_Medium.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_Medium.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_MediumItalic.less b/packages/webui/src/fonts/roboto-gh-pages/less/_MediumItalic.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_MediumItalic.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_MediumItalic.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_Regular.less b/packages/webui/src/fonts/roboto-gh-pages/less/_Regular.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_Regular.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_Regular.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_Thin.less b/packages/webui/src/fonts/roboto-gh-pages/less/_Thin.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_Thin.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_Thin.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_ThinItalic.less b/packages/webui/src/fonts/roboto-gh-pages/less/_ThinItalic.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_ThinItalic.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_ThinItalic.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_mixins.less b/packages/webui/src/fonts/roboto-gh-pages/less/_mixins.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_mixins.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_mixins.less diff --git a/meteor/public/fonts/roboto-gh-pages/less/_variables.less b/packages/webui/src/fonts/roboto-gh-pages/less/_variables.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/less/_variables.less rename to packages/webui/src/fonts/roboto-gh-pages/less/_variables.less diff --git a/meteor/public/fonts/roboto-gh-pages/package.json b/packages/webui/src/fonts/roboto-gh-pages/package.json similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/package.json rename to packages/webui/src/fonts/roboto-gh-pages/package.json diff --git a/meteor/public/fonts/roboto-gh-pages/roboto.css b/packages/webui/src/fonts/roboto-gh-pages/roboto.css similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/roboto.css rename to packages/webui/src/fonts/roboto-gh-pages/roboto.css diff --git a/meteor/public/fonts/roboto-gh-pages/roboto.css.map b/packages/webui/src/fonts/roboto-gh-pages/roboto.css.map similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/roboto.css.map rename to packages/webui/src/fonts/roboto-gh-pages/roboto.css.map diff --git a/meteor/public/fonts/roboto-gh-pages/roboto.less b/packages/webui/src/fonts/roboto-gh-pages/roboto.less similarity index 100% rename from meteor/public/fonts/roboto-gh-pages/roboto.less rename to packages/webui/src/fonts/roboto-gh-pages/roboto.less diff --git a/packages/webui/src/lib/KeyboardLayout.ts b/packages/webui/src/lib/KeyboardLayout.ts new file mode 100644 index 0000000000..2c8ce9fff1 --- /dev/null +++ b/packages/webui/src/lib/KeyboardLayout.ts @@ -0,0 +1,98 @@ +import * as _ from 'underscore' + +/** + * Convert an array of strings into a PhysicalLayout. + * See https://w3c.github.io/uievents-code/#keyboard-sections for rows and sections + * + * @param {string[]} shortForm Order of keys is: Alphanum Row E...A, Function Section Row K, Control Pad E, + * Control Pad D, Arrow Pad B, Arrow Pad A, Numpad Row E...A. + * @returns {PhysicalLayout} + */ +function createPhysicalLayout(shortForm: string[]): PhysicalLayout { + return shortForm.map((row) => { + return _.compact( + row.split(',').map((keyPosition) => { + const args = keyPosition.split(':') + return args[0] + ? { + code: args[1] ? args[1] : args[0], + width: args[1] ? (args[0] === 'X' ? -1 : parseFloat(args[0])) : 3, + } + : undefined + }) + ) + }) +} + +export interface KeyPositon { + code: string + width: number + space?: true +} + +/** + * Order of keys is: Alphanum Row E...A, Function Section Row K, Control Pad E, + * Control Pad D, Arrow Pad B, Arrow Pad A, Numpad Row E...A. Not all rows need to be specified. + */ +export type PhysicalLayout = KeyPositon[][] + +const STANDARD_102_TKL_TEMPLATE = [ + // Row E + 'Backquote,Digit1,Digit2,Digit3,Digit4,Digit5,Digit6,Digit7,Digit8,Digit9,Digit0,Minus,Equal,X:Backspace', + // Row D + '4:Tab,KeyQ,KeyW,KeyE,KeyR,KeyT,KeyY,KeyU,KeyI,KeyO,KeyP,BracketLeft,BracketRight', + // Row C + '5:CapsLock,KeyA,KeyS,KeyD,KeyF,KeyG,KeyH,KeyJ,KeyK,KeyL,Semicolon,Quote,Backslash,X:Enter', + // Row B + '3.5:ShiftLeft,IntlBackslash,KeyZ,KeyX,KeyC,KeyV,KeyB,KeyN,KeyM,Comma,Period,Slash,X:ShiftRight', + // Row A + '4:ControlLeft,MetaLeft,AltLeft,21:Space,AltRight,MetaRight,ContextMenu,X:ControlRight', + + // Row K + 'Escape,-1:$space,F1,F2,F3,F4,-1:$space,F5,F6,F7,F8,-1:$space,F9,F10,F11,F12', + + // Control Pad E + 'Insert,Home,PageUp', + // Control Pad D + 'Delete,End,PageDown', + + // Arrow Pad B + '$space,ArrowUp,$space', + // Arrow Pad A + 'ArrowLeft,ArrowDown,ArrowRight', +] + +const STANDARD_102_EXTENDED_TEMPLATE = [ + ...STANDARD_102_TKL_TEMPLATE, + // Row E + 'NumLock,NumpadDivide,NumpadMultiply,NumpadSubtract', + // Row D + 'Numpad7,Numpad8,Numpad9,NumpadAdd', + // Row C + 'Numpad4,Numpad5,Numpad6', + // Row B + 'Numpad1,Numpad2,Numpad3,NumpadEnter', + // Row A + '6.16:Numpad0,NumpadDecimal', +] + +export namespace KeyboardLayouts { + // This is a small keyboard layout: 102-Standard keybord, without the Numpad + export const STANDARD_102_TKL: PhysicalLayout = createPhysicalLayout(STANDARD_102_TKL_TEMPLATE) + export const STANDARD_102_EXTENDED: PhysicalLayout = createPhysicalLayout(STANDARD_102_EXTENDED_TEMPLATE) + + export function nameToPhysicalLayout(name: Names): PhysicalLayout { + switch (name) { + case Names.STANDARD_102_EXTENDED: + return STANDARD_102_EXTENDED + case Names.STANDARD_102_TKL: + default: + return STANDARD_102_TKL + } + } + + export enum Names { + STANDARD_102_TKL = 'STANDARD_102_TKL', + STANDARD_102_EXTENDED = 'STANDARD_102_EXTENDED', + } +} diff --git a/packages/webui/src/lib/MeteorApply.ts b/packages/webui/src/lib/MeteorApply.ts new file mode 100644 index 0000000000..607793399e --- /dev/null +++ b/packages/webui/src/lib/MeteorApply.ts @@ -0,0 +1,150 @@ +import { Meteor } from 'meteor/meteor' +import { logger } from './logging' + +/* + * MeteorApply is a wrapper around Meteor.apply(), and logs a warning if the method is sent late. + * + * Because Meteor methods are generally executed in order, it might be useful to know if a method is sent late. + * ref: https://guide.meteor.com/methods#methods-vs-rest + * + * This only works if all method-calls on the client are done through this function, + * as separate calls to Meteor.apply() or Meteor.call() will bypass the queueing, and cause the + * time-measurements and logged warnings to be incorrect or omitted. + */ + +/** + * Synonym for Meteor.apply. + * Logs a warning if the method is sent late + */ +export async function MeteorApply( + callName: Parameters[0], + args: Parameters[1], + options?: Parameters[2], + sendOptions?: SendOptions +): Promise { + if (!Meteor.isClient) throw new Error('MeteorApply should only be called client side!') + + return new Promise((resolve, reject) => { + const queuedMethod: QueuedMeteorMethod = { + queueTime: Date.now(), + running: false, + callName, + args, + options, + sendOptions, + reject, + resolve, + } + meteorMethodQueue.push(queuedMethod) + checkMethodQueue() + }) +} +const meteorMethodQueue: QueuedMeteorMethod[] = [] + +function checkMethodQueue() { + const nextMethod = meteorMethodQueue[0] + if (!nextMethod) return + if (!nextMethod.running) { + // Time to send the method + nextMethod.running = true + + const sendTime = Date.now() + const timeBetweenTriggerAndSend = sendTime - nextMethod.queueTime + if (timeBetweenTriggerAndSend > (nextMethod.sendOptions?.warnSendTime ?? 1000)) { + logWarning( + `Method "${ + nextMethod.callName + }" was sent ${timeBetweenTriggerAndSend}ms after it was triggered (at ${new Date().toISOString()})` + ) + } + + Meteor.apply(nextMethod.callName, nextMethod.args, nextMethod.options, (err, res) => { + meteorMethodQueue.shift() + setTimeout(() => { + checkMethodQueue() + }, 0) + + const completeTime = Date.now() + const timeBetweenSendAndComplete = completeTime - sendTime + if (timeBetweenSendAndComplete > (nextMethod.sendOptions?.warnCompleteTime ?? 1000)) { + logWarning( + `Method "${ + nextMethod.callName + }" was completed ${timeBetweenSendAndComplete}ms after it was sent (at ${new Date().toISOString()})` + ) + } + + if (err) { + nextMethod.reject(err) + } else { + nextMethod.resolve(res) + } + }) + } +} + +let loggedWarningCount = 0 +let tooManyWarnings = false +let skippedWarningCount = 0 +const MAX_LOG_COUNT = 10 +const SKIP_WARNING_COOL_DOWN = 60 * 5 // seconds +function logWarning(message: string) { + if (!tooManyWarnings) { + loggedWarningCount++ + + if (loggedWarningCount > MAX_LOG_COUNT) { + // To avoid a flood of warnings (which, when sent to server via a method call, can cause slowness itself), + // we will only log the first {MAX_LOG_COUNT} warnings, and then ignore further warnings for a while. + + logger.warn( + `Has logged too many warnings (${loggedWarningCount}) about late Meteor methods. Will ignore further warnings for ${SKIP_WARNING_COOL_DOWN} seconds.` + ) + tooManyWarnings = true + setTimeout(() => { + if (skippedWarningCount > 0) { + logger.warn( + `Will start logging warnings about late Meteor methods again. (Ignored ${skippedWarningCount} warnings.)` + ) + } + tooManyWarnings = false + skippedWarningCount = 0 + loggedWarningCount = 0 + }, SKIP_WARNING_COOL_DOWN * 1000) + } else { + logger.warn(message) + } + } else { + // Ignore the warning + skippedWarningCount++ + } +} +// Clear the warning count every hour, just to avoid +setInterval(() => { + if (!tooManyWarnings) { + loggedWarningCount = 0 + } +}, 3600 * 1000) + +interface QueuedMeteorMethod { + callName: Parameters[0] + args: Parameters[1] + options?: Parameters[2] + + reject: (reason: any) => void + resolve: (value: unknown) => void + + sendOptions?: SendOptions + + queueTime: number + running: boolean +} +export interface SendOptions { + /** Log a warning if the method was sent later than this time. Defaults to 1000. [milliseconds] */ + warnSendTime?: number + /** Log a warning if the method was completed later than this time. Defaults to 1000. [milliseconds] */ + warnCompleteTime?: number +} +if (Meteor.isClient) { + // @ts-expect-error hack for dev + window.MeteorApply = MeteorApply +} diff --git a/packages/webui/src/lib/ReactingObserver.ts b/packages/webui/src/lib/ReactingObserver.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/webui/src/lib/ReactiveStore.ts b/packages/webui/src/lib/ReactiveStore.ts new file mode 100644 index 0000000000..005b35ab20 --- /dev/null +++ b/packages/webui/src/lib/ReactiveStore.ts @@ -0,0 +1,127 @@ +import { Meteor } from 'meteor/meteor' +import { Tracker } from 'meteor/tracker' +import * as _ from 'underscore' +import { getRandomString, lazyIgnore, ProtectedString } from './lib' + +/** The ReactiveStore is a Reactive key-value store. + * Keeps track of when the reactive values aren't in use anymore and automatically cleans them up. + */ +export class ReactiveStore | string, Value> { + private _store: Record< + string, + { + dep: Tracker.Dependency + computation?: Tracker.Computation + value: Value + } + > = {} + private _depsToBatchInvalidate: Tracker.Dependency[] = [] + private _name = getRandomString() + + constructor( + private options: { + /** Delays all Reactive updates with this time [ms] */ + delayUpdateTime?: number + } = {} + ) {} + /** + * Retrieves a value from the store. + * @param key Key to fetch the value from + * @param callbackGetValue (Optional) A Reactive function. If the value isn't found in the store, set up a Reactive watch for the value using this callback. + */ + getValue(key: Key, callbackGetValue?: () => Value): Value | undefined { + if (Meteor.isServer) { + // Server-side we won't use the cache at all. + return callbackGetValue?.() + } + + const key0 = key as unknown as string + let o = this._store[key0] + + if (!o) { + if (callbackGetValue) { + // Set up a Reactive watch for the value: + + this._store[key0] = o = { + dep: new Tracker.Dependency(), + computation: undefined, + value: undefined as any, + } + o.computation = Tracker.nonreactive(() => { + // Set up a new Reactive context for the callback: + return Tracker.autorun(() => { + // This function is invalidated and re-run whenever the value changes. + const newValue = callbackGetValue() + + const o = this._store[key0] + if (o) { + // Do an additional check whether the returned value actually changed: + if (!_.isEqual(o.value, newValue)) { + o.value = newValue + // Invaludate the dependency: + this.invalidateDependency(o.dep) + } + } + }) + }) + } else { + // No callback provided + return undefined + } + } + + if (Tracker.active && Tracker.currentComputation) { + Tracker.currentComputation.onStop(() => { + // Called when the reactive context of the caller of this.getValue is invalidated. + + if (!o.dep.hasDependents()) { + // If no-one is using it anymore, we should clean it out. + // Wait a bit, to give it a change to be reused. + setTimeout(() => { + const o = this._store[key0] + if (o) { + if (!o.dep.hasDependents()) { + this.removeValue(key) + } + } + }, 2000) + } + }) + // Depend, so that the reactive context will be invalidated whenever the value changes. + o.dep.depend() + } + return o.value + } + /** Remove a value from the store */ + private removeValue(key: Key) { + const key0 = key as unknown as string + const o = this._store[key0] + if (o) { + o.computation?.stop() + delete this._store[key0] + } + } + private invalidateDependency(dep: Tracker.Dependency) { + if (this.options.delayUpdateTime) { + // Delay and batch-invalidate all changes that might have come in until then: + this._depsToBatchInvalidate.push(dep) + lazyIgnore( + this._name, + () => { + for (const dep of this._depsToBatchInvalidate) { + dep.changed() + } + this._depsToBatchInvalidate = [] + }, + this.options.delayUpdateTime + ) + } else { + dep.changed() + } + } + clear(): void { + for (const key of Object.keys(this._store)) { + this.removeValue(key as unknown as Key) + } + } +} diff --git a/packages/webui/src/lib/Settings.ts b/packages/webui/src/lib/Settings.ts new file mode 100644 index 0000000000..c6b698ca01 --- /dev/null +++ b/packages/webui/src/lib/Settings.ts @@ -0,0 +1,102 @@ +import { Meteor } from 'meteor/meteor' +import * as _ from 'underscore' +import { KeyboardLayouts } from './KeyboardLayout' + +/** + * This is an object specifying installation-wide, User Interface settings. + * There are default values for these settings that will be used, unless overriden + * through Meteor.settings functionality. + * + * You can use METEOR_SETTING to inject the settings JSON or you can use the + * --settings [filename] to provide a JSON file containing the settings + */ +export interface ISettings { + /* Should the segment in the Rundown view automatically rewind after it stops being live? Default: false */ + autoRewindLeavingSegment: boolean + /** Disable blur border in RundownView */ + disableBlurBorder: boolean + /** Default time scale zooming for the UI. Default: 1 */ + defaultTimeScale: number + // Allow grabbing the entire timeline + allowGrabbingTimeline: boolean + /** If true, enables security measures, access control and user accounts. */ + enableUserAccounts: boolean + /** Default duration to use to render parts when no duration is provided */ + defaultDisplayDuration: number + /** If true, allows creation of new playlists in the Lobby Gui (rundown list). If false; only pre-existing playlists are allowed. */ + allowMultiplePlaylistsInGUI: boolean + /** How many segments of history to show when scrolling back in time (0 = show current segment only) */ + followOnAirSegmentsHistory: number + /** Clean up stuff that are older than this [ms] */ + maximumDataAge: number + /** Enable the use of poison key if present and use the key specified. **/ + poisonKey: string | null + /** If set, enables a check to ensure that the system time doesn't differ too much from the speficied NTP server time. */ + enableNTPTimeChecker: null | { + host: string + port?: number + maxAllowedDiff: number + } + /** Default value used to toggle Shelf options when the 'display' URL argument is not provided. */ + defaultShelfDisplayOptions: string + + /** The KeyboardPreview is a feature that is not implemented in the main Fork, and is kept here for compatibility */ + enableKeyboardPreview: boolean + + /** Keyboard map layout (what physical layout to use for the keyboard) */ + keyboardMapLayout: KeyboardLayouts.Names + + /** + * CSS class applied to the body of the page. Used to include custom implementations that differ from the main Fork. + * I.e. custom CSS etc. Leave undefined if no custom implementation is needed + * */ + customizationClassName?: string + + /** If true, countdowns of videos will count down to the last freeze-frame of the video instead of to the end of the video */ + useCountdownToFreezeFrame: boolean + + /** + * Which keyboard key is used as "Confirm" in modal dialogs etc. + * In some installations, the rightmost Enter key (on the numpad) is dedicated for playout, + * in such cases this must be set to 'Enter' to exclude it. + */ + confirmKeyCode: 'Enter' | 'AnyEnter' +} + +/** + * Default values for Settings + */ +const DEFAULT_SETTINGS = Object.freeze({ + autoRewindLeavingSegment: true, + disableBlurBorder: false, + defaultTimeScale: 1, + allowGrabbingTimeline: true, + enableUserAccounts: false, + defaultDisplayDuration: 3000, + allowMultiplePlaylistsInGUI: false, + poisonKey: 'Escape', + followOnAirSegmentsHistory: 0, + maximumDataAge: 1000 * 60 * 60 * 24 * 100, // 100 days + enableNTPTimeChecker: null, + defaultShelfDisplayOptions: 'buckets,layout,shelfLayout,inspector', + enableKeyboardPreview: false, + keyboardMapLayout: KeyboardLayouts.Names.STANDARD_102_TKL, + useCountdownToFreezeFrame: true, + confirmKeyCode: 'Enter', +}) + +/** + * This is an object specifying installation-wide, User Interface settings. + * There are default values for these settings that will be used, unless overriden + * through Meteor.settings functionality. + * + * You can use METEOR_SETTING to inject the settings JSON or you can use the + * --settings [filename] to provide a JSON file containing the settings + */ +export let Settings: ISettings + +Settings = _.clone(DEFAULT_SETTINGS) + +Meteor.startup(() => { + Settings = _.extend(Settings, Meteor.settings.public) +}) diff --git a/packages/webui/src/lib/__tests__/check.test.ts b/packages/webui/src/lib/__tests__/check.test.ts new file mode 100644 index 0000000000..b882f202d1 --- /dev/null +++ b/packages/webui/src/lib/__tests__/check.test.ts @@ -0,0 +1,56 @@ +import deepExtend from 'deep-extend' +import { check, Match } from '../check' + +describe('lib/check', () => { + test('check basic', () => { + expect(() => check('asdf', String)).not.toThrow() + expect(() => check(123, Number)).not.toThrow() + expect(() => check({ a: 1 }, Object)).not.toThrow() + expect(() => check({}, Object)).not.toThrow() + expect(() => check([1234, 5, 6], Array)).not.toThrow() + expect(() => check(true, Boolean)).not.toThrow() + expect(() => check(false, Boolean)).not.toThrow() + expect(() => check(() => console.log('hello'), Function)).not.toThrow() + + expect(() => check(['asdf', 'asdf2'], [String])).not.toThrow() + expect(() => check([1, 2, 3], [Number])).not.toThrow() + + // Bad values: + expect(() => check(123, String)).toThrow() + expect(() => check('123', Number)).toThrow() + expect(() => check([], Object)).toThrow() + expect(() => check({}, Array)).toThrow() + expect(() => check(123, Array)).toThrow() + expect(() => check(null, Boolean)).toThrow() + expect(() => check(undefined, Boolean)).toThrow() + + expect(() => check(['asdf', 1], [String])).toThrow() + expect(() => check([1, 2, 3, null], [Number])).toThrow() + }) + test('check object', () => { + const val = { + a: 1, + b: { + c: 2, + e: [1, 2, 3], + }, + e: [1, 2, 3], + } + const verify: Match.Pattern = { + a: Number, + b: { + c: Number, + e: [Number], + }, + e: [Number], + } + + expect(() => check(val, verify)).not.toThrow() + + { + const verify2 = deepExtend({}, verify) as any + verify2.b.e = [String] + expect(() => check(val, verify2)).toThrow() + } + }) +}) diff --git a/packages/webui/src/lib/__tests__/lib.test.ts2 b/packages/webui/src/lib/__tests__/lib.test.ts2 new file mode 100644 index 0000000000..e71044efb8 --- /dev/null +++ b/packages/webui/src/lib/__tests__/lib.test.ts2 @@ -0,0 +1,232 @@ +import '../../__mocks__/_extendJest' + +import { Mongo } from 'meteor/mongo' +import { setLogLevel } from '../../server/logging' +import { + getCurrentTime, + systemTime, + formatDateTime, + stringifyObjects, + partial, + protectString, + equalSets, + equivalentArrays, + LogLevel, + MeteorPromiseApply, +} from '../lib' +import { MeteorMock } from '../../__mocks__/meteor' +import { MeteorDebugMethods } from '../../server/methods' +import { Settings } from '../Settings' + +// require('../../../../../server/api/ingest/mosDevice/api.ts') // include in order to create the Meteor methods needed + +describe('lib/lib', () => { + afterEach(() => { + MeteorMock.mockSetServerEnvironment() + }) + + test('MeteorPromiseApply', async () => { + // set up method: + Settings.enableUserAccounts = false + MeteorDebugMethods({ + myMethod: async (value1: string, value2: string) => { + // Do an async operation, to ensure that asynchronous operations work: + const v = await new Promise((resolve) => { + setTimeout(() => { + resolve(value1 + value2) + }, 10) + }) + return v + }, + }) + const pValue: any = MeteorPromiseApply('myMethod', ['myValue', 'AAA']).catch((e) => { + throw e + }) + expect(pValue).toHaveProperty('then') // be a promise + const value = await pValue + expect(value).toEqual('myValueAAA') + }) + test('getCurrentTime', () => { + systemTime.diff = 5439 + MeteorMock.mockSetClientEnvironment() + expect(getCurrentTime() / 1000).toBeCloseTo((Date.now() - 5439) / 1000, 1) + MeteorMock.mockSetServerEnvironment() + expect(getCurrentTime() / 1000).toBeCloseTo(Date.now() / 1000, 1) + }) + + test('formatDateTime', () => { + expect(formatDateTime(1556194064374)).toMatch(/2019-04-\d{2} \d{2}:\d{2}:\d{2}/) + }) + test('stringifyObjects', () => { + const o: any = { + a: 1, + b: { + c: '1', + d: { + e: 2, + }, + }, + } + expect(stringifyObjects(o)).toEqual(stringifyObjects(o)) + }) + test('mongowhere', () => { + setLogLevel(LogLevel.DEBUG) + + // mongoWhere is used my Collection mock + const MyCollection = new Mongo.Collection('mycollection') + + expect(MyCollection.findOne()).toBeFalsy() + + MyCollection.insert({ + _id: protectString('id0'), + name: 'abc', + rank: 0, + }) + MyCollection.insert({ + _id: protectString('id1'), + name: 'abc', + rank: 1, + }) + MyCollection.insert({ + _id: protectString('id2'), + name: 'abcd', + rank: 2, + }) + MyCollection.insert({ + _id: protectString('id3'), + name: 'abcd', + rank: 3, + }) + MyCollection.insert({ + _id: protectString('id4'), + name: 'xyz', + rank: 4, + }) + MyCollection.insert({ + _id: protectString('id5'), + name: 'xyz', + rank: 5, + }) + + expect(MyCollection.find().fetch()).toHaveLength(6) + + expect(MyCollection.find({ _id: protectString('id3') }).fetch()).toHaveLength(1) + expect(MyCollection.find({ _id: protectString('id99') }).fetch()).toHaveLength(0) + + expect(MyCollection.find({ name: 'abcd' }).fetch()).toHaveLength(2) + expect(MyCollection.find({ name: 'xyz' }).fetch()).toHaveLength(2) + expect(MyCollection.find({ name: { $in: ['abc', 'xyz'] } }).fetch()).toHaveLength(4) + + expect(MyCollection.find({ rank: { $gt: 2 } }).fetch()).toHaveLength(3) + expect(MyCollection.find({ rank: { $gte: 2 } }).fetch()).toHaveLength(4) + + expect(MyCollection.find({ rank: { $lt: 3 } }).fetch()).toHaveLength(3) + expect(MyCollection.find({ rank: { $lte: 3 } }).fetch()).toHaveLength(4) + }) + // test('getRank', () => { + // const objs: { _rank: number }[] = [ + // { _rank: 0 }, + // { _rank: 10 }, + // { _rank: 20 }, + // { _rank: 21 }, + // { _rank: 22 }, + // { _rank: 23 }, + // ] + + // // First: + // expect(getRank(null, objs[0])).toEqual(-0.5) + // // Insert two: + // expect(getRank(null, objs[0], 0, 2)).toEqual(-0.6666666666666667) + // expect(getRank(null, objs[0], 1, 2)).toEqual(-0.33333333333333337) + + // // Center: + // expect(getRank(objs[1], objs[2])).toEqual(15) + // // Insert three: + // expect(getRank(objs[1], objs[2], 0, 3)).toEqual(12.5) + // expect(getRank(objs[1], objs[2], 1, 3)).toEqual(15) + // expect(getRank(objs[1], objs[2], 2, 3)).toEqual(17.5) + + // // Last: + // expect(getRank(objs[5], undefined)).toEqual(23.5) + // // Insert three: + // expect(getRank(objs[5], undefined, 0, 3)).toEqual(23.25) + // expect(getRank(objs[5], undefined, 1, 3)).toEqual(23.5) + // expect(getRank(objs[5], undefined, 2, 3)).toEqual(23.75) + + // // Insert in empty list + // expect(getRank(undefined, undefined)).toEqual(0.5) + + // // Insert three: + // expect(getRank(undefined, undefined, 0, 2)).toEqual(0.3333333333333333) + // expect(getRank(undefined, undefined, 1, 2)).toEqual(0.6666666666666666) + // }) + test('partial', () => { + const o = { + a: 1, + b: 'asdf', + c: { + d: 1, + }, + e: null, + f: undefined, + } + expect(partial(o)).toEqual(o) // The function only affects typings + }) + test('formatDateTime', () => { + if (process.platform === 'win32') { + // Due to a bug in how timezones are handled in Windows & Node, we just have to skip these tests when running tests locally.. + expect(0).toEqual(0) + return + } + + expect(new Date().getTimezoneOffset()).toBe(0) // Timezone is UTC + + expect(formatDateTime(1578295344070)).toBe('2020-01-06 07:22:24') + expect(formatDateTime(1578389166594)).toBe('2020-01-07 09:26:06') + expect(formatDateTime(2579299201000)).toBe('2051-09-26 00:00:01') + expect(formatDateTime(2579299200000)).toBe('2051-09-26 00:00:00') + expect(formatDateTime(2579299344070)).toBe('2051-09-26 00:02:24') + }) + + test('equalSets', () => { + expect(equalSets(new Set(['a', 'b', 'c']), new Set(['c', 'b', 'a']))).toBe(true) + expect(equalSets(new Set(['a', 'b', 'c']), new Set(['d', 'b', 'a']))).toBe(false) + }) + test('equivalentArrays', () => { + expect(equivalentArrays(['a', 'b', 'c'], ['c', 'a', 'b'])).toBe(true) + expect(equivalentArrays(['a', 'b', 'c'], ['b', 'g', 'a'])).toBe(false) + }) + // test('makePromise', async () => { + // let a = 0 + // // Check that they are executed in order: + // expect( + // await Promise.all([ + // makePromise(() => { + // return a++ + // }), + // makePromise(() => { + // return a++ + // }), + // ]) + // ).toStrictEqual([0, 1]) + + // // Handle an instant throw: + // await expect( + // makePromise(() => { + // throw new Error('asdf') + // }) + // ).rejects.toMatchToString(/asdf/) + + // // Handle a delayed throw: + // const delayedThrow = Meteor.wrapAsync((callback: (err: any, result: any) => void) => { + // setTimeout(() => { + // callback(new Error('asdf'), null) + // }, 10) + // }) + // await expect( + // makePromise(() => { + // delayedThrow() + // }) + // ).rejects.toMatchToString(/asdf/) + // }) +}) diff --git a/packages/webui/src/lib/__tests__/logging.test.ts b/packages/webui/src/lib/__tests__/logging.test.ts new file mode 100644 index 0000000000..e047c82a06 --- /dev/null +++ b/packages/webui/src/lib/__tests__/logging.test.ts @@ -0,0 +1,11 @@ +import { logger } from '../logging' + +describe('lib/logger', () => { + test('logger', () => { + expect(typeof logger.error).toEqual('function') + expect(typeof logger.warn).toEqual('function') + expect(typeof logger.help).toEqual('function') + expect(typeof logger.info).toEqual('function') + expect(typeof logger.debug).toEqual('function') + }) +}) diff --git a/packages/webui/src/lib/__tests__/memoizedIsolatedAutorun.test.ts b/packages/webui/src/lib/__tests__/memoizedIsolatedAutorun.test.ts new file mode 100644 index 0000000000..b3ab483731 --- /dev/null +++ b/packages/webui/src/lib/__tests__/memoizedIsolatedAutorun.test.ts @@ -0,0 +1,95 @@ +import { MeteorMock } from '../../__mocks__/meteor' +import { memoizedIsolatedAutorun } from '../memoizedIsolatedAutorun' +import { Tracker } from 'meteor/tracker' + +describe('memoizedIsolatedAutorun', () => { + describe('Meteor.isClient', () => { + beforeAll(() => { + MeteorMock.mockSetClientEnvironment() + }) + test('it returns the result of the autorun function', () => { + const dep = new Tracker.Dependency() + const result0 = memoizedIsolatedAutorun(() => { + dep.depend() + return 'result0' + }, 'getResult0') + expect(result0).toBe('result0') + }) + test('it reruns when the dependency is changed', () => { + let runCount = 0 + const dep = new Tracker.Dependency() + const result1 = memoizedIsolatedAutorun(() => { + runCount++ + dep.depend() + return 'result1' + }, 'getResult1') + expect(result1).toBe('result1') + expect(runCount).toBe(1) + dep.changed() + expect(runCount).toBe(2) + }) + test("it invalidates the parent computation if it's dependency has changed and the returned result is different", () => { + let runCount0 = 0 + let runCount1 = 0 + const dep0 = new Tracker.Dependency() + const dep1 = new Tracker.Dependency() + let innerResult = '' + Tracker.autorun(() => { + runCount0++ + dep0.depend() + innerResult = memoizedIsolatedAutorun(() => { + runCount1++ + dep1.depend() + return 'result2_' + runCount1 + }, 'getResult2') + }) + expect(innerResult).toBe('result2_1') + expect(runCount0).toBe(1) + expect(runCount1).toBe(1) + dep1.changed() + expect(innerResult).toBe('result2_2') + expect(runCount0).toBe(2) + expect(runCount1).toBe(2) + }) + test("it doesn't invalidate the parent computation if it's dependency has changed and the returned result is the same", () => { + let runCount0 = 0 + let runCount1 = 0 + const dep0 = new Tracker.Dependency() + const dep1 = new Tracker.Dependency() + Tracker.autorun(() => { + runCount0++ + dep0.depend() + memoizedIsolatedAutorun(() => { + runCount1++ + dep1.depend() + return 'result3' + }, 'getResult3') + }) + expect(runCount0).toBe(1) + expect(runCount1).toBe(1) + dep1.changed() + expect(runCount0).toBe(1) + expect(runCount1).toBe(2) + }) + test("it doesn't rerun if the dependency is not changed, even if the outer computation is invalidated", () => { + let runCount0 = 0 + let runCount1 = 0 + const dep0 = new Tracker.Dependency() + const dep1 = new Tracker.Dependency() + Tracker.autorun(() => { + runCount0++ + dep0.depend() + memoizedIsolatedAutorun(() => { + runCount1++ + dep1.depend() + return 'result4' + }, 'getResult4') + }) + expect(runCount0).toBe(1) + expect(runCount1).toBe(1) + dep0.changed() + expect(runCount0).toBe(2) + expect(runCount1).toBe(1) + }) + }) +}) diff --git a/packages/webui/src/lib/__tests__/systemTime.test.ts2 b/packages/webui/src/lib/__tests__/systemTime.test.ts2 new file mode 100644 index 0000000000..5f341fc817 --- /dev/null +++ b/packages/webui/src/lib/__tests__/systemTime.test.ts2 @@ -0,0 +1,38 @@ +import { runTimersUntilNow } from '../../__mocks__/helpers/jest' +import { TimeJumpDetector } from '../systemTime' + +describe('lib/systemTime', () => { + test('TimeJumpDetector', async () => { + jest.useFakeTimers() + const mockCallback = jest.fn() + let now = Date.now() + let monotonicNow = BigInt(5000 * 1000000) // say it's running for 5 seconds + const mockDateNow = jest.spyOn(global.Date, 'now').mockImplementation(() => now) + const mockProcessHrtime = jest.spyOn(global.process.hrtime, 'bigint').mockImplementation(() => monotonicNow) + + const timeJumpDetector = new TimeJumpDetector(10000, mockCallback) + timeJumpDetector.start() + + jest.advanceTimersByTime(11000) + await runTimersUntilNow() + expect(mockCallback).toHaveBeenCalledTimes(0) + + now += 11000 + monotonicNow += BigInt(11051 * 1000000) + + jest.advanceTimersByTime(11000) + await runTimersUntilNow() + expect(mockCallback).toHaveBeenCalledTimes(1) + mockCallback.mockClear() + + now += 11000 + monotonicNow += BigInt(10951 * 1000000) + + jest.advanceTimersByTime(11000) + await runTimersUntilNow() + expect(mockCallback).toHaveBeenCalledTimes(0) + + mockDateNow.mockRestore() + mockProcessHrtime.mockRestore() + }) +}) diff --git a/packages/webui/src/lib/__tests__/timeline.test.ts b/packages/webui/src/lib/__tests__/timeline.test.ts new file mode 100644 index 0000000000..9a4f6243a5 --- /dev/null +++ b/packages/webui/src/lib/__tests__/timeline.test.ts @@ -0,0 +1,151 @@ +import { transformTimeline } from '@sofie-automation/corelib/dist/playout/timeline' +import { + TimelineObjGeneric, + TimelineObjType, + TimelineObjRundown, +} from '@sofie-automation/corelib/dist/dataModel/Timeline' +import { TSR } from '@sofie-automation/blueprints-integration' + +describe('lib/timeline', () => { + test('transformTimeline', () => { + const timeline: TimelineObjRundown[] = [ + { + id: '0', + objectType: TimelineObjType.RUNDOWN, + enable: { + start: 0, + }, + content: { + deviceType: TSR.DeviceType.ABSTRACT, + }, + layer: 'L1', + priority: 0, + }, + { + id: 'child0', + objectType: TimelineObjType.RUNDOWN, + enable: { + start: 0, + }, + content: { + deviceType: TSR.DeviceType.ABSTRACT, + }, + layer: 'L1', + inGroup: 'group0', + priority: 0, + }, + { + id: 'child1', + objectType: TimelineObjType.RUNDOWN, + enable: { + start: 0, + }, + content: { + deviceType: TSR.DeviceType.ABSTRACT, + }, + layer: 'L1', + inGroup: 'group0', + priority: 0, + }, + { + id: 'group0', + objectType: TimelineObjType.RUNDOWN, + enable: { + start: 0, + }, + content: { + deviceType: TSR.DeviceType.ABSTRACT, + }, + layer: 'L1', + isGroup: true, + priority: 0, + }, + { + id: '2', + objectType: TimelineObjType.RUNDOWN, + enable: { + start: 0, + }, + content: { + deviceType: TSR.DeviceType.ABSTRACT, + callBack: 'partPlaybackStarted', + callBackData: { + rundownId: 'myRundown0', + partId: 'myPart0', + }, + callBackStopped: 'partPlaybackStopped', + }, + layer: 'L1', + // partId: 'myPart0', + priority: 0, + }, + { + id: '3', + objectType: TimelineObjType.RUNDOWN, + enable: { + start: 0, + }, + content: { + deviceType: TSR.DeviceType.ABSTRACT, + callBack: 'piecePlaybackStarted', + callBackData: { + rundownId: 'myRundown0', + pieceId: 'myPiece0', + }, + callBackStopped: 'piecePlaybackStopped', + }, + layer: 'L1', + // @ts-ignore + pieceId: 'myPiece0', + priority: 0, + }, + ] + const transformedTimeline = transformTimeline(timeline) + + expect(transformedTimeline).toHaveLength(4) + + expect(transformedTimeline[0]).toMatchObject({ + id: '0', + }) + expect(transformedTimeline[3]).toMatchObject({ + id: 'group0', + }) + expect(transformedTimeline[3].children).toHaveLength(2) + + expect(transformedTimeline[1]).toMatchObject({ + id: '2', + content: { + callBack: 'partPlaybackStarted', + callBackData: { + rundownId: 'myRundown0', + partId: 'myPart0', + }, + callBackStopped: 'partPlaybackStopped', + }, + }) + expect(transformedTimeline[2]).toMatchObject({ + id: '3', + content: { + callBack: 'piecePlaybackStarted', + callBackData: { + rundownId: 'myRundown0', + pieceId: 'myPiece0', + }, + callBackStopped: 'piecePlaybackStopped', + }, + }) + }) + test('missing id', () => { + expect(() => { + transformTimeline([ + // @ts-ignore missing: id + { + objectType: TimelineObjType.RUNDOWN, + enable: { start: 0 }, + content: { deviceType: TSR.DeviceType.ABSTRACT }, + layer: 'L1', + }, + ] as TimelineObjGeneric[]) + }).toThrow(/missing id/) + }) +}) diff --git a/packages/webui/src/lib/adlibs.ts b/packages/webui/src/lib/adlibs.ts new file mode 100644 index 0000000000..e41e6cccbb --- /dev/null +++ b/packages/webui/src/lib/adlibs.ts @@ -0,0 +1,62 @@ +import type { ITranslatableMessage } from '@sofie-automation/blueprints-integration' +import { isTranslatableMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' +import type { ProtectedString } from './lib' + +function compareLabels(a: string | ITranslatableMessage, b: string | ITranslatableMessage) { + const actualA = isTranslatableMessage(a) ? a.key : (a as string) + const actualB = isTranslatableMessage(b) ? b.key : (b as string) + // can't use .localeCompare, because this needs to be locale-independent and always return + // the same sorting order, because that's being relied upon by limit & pick/pickEnd. + if (actualA > actualB) return 1 + if (actualA < actualB) return -1 + return 0 +} + +/** Sort a list of adlibs */ +export function sortAdlibs( + adlibs: { + adlib: T + label: string | ITranslatableMessage + adlibRank: number + adlibId: ProtectedString | string + partRank: number | null + segmentRank: number | null + rundownRank: number | null + }[] +): T[] { + adlibs = adlibs.sort((a, b) => { + // Sort by rundown rank, where applicable: + a.rundownRank = a.rundownRank ?? Number.POSITIVE_INFINITY + b.rundownRank = b.rundownRank ?? Number.POSITIVE_INFINITY + if (a.rundownRank > b.rundownRank) return 1 + if (a.rundownRank < b.rundownRank) return -1 + + // Sort by segment rank, where applicable: + a.segmentRank = a.segmentRank ?? Number.POSITIVE_INFINITY + b.segmentRank = b.segmentRank ?? Number.POSITIVE_INFINITY + if (a.segmentRank > b.segmentRank) return 1 + if (a.segmentRank < b.segmentRank) return -1 + + // Sort by part rank, where applicable: + a.partRank = a.partRank ?? Number.POSITIVE_INFINITY + b.partRank = b.partRank ?? Number.POSITIVE_INFINITY + if (a.partRank > b.partRank) return 1 + if (a.partRank < b.partRank) return -1 + + // Sort by adlib rank + if (a.adlibRank > b.adlibRank) return 1 + if (a.adlibRank < b.adlibRank) return -1 + + // Sort by labels: + const r = compareLabels(a.label, b.label) + if (r !== 0) return r + + // As a last resort, sort by ids: + if (a.adlibId > b.adlibId) return 1 + if (a.adlibId < b.adlibId) return -1 + + return 0 + }) + + return adlibs.map((a) => a.adlib) +} diff --git a/packages/webui/src/lib/api/ExternalMessageQueue.ts b/packages/webui/src/lib/api/ExternalMessageQueue.ts new file mode 100644 index 0000000000..4f023a7628 --- /dev/null +++ b/packages/webui/src/lib/api/ExternalMessageQueue.ts @@ -0,0 +1,13 @@ +import { ExternalMessageQueueObjId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +export interface NewExternalMessageQueueAPI { + remove(messageId: ExternalMessageQueueObjId): Promise + toggleHold(messageId: ExternalMessageQueueObjId): Promise + retry(messageId: ExternalMessageQueueObjId): Promise +} + +export enum ExternalMessageQueueAPIMethods { + 'remove' = 'externalMessages.remove', + 'toggleHold' = 'externalMessages.toggleHold', + 'retry' = 'externalMessages.retry', +} diff --git a/packages/webui/src/lib/api/__tests__/client.test.ts b/packages/webui/src/lib/api/__tests__/client.test.ts new file mode 100644 index 0000000000..1787998e75 --- /dev/null +++ b/packages/webui/src/lib/api/__tests__/client.test.ts @@ -0,0 +1,76 @@ +import { ClientAPI } from '../client' +import { Meteor } from 'meteor/meteor' +import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' + +describe('ClientAPI', () => { + it('Creates a responseSuccess object', () => { + const mockSuccessValue = { + someData: 'someValue', + } + const response = ClientAPI.responseSuccess(mockSuccessValue) + expect(response).toMatchObject({ + success: 200, + result: mockSuccessValue, + }) + }) + it('Creates a responseError object', () => { + const mockErrorMessage = 'Some error' + + const mockArgs = { a: 'test' } + + { + const error = ClientAPI.responseError(UserError.create(UserErrorMessage.InactiveRundown, mockArgs)) + expect(error).toMatchObject({ + error: { + key: UserErrorMessage.InactiveRundown, + message: { + args: mockArgs, + key: 'Rundown must be active!', + }, + rawError: expect.anything(), + }, + }) + } + + { + const rawErr = new Error(mockErrorMessage) + const error = ClientAPI.responseError(UserError.from(rawErr, UserErrorMessage.InternalError, mockArgs)) + expect(error).toMatchObject({ + error: { + key: UserErrorMessage.InternalError, + message: { + args: mockArgs, + key: 'An internal error occured!', + }, + rawError: rawErr, + }, + }) + } + }) + describe('isClientResponseSuccess', () => { + it('Correctly recognizes a responseSuccess object', () => { + const response = ClientAPI.responseSuccess(undefined) + expect(ClientAPI.isClientResponseSuccess(response)).toBe(true) + }) + it('Correctly recognizes a not-success object', () => { + const response = ClientAPI.responseError(UserError.create(UserErrorMessage.InactiveRundown)) + expect(ClientAPI.isClientResponseSuccess(response)).toBe(false) + }) + it('Correctly recognizes that a Meteor.Error is not a success object', () => { + expect(ClientAPI.isClientResponseSuccess(new Meteor.Error(404))).toBe(false) + }) + }) + describe('isClientResponseError', () => { + it('Correctly recognizes a responseError object', () => { + const response = ClientAPI.responseError(UserError.create(UserErrorMessage.InactiveRundown)) + expect(ClientAPI.isClientResponseError(response)).toBe(true) + }) + it('Correctly regognizes a not-error object', () => { + const response = ClientAPI.responseSuccess(undefined) + expect(ClientAPI.isClientResponseError(response)).toBe(false) + }) + it('Correctly recognizes that a Meteor.Error is not an error object', () => { + expect(ClientAPI.isClientResponseError(new Meteor.Error(404))).toBe(false) + }) + }) +}) diff --git a/packages/webui/src/lib/api/__tests__/pubsub.test.ts b/packages/webui/src/lib/api/__tests__/pubsub.test.ts new file mode 100644 index 0000000000..a3e7f377b6 --- /dev/null +++ b/packages/webui/src/lib/api/__tests__/pubsub.test.ts @@ -0,0 +1,24 @@ +import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' +import { MeteorPubSub } from '../pubsub' +import { PeripheralDevicePubSub } from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' + +describe('Pubsub', () => { + it('Ensures that PubSub values are unique', () => { + const values = new Set() + const runForEnum = (enumType: any) => { + for (const key in enumType) { + if (values.has(key)) + // Throw a meaningful error + throw new Error(`Key "${key}" is already defined`) + + values.add(key) + } + } + + runForEnum(MeteorPubSub) + runForEnum(CorelibPubSub) + runForEnum(PeripheralDevicePubSub) + + expect(values.size).toBeGreaterThan(10) + }) +}) diff --git a/packages/webui/src/lib/api/blueprint.ts b/packages/webui/src/lib/api/blueprint.ts new file mode 100644 index 0000000000..487142b98c --- /dev/null +++ b/packages/webui/src/lib/api/blueprint.ts @@ -0,0 +1,13 @@ +import { BlueprintId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +export interface NewBlueprintAPI { + insertBlueprint(): Promise + removeBlueprint(blueprintId: BlueprintId): Promise + assignSystemBlueprint(blueprintId?: BlueprintId): Promise +} + +export enum BlueprintAPIMethods { + 'insertBlueprint' = 'showstyles.insertBlueprint', + 'removeBlueprint' = 'showstyles.removeBlueprint', + 'assignSystemBlueprint' = 'blueprint.assignSystem', +} diff --git a/packages/webui/src/lib/api/client.ts b/packages/webui/src/lib/api/client.ts new file mode 100644 index 0000000000..34910deb34 --- /dev/null +++ b/packages/webui/src/lib/api/client.ts @@ -0,0 +1,86 @@ +import * as _ from 'underscore' +import { Time } from '../lib' +import { UserError } from '@sofie-automation/corelib/dist/error' +import { NoticeLevel } from '../notifications/notifications' +import { PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { TSR } from '@sofie-automation/blueprints-integration' + +export interface NewClientAPI { + clientErrorReport(timestamp: Time, errorString: string, location: string): Promise + clientLogNotification( + timestamp: Time, + from: string, + severity: NoticeLevel, + message: string, + source?: any + ): Promise + callPeripheralDeviceFunction( + context: string, + deviceId: PeripheralDeviceId, + timeoutTime: number | undefined, + functionName: string, + ...args: any[] + ): Promise + callPeripheralDeviceAction( + context: string, + deviceId: PeripheralDeviceId, + timeoutTime: number | undefined, + actionId: string, + payload?: Record + ): Promise + callBackgroundPeripheralDeviceFunction( + deviceId: PeripheralDeviceId, + timeoutTime: number | undefined, + functionName: string, + ...args: any[] + ): Promise +} + +export enum ClientAPIMethods { + 'clientErrorReport' = 'client.clientErrorReport', + 'clientLogNotification' = 'client.clientLogNotification', + 'callPeripheralDeviceFunction' = 'client.callPeripheralDeviceFunction', + 'callPeripheralDeviceAction' = 'client.callPeripheralDeviceAction', + 'callBackgroundPeripheralDeviceFunction' = 'client.callBackgroundPeripheralDeviceFunction', +} + +export namespace ClientAPI { + /** Response from a method that's called from the client */ + export interface ClientResponseError { + /** On error, return status code (by default, use 500) */ + errorCode: number + /** On error, provide a human-readable error message */ + error: UserError + } + /** + * Used to reply to the user that the action didn't succeed (but it's not bad enough to log it as an error) + * @param errorMessage + */ + export function responseError(error: UserError, errorCode?: number): ClientResponseError { + return { error, errorCode: errorCode ?? 500 } + } + export interface ClientResponseSuccess { + /** On success, return success code (by default, use 200) */ + success: number + /** Optionally, provide method result */ + result: Result + } + export function responseSuccess(result: Result, code?: number): ClientResponseSuccess { + if (isClientResponseSuccess(result)) result = result.result + else if (isClientResponseError(result)) throw result.error + + return { + success: code ?? 200, + result, + } + } + export type ClientResponse = ClientResponseError | ClientResponseSuccess + export function isClientResponseError(res: unknown): res is ClientResponseError { + const res0 = res as Partial + return !!res0 && typeof res0 === 'object' && 'error' in res0 && UserError.isUserError(res0.error) + } + export function isClientResponseSuccess(res: unknown): res is ClientResponseSuccess { + const res0 = res as any + return !!(_.isObject(res0) && !_.isArray(res0) && res0.error === undefined && res0.success) + } +} diff --git a/packages/webui/src/lib/api/mediaManager.ts b/packages/webui/src/lib/api/mediaManager.ts new file mode 100644 index 0000000000..1a5d788930 --- /dev/null +++ b/packages/webui/src/lib/api/mediaManager.ts @@ -0,0 +1,8 @@ +import * as Shared from '@sofie-automation/shared-lib/dist/peripheralDevice/mediaManager' +export namespace MediaManagerAPI { + export type WorkStepStatus = Shared.WorkStepStatus + export const WorkStepStatus = Shared.WorkStepStatus + + export type WorkStepAction = Shared.WorkStepAction + export const WorkStepAction = Shared.WorkStepAction +} diff --git a/packages/webui/src/lib/api/methods.ts b/packages/webui/src/lib/api/methods.ts new file mode 100644 index 0000000000..40c4a513d0 --- /dev/null +++ b/packages/webui/src/lib/api/methods.ts @@ -0,0 +1,108 @@ +import * as _ from 'underscore' +import { MeteorPromiseApply } from '../lib' +import { NewBlueprintAPI, BlueprintAPIMethods } from './blueprint' +import { NewClientAPI, ClientAPIMethods } from './client' +import { NewExternalMessageQueueAPI, ExternalMessageQueueAPIMethods } from './ExternalMessageQueue' +import { NewMigrationAPI, MigrationAPIMethods } from './migration' +import { NewPlayoutAPI, PlayoutAPIMethods } from './playout' +import { NewRundownAPI, RundownAPIMethods } from './rundown' +import { NewRundownLayoutsAPI, RundownLayoutsAPIMethods } from './rundownLayouts' +import { NewShowStylesAPI, ShowStylesAPIMethods } from './showStyles' +import { NewSnapshotAPI, SnapshotAPIMethods } from './shapshot' +import { NewSystemStatusAPI, SystemStatusAPIMethods } from './systemStatus' +import { NewUserActionAPI, UserActionAPIMethods } from './userActions' +import { StudiosAPIMethods, NewStudiosAPI } from './studios' +import { NewOrganizationAPI, OrganizationAPIMethods } from './organization' +import { NewUserAPI, UserAPIMethods } from './user' +import { SystemAPIMethods, SystemAPI } from './system' +import { Meteor } from 'meteor/meteor' +import { NewTriggeredActionsAPI, TriggeredActionsAPIMethods } from './triggeredActions' +import { UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { + NewPeripheralDeviceAPI, + PeripheralDeviceAPIMethods, +} from '@sofie-automation/shared-lib/dist/peripheralDevice/methodsAPI' + +/** All methods typings are defined here, the actual implementation is defined in other places */ +export type MethodsBase = { + [key: string]: (...args: any[]) => Promise +} +interface IMeteorCall { + blueprint: NewBlueprintAPI + client: NewClientAPI + externalMessages: NewExternalMessageQueueAPI + migration: NewMigrationAPI + peripheralDevice: NewPeripheralDeviceAPI + playout: NewPlayoutAPI + rundown: NewRundownAPI + rundownLayout: NewRundownLayoutsAPI + snapshot: NewSnapshotAPI + showstyles: NewShowStylesAPI + triggeredActions: NewTriggeredActionsAPI + studio: NewStudiosAPI + systemStatus: NewSystemStatusAPI + user: NewUserAPI + userAction: NewUserActionAPI + organization: NewOrganizationAPI + system: SystemAPI +} +export const MeteorCall: IMeteorCall = { + blueprint: makeMethods(BlueprintAPIMethods), + client: makeMethods(ClientAPIMethods), + externalMessages: makeMethods(ExternalMessageQueueAPIMethods), + migration: makeMethods(MigrationAPIMethods), + peripheralDevice: makeMethods(PeripheralDeviceAPIMethods), + playout: makeMethods(PlayoutAPIMethods), + rundown: makeMethods(RundownAPIMethods), + rundownLayout: makeMethods(RundownLayoutsAPIMethods), + snapshot: makeMethods(SnapshotAPIMethods), + showstyles: makeMethods(ShowStylesAPIMethods), + triggeredActions: makeMethods(TriggeredActionsAPIMethods), + studio: makeMethods(StudiosAPIMethods), + systemStatus: makeMethods(SystemStatusAPIMethods), + user: makeMethods(UserAPIMethods), + userAction: makeMethods(UserActionAPIMethods, ['storeRundownSnapshot']), + organization: makeMethods(OrganizationAPIMethods), + system: makeMethods(SystemAPIMethods), +} +function makeMethods( + methods: Enum, + /** (Optional) An array of methodnames. Calls to these methods won't be retried in the case of a loss-of-connection for the client. */ + listOfMethodsThatShouldNotRetry?: (keyof Enum)[] +): any { + const resultingMethods: Record any> = {} + _.each(methods, (serverMethodName: any, methodName: string) => { + if (listOfMethodsThatShouldNotRetry?.includes(methodName)) { + resultingMethods[methodName] = async (...args: any[]) => + MeteorPromiseApply(serverMethodName, args, { + noRetry: true, + }) + } else { + resultingMethods[methodName] = async (...args: any[]) => MeteorPromiseApply(serverMethodName, args) + } + }) + return resultingMethods +} +export interface MethodContext extends Omit { + userId: UserId | null +} + +/** Abstarct class to be used when defining Mehod-classes */ +export abstract class MethodContextAPI implements MethodContext { + // These properties are added by Meteor to the `this` context when calling methods + public userId!: UserId | null + public isSimulation!: boolean + public setUserId(_userId: string | null): void { + throw new Meteor.Error( + 500, + `This shoulc never be called, there's something wrong in with 'this' in the calling method` + ) + } + public unblock(): void { + throw new Meteor.Error( + 500, + `This shoulc never be called, there's something wrong in with 'this' in the calling method` + ) + } + public connection!: Meteor.Connection | null +} diff --git a/packages/webui/src/lib/api/migration.ts b/packages/webui/src/lib/api/migration.ts new file mode 100644 index 0000000000..40df1b77a0 --- /dev/null +++ b/packages/webui/src/lib/api/migration.ts @@ -0,0 +1,124 @@ +import { MigrationStepInput, MigrationStepInputResult } from '@sofie-automation/blueprints-integration' +import { BlueprintId, ShowStyleBaseId, SnapshotId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ITranslatableMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' +import { BlueprintValidateConfigForStudioResult } from '@sofie-automation/corelib/dist/worker/studio' + +export interface BlueprintFixUpConfigMessage { + message: ITranslatableMessage + path: string +} + +export interface NewMigrationAPI { + getMigrationStatus(): Promise + runMigration( + chunks: Array, + hash: string, + inputResults: Array, + isFirstOfPartialMigrations?: boolean + ): Promise + forceMigration(chunks: Array): Promise + resetDatabaseVersions(): Promise + + /** + * Run `fixupConfig` on the blueprint for a Studio + * @param studioId Id of the Studio + */ + fixupConfigForStudio(studioId: StudioId): Promise + + /** + * Ignore that `fixupConfig` needs to be run for a Studio + * @param studioId Id of the Studio + */ + ignoreFixupConfigForStudio(studioId: StudioId): Promise + + /** + * Run `validateConfig` on the blueprint for a Studio + * @param studioId Id of the Studio + * @returns List of messages to display to the user + */ + validateConfigForStudio(studioId: StudioId): Promise + + /** + * Run `applyConfig` on the blueprint for a Studio, and store the results into the db + * @param studioId Id of the Studio + */ + runUpgradeForStudio(studioId: StudioId): Promise + + /** + * Run `fixupConfig` on the blueprint for a ShowStyleBase + * @param showStyleBaseId Id of the ShowStyleBase + */ + fixupConfigForShowStyleBase(showStyleBaseId: ShowStyleBaseId): Promise + + /** + * Ignore that `fixupConfig` needs to be run for a ShowStyleBase + * @param showStyleBaseId Id of the ShowStyleBase + */ + ignoreFixupConfigForShowStyleBase(showStyleBaseId: ShowStyleBaseId): Promise + + /** + * Run `validateConfig` on the blueprint for a ShowStyleBase + * @param showStyleBaseId Id of the ShowStyleBase + * @returns List of messages to display to the user + */ + validateConfigForShowStyleBase(showStyleBaseId: ShowStyleBaseId): Promise + + /** + * Run `applyConfig` on the blueprint for a Studio, and store the results into the db + * @param studioId Id of the Studio + */ + runUpgradeForShowStyleBase(showStyleBaseId: ShowStyleBaseId): Promise +} + +export enum MigrationAPIMethods { + 'getMigrationStatus' = 'migration.getMigrationStatus', + 'runMigration' = 'migration.runMigration', + 'forceMigration' = 'migration.forceMigration', + 'resetDatabaseVersions' = 'migration.resetDatabaseVersions', + + 'getUpgradeStatus' = 'migration.getUpgradeStatus', + 'fixupConfigForStudio' = 'migration.fixupConfigForStudio', + 'ignoreFixupConfigForStudio' = 'migration.ignoreFixupConfigForStudio', + 'validateConfigForStudio' = 'migration.validateConfigForStudio', + 'runUpgradeForStudio' = 'migration.runUpgradeForStudio', + 'fixupConfigForShowStyleBase' = 'migration.fixupConfigForShowStyleBase', + 'ignoreFixupConfigForShowStyleBase' = 'migration.ignoreFixupConfigForShowStyleBase', + 'validateConfigForShowStyleBase' = 'migration.validateConfigForShowStyleBase', + 'runUpgradeForShowStyleBase' = 'migration.runUpgradeForShowStyleBase', +} + +export interface GetMigrationStatusResult { + migrationNeeded: boolean + + migration: { + canDoAutomaticMigration: boolean + manualInputs: Array + hash: string + automaticStepCount: number + manualStepCount: number + ignoredStepCount: number + partialMigration: boolean + chunks: Array + } +} +export interface RunMigrationResult { + migrationCompleted: boolean + partialMigration: boolean + warnings: Array + snapshot: SnapshotId +} +export enum MigrationStepType { + CORE = 'core', + SYSTEM = 'system', + STUDIO = 'studio', + SHOWSTYLE = 'showstyle', +} +export interface MigrationChunk { + sourceType: MigrationStepType + sourceName: string + blueprintId?: BlueprintId // blueprint id + sourceId?: ShowStyleBaseId | StudioId | 'system' // id in blueprint databaseVersions + _dbVersion: string // database version + _targetVersion: string // target version + _steps: Array // ref to step that use it +} diff --git a/packages/webui/src/lib/api/organization.ts b/packages/webui/src/lib/api/organization.ts new file mode 100644 index 0000000000..5c1289cb9d --- /dev/null +++ b/packages/webui/src/lib/api/organization.ts @@ -0,0 +1,9 @@ +import { OrganizationId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +export interface NewOrganizationAPI { + removeOrganization(organizationId: OrganizationId): Promise +} + +export enum OrganizationAPIMethods { + 'removeOrganization' = 'organization.removeOrganization', +} diff --git a/packages/webui/src/lib/api/pieceContentStatus.ts b/packages/webui/src/lib/api/pieceContentStatus.ts new file mode 100644 index 0000000000..d6612512c2 --- /dev/null +++ b/packages/webui/src/lib/api/pieceContentStatus.ts @@ -0,0 +1,21 @@ +import { PackageInfo } from '@sofie-automation/blueprints-integration' +import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' +import { ITranslatableMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' + +export interface PieceContentStatusObj { + status: PieceStatusCode + messages: ITranslatableMessage[] + + freezes: Array + blacks: Array + scenes: Array + + thumbnailUrl: string | undefined + previewUrl: string | undefined + + packageName: string | null + + contentDuration: number | undefined + + progress: number | undefined +} diff --git a/packages/webui/src/lib/api/playout.ts b/packages/webui/src/lib/api/playout.ts new file mode 100644 index 0000000000..ac9b5a9737 --- /dev/null +++ b/packages/webui/src/lib/api/playout.ts @@ -0,0 +1,11 @@ +import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +export interface NewPlayoutAPI { + updateStudioBaseline(studioId: StudioId): Promise + shouldUpdateStudioBaseline(studioId: StudioId): Promise +} + +export enum PlayoutAPIMethods { + 'updateStudioBaseline' = 'playout.updateStudioBaseline', + 'shouldUpdateStudioBaseline' = 'playout.shouldUpdateStudioBaseline', +} diff --git a/packages/webui/src/lib/api/pubsub.ts b/packages/webui/src/lib/api/pubsub.ts new file mode 100644 index 0000000000..91d5d66cd0 --- /dev/null +++ b/packages/webui/src/lib/api/pubsub.ts @@ -0,0 +1,307 @@ +import { + BucketId, + OrganizationId, + PartId, + RundownPlaylistId, + ShowStyleBaseId, + StudioId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { Meteor } from 'meteor/meteor' +import { Bucket } from '../collections/Buckets' +import { ICoreSystem } from '../collections/CoreSystem' +import { Evaluation } from '../collections/Evaluations' +import { ExpectedPlayoutItem } from '@sofie-automation/corelib/dist/dataModel/ExpectedPlayoutItem' +import { MediaWorkFlow } from '@sofie-automation/shared-lib/dist/core/model/MediaWorkFlows' +import { MediaWorkFlowStep } from '@sofie-automation/shared-lib/dist/core/model/MediaWorkFlowSteps' +import { DBOrganization } from '../collections/Organization' +import { RundownLayoutBase } from '../collections/RundownLayouts' +import { SnapshotItem } from '../collections/Snapshots' +import { TranslationsBundle } from '../collections/TranslationsBundles' +import { DBTriggeredActions, UITriggeredActionsObj } from '../collections/TriggeredActions' +import { UserActionsLogItem } from '../collections/UserActionsLog' +import { DBUser } from '../collections/Users' +import { UIBucketContentStatus, UIPieceContentStatus, UISegmentPartNote } from './rundownNotifications' +import { UIShowStyleBase } from './showStyles' +import { UIStudio } from './studios' +import { UIDeviceTriggerPreview } from './triggers/MountedTriggers' +import { logger } from '../logging' +import { UIBlueprintUpgradeStatus } from './upgradeStatus' +import { + PeripheralDevicePubSub, + PeripheralDevicePubSubTypes, + PeripheralDevicePubSubCollections, + PeripheralDevicePubSubCollectionsNames, +} from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' +import { CorelibPubSub, CorelibPubSubCollections, CorelibPubSubTypes } from '@sofie-automation/corelib/dist/pubsub' +import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' + +/** + * Ids of possible DDP subscriptions for the UI only + */ +export enum MeteorPubSub { + /** + * Fetch the CoreSystem document + */ + coreSystem = 'coreSystem', + /** + * Fetch all User Evaluations for the specified time range + */ + evaluations = 'evaluations', + + /** + * Fetch RundownPlaylists for the specified Studio, limited to either active or inactive playlists + */ + rundownPlaylistForStudio = 'rundownPlaylistForStudio', + /** + * Fetch all the AdlibActions for specified PartId, limited to the specified sourceLayerIds + */ + adLibActionsForPart = 'adLibActionsForPart', + /** + * Fetch all the AdlibPieces for specified PartId, limited to the specified sourceLayerIds + */ + adLibPiecesForPart = 'adLibPiecesForPart', + + /** + * Fetch either all TriggeredActions or limited to the specified ShowStyleBases + */ + triggeredActions = 'triggeredActions', + /** + * Fetch all the Snapshots in the system + */ + snapshots = 'snapshots', + /** + * Fetch all User Action Log entries for the specified time range + */ + userActionsLog = 'userActionsLog', + /** + * Fetch all MediaManager workflows in the system + * @deprecated + */ + mediaWorkFlows = 'mediaWorkFlows', + /** + * Fetch all MediaManager workflow steps in the system + * @deprecated + */ + mediaWorkFlowSteps = 'mediaWorkFlowSteps', + /** + * Fetch either all RundownLayouts or limited to the specified ShowStyleBases + */ + rundownLayouts = 'rundownLayouts', + /** + * Fetch information about the current logged in user, if any + */ + loggedInUser = 'loggedInUser', + /** + * Fetch information about all users for a given organization + */ + usersInOrganization = 'usersInOrganization', + /** + * Fetch information about a specified organization. + * If null is provided, nothing will be returned + */ + organization = 'organization', + /** + * Fetch either all buckets for the given Studio, or the Bucket specified. + */ + buckets = 'buckets', + /** + * Fetch all translation bundles + */ + translationsBundles = 'translationsBundles', + + // custom publications: + + /** + * Fetch the simplified timeline mappings for a given studio + */ + mappingsForStudio = 'mappingsForStudio', + /** + * Fetch the simplified timeline for a given studio + */ + timelineForStudio = 'timelineForStudio', + + /** + * Fetch the simplified playout UI view of the specified ShowStyleBase + */ + uiShowStyleBase = 'uiShowStyleBase', + /** + * Fetch the simplified playout UI view of the specified Studio. + * If the id is null, all studios will be returned + */ + uiStudio = 'uiStudio', + /** + * Fetch the simplified playout UI view of the TriggeredActions in the specified ShowStyleBase. + * If the id is null, only global TriggeredActions will be returned + */ + uiTriggeredActions = 'uiTriggeredActions', + + /** + * Fetch the calculated trigger previews for the given Studio + */ + deviceTriggersPreview = 'deviceTriggersPreview', + + /** + * Fetch the Segment and Part notes in the given RundownPlaylist + * If the id is null, nothing will be returned + */ + uiSegmentPartNotes = 'uiSegmentPartNotes', + /** + * Fetch the Pieces content-status in the given RundownPlaylist + * If the id is null, nothing will be returned + */ + uiPieceContentStatuses = 'uiPieceContentStatuses', + /** + * Fetch the Pieces content-status in the given Bucket + */ + uiBucketContentStatuses = 'uiBucketContentStatuses', + /** + * Fetch the Upgrade Statuses of all Blueprints in the system + */ + uiBlueprintUpgradeStatuses = 'uiBlueprintUpgradeStatuses', +} + +/** + * Names of all the known DDP publications + */ +export const AllPubSubNames: string[] = [ + ...Object.values(MeteorPubSub), + ...Object.values(CorelibPubSub), + ...Object.values(PeripheralDevicePubSub), +] + +/** + * Type definitions for all DDP subscriptions. + * All the PubSub ids must be present here, or they will produce type errors when used + */ +export type AllPubSubTypes = CorelibPubSubTypes & PeripheralDevicePubSubTypes & MeteorPubSubTypes + +export interface MeteorPubSubTypes { + [MeteorPubSub.coreSystem]: (token?: string) => CollectionName.CoreSystem + [MeteorPubSub.evaluations]: (dateFrom: number, dateTo: number, token?: string) => CollectionName.Evaluations + + [MeteorPubSub.rundownPlaylistForStudio]: (studioId: StudioId, isActive: boolean) => CollectionName.RundownPlaylists + [MeteorPubSub.adLibActionsForPart]: (partId: PartId, sourceLayerIds: string[]) => CollectionName.AdLibActions + [MeteorPubSub.adLibPiecesForPart]: (partId: PartId, sourceLayerIds: string[]) => CollectionName.AdLibPieces + + [MeteorPubSub.triggeredActions]: ( + /** ShowStyleBaseIds to fetch for, or null to just fetch global */ + showStyleBaseIds: ShowStyleBaseId[] | null, + token?: string + ) => CollectionName.TriggeredActions + [MeteorPubSub.snapshots]: (token?: string) => CollectionName.Snapshots + [MeteorPubSub.userActionsLog]: (dateFrom: number, dateTo: number, token?: string) => CollectionName.UserActionsLog + /** @deprecated */ + [MeteorPubSub.mediaWorkFlows]: (token?: string) => CollectionName.MediaWorkFlows + /** @deprecated */ + [MeteorPubSub.mediaWorkFlowSteps]: (token?: string) => CollectionName.MediaWorkFlowSteps + [MeteorPubSub.rundownLayouts]: ( + /** ShowStyleBaseIds to fetch for, or null to fetch all */ + showStyleBaseIds: ShowStyleBaseId[] | null, + token?: string + ) => CollectionName.RundownLayouts + [MeteorPubSub.loggedInUser]: (token?: string) => CollectionName.Users + [MeteorPubSub.usersInOrganization]: (organizationId: OrganizationId, token?: string) => CollectionName.Users + [MeteorPubSub.organization]: (organizationId: OrganizationId | null, token?: string) => CollectionName.Organizations + [MeteorPubSub.buckets]: (studioId: StudioId, bucketId: BucketId | null, token?: string) => CollectionName.Buckets + [MeteorPubSub.translationsBundles]: (token?: string) => CollectionName.TranslationsBundles + + // custom publications: + + [MeteorPubSub.mappingsForStudio]: ( + studioId: StudioId, + token?: string + ) => PeripheralDevicePubSubCollectionsNames.studioMappings + [MeteorPubSub.timelineForStudio]: ( + studioId: StudioId, + token?: string + ) => PeripheralDevicePubSubCollectionsNames.studioTimeline + [MeteorPubSub.uiShowStyleBase]: (showStyleBaseId: ShowStyleBaseId) => CustomCollectionName.UIShowStyleBase + /** Subscribe to one or all studios */ + [MeteorPubSub.uiStudio]: (studioId: StudioId | null) => CustomCollectionName.UIStudio + [MeteorPubSub.uiTriggeredActions]: ( + showStyleBaseId: ShowStyleBaseId | null + ) => CustomCollectionName.UITriggeredActions + + [MeteorPubSub.deviceTriggersPreview]: ( + studioId: StudioId, + token?: string + ) => CustomCollectionName.UIDeviceTriggerPreviews + + /** Custom publications for the UI */ + [MeteorPubSub.uiSegmentPartNotes]: (playlistId: RundownPlaylistId | null) => CustomCollectionName.UISegmentPartNotes + [MeteorPubSub.uiPieceContentStatuses]: ( + rundownPlaylistId: RundownPlaylistId | null + ) => CustomCollectionName.UIPieceContentStatuses + [MeteorPubSub.uiBucketContentStatuses]: ( + studioId: StudioId, + bucketId: BucketId + ) => CustomCollectionName.UIBucketContentStatuses + [MeteorPubSub.uiBlueprintUpgradeStatuses]: () => CustomCollectionName.UIBlueprintUpgradeStatuses +} + +export type AllPubSubCollections = PeripheralDevicePubSubCollections & + CorelibPubSubCollections & + MeteorPubSubCollections + +/** + * Ids of possible Custom collections, populated by DDP subscriptions + */ +export enum CustomCollectionName { + UIShowStyleBase = 'uiShowStyleBase', + UIStudio = 'uiStudio', + UITriggeredActions = 'uiTriggeredActions', + UIDeviceTriggerPreviews = 'deviceTriggerPreviews', + UISegmentPartNotes = 'uiSegmentPartNotes', + UIPieceContentStatuses = 'uiPieceContentStatuses', + UIBucketContentStatuses = 'uiBucketContentStatuses', + UIBlueprintUpgradeStatuses = 'uiBlueprintUpgradeStatuses', +} + +export type MeteorPubSubCollections = { + [CollectionName.CoreSystem]: ICoreSystem + [CollectionName.Evaluations]: Evaluation + [CollectionName.TriggeredActions]: DBTriggeredActions + [CollectionName.Snapshots]: SnapshotItem + [CollectionName.UserActionsLog]: UserActionsLogItem + [CollectionName.RundownLayouts]: RundownLayoutBase + [CollectionName.Organizations]: DBOrganization + [CollectionName.Buckets]: Bucket + [CollectionName.TranslationsBundles]: TranslationsBundle + [CollectionName.Users]: DBUser + [CollectionName.ExpectedPlayoutItems]: ExpectedPlayoutItem + + [CollectionName.MediaWorkFlows]: MediaWorkFlow + [CollectionName.MediaWorkFlowSteps]: MediaWorkFlowStep +} & MeteorPubSubCustomCollections + +export type MeteorPubSubCustomCollections = { + [CustomCollectionName.UIShowStyleBase]: UIShowStyleBase + [CustomCollectionName.UIStudio]: UIStudio + [CustomCollectionName.UITriggeredActions]: UITriggeredActionsObj + [CustomCollectionName.UIDeviceTriggerPreviews]: UIDeviceTriggerPreview + [CustomCollectionName.UISegmentPartNotes]: UISegmentPartNote + [CustomCollectionName.UIPieceContentStatuses]: UIPieceContentStatus + [CustomCollectionName.UIBucketContentStatuses]: UIBucketContentStatus + [CustomCollectionName.UIBlueprintUpgradeStatuses]: UIBlueprintUpgradeStatus +} + +/** + * Type safe wrapper around Meteor.subscribe() + * @param name name of the subscription + * @param args arguments to the subscription + * @returns Meteor subscription handle + */ +export function meteorSubscribe( + name: K, + ...args: Parameters +): Meteor.SubscriptionHandle { + if (Meteor.isClient) { + const callbacks = { + onError: (...errs: any[]) => { + logger.error('meteorSubscribe', name, ...args, ...errs) + }, + } + + return Meteor.subscribe(name, ...args, callbacks) + } else throw new Meteor.Error(500, 'meteorSubscribe is only available client-side') +} diff --git a/packages/webui/src/lib/api/rest/v1/blueprints.ts b/packages/webui/src/lib/api/rest/v1/blueprints.ts new file mode 100644 index 0000000000..a22045523c --- /dev/null +++ b/packages/webui/src/lib/api/rest/v1/blueprints.ts @@ -0,0 +1,42 @@ +import { BlueprintId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { Meteor } from 'meteor/meteor' +import { ClientAPI } from '../../client' + +/* ************************************************************************* +This file contains types and interfaces that are used by the REST API. +When making changes to these types, you should be aware of any breaking changes +and update packages/openapi accordingly if needed. +************************************************************************* */ + +export interface BlueprintsRestAPI { + /* + * Gets all available Blueprints. + * @param connection Connection data including client and header details + * @param event User event string + */ + getAllBlueprints( + connection: Meteor.Connection, + event: string + ): Promise>> + /** + * Gets a specific Blueprint. + * + * Throws if the specified Blueprint does not exist. + * Throws if the specified Blueprint is of unknown type. + * @param connection Connection data including client and header details + * @param event User event string + * @param blueprintId Blueprint to fetch + */ + getBlueprint( + connection: Meteor.Connection, + event: string, + blueprintId: BlueprintId + ): Promise> +} + +export interface APIBlueprint { + id: string + name: string + blueprintType: 'system' | 'studio' | 'showstyle' + blueprintVersion: string +} diff --git a/packages/webui/src/lib/api/rest/v1/buckets.ts b/packages/webui/src/lib/api/rest/v1/buckets.ts new file mode 100644 index 0000000000..6df295761d --- /dev/null +++ b/packages/webui/src/lib/api/rest/v1/buckets.ts @@ -0,0 +1,120 @@ +import { Meteor } from 'meteor/meteor' +import { ClientAPI } from '../../client' +import { BucketId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { IngestAdlib } from '@sofie-automation/blueprints-integration' + +export interface BucketsRestAPI { + /** + * Get all available Buckets. + * + * @param connection Connection data including client and header details + * @param event User event string + * @param inputs Migration data to apply + */ + getAllBuckets( + connection: Meteor.Connection, + event: string + ): Promise>> + + /** + * Get a Bucket. + * + * @param connection Connection data including client and header details + * @param event User event string + * @param inputs Migration data to apply + */ + getBucket( + connection: Meteor.Connection, + event: string, + bucketId: BucketId + ): Promise> + + /** + * Adds a new Bucket, returns the Id of the newly created Bucket. + * + * @param connection Connection data including client and header details + * @param event User event string + * @param bucket Bucket to add + */ + addBucket( + connection: Meteor.Connection, + event: string, + bucket: APIBucket + ): Promise> + + /** + * Deletes a Bucket. + * + * @param connection Connection data including client and header details + * @param event User event string + * @param bucketId Id of the bucket to delete + */ + deleteBucket( + connection: Meteor.Connection, + event: string, + bucketId: BucketId + ): Promise> + + /** + * Empties a Bucket. + * + * @param connection Connection data including client and header details + * @param event User event string + * @param bucketId Id of the bucket to empty + */ + emptyBucket( + connection: Meteor.Connection, + event: string, + bucketId: BucketId + ): Promise> + + /** + * Deletes a Bucket AdLib. + * + * @param connection Connection data including client and header details + * @param event User event string + * @param adLibId Id of the bucket adlib to delete + */ + deleteBucketAdLib( + connection: Meteor.Connection, + event: string, + externalId: string + ): Promise> + + /** + * Imports a Bucket AdLib. + * If adlibs with the same `ingestItem.externalId` already exist in the bucket, they will be replaced. + * + * @param connection Connection data including client and header details + * @param event User event string + * @param bucketId Id of the bucket where to import the adlib + * @param showStyleBaseId Id of the showStyle to use when importing the adlib + * @param ingestItem Adlib to be imported + */ + importAdLibToBucket( + connection: Meteor.Connection, + event: string, + bucketId: BucketId, + showStyleBaseId: ShowStyleBaseId, + ingestItem: IngestAdlib + ): Promise> +} + +export interface APIBucket { + name: string + studioId: string +} + +export interface APIBucketComplete extends APIBucket { + id: string +} + +// Based on the IngestAdlib interface +export interface APIImportAdlib { + externalId: string + name: string + payloadType: string + payload?: unknown + + showStyleBaseId: string +} diff --git a/packages/webui/src/lib/api/rest/v1/devices.ts b/packages/webui/src/lib/api/rest/v1/devices.ts new file mode 100644 index 0000000000..bf0256c1a4 --- /dev/null +++ b/packages/webui/src/lib/api/rest/v1/devices.ts @@ -0,0 +1,84 @@ +import { ClientAPI } from '../../client' +import { PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { Meteor } from 'meteor/meteor' + +/* ************************************************************************* +This file contains types and interfaces that are used by the REST API. +When making changes to these types, you should be aware of any breaking changes +and update packages/openapi accordingly if needed. +************************************************************************* */ + +export interface DevicesRestAPI { + /** + * Gets all devices attached to Sofie. + * + * @param connection Connection data including client and header details + * @param event User event string + */ + getPeripheralDevices( + connection: Meteor.Connection, + event: string + ): Promise>> + /** + * Get a specific device. + * + * Throws if the requested device does not exist. + * @param connection Connection data including client and header details + * @param event User event string + * @param deviceId Device to get + */ + getPeripheralDevice( + connection: Meteor.Connection, + event: string, + deviceId: PeripheralDeviceId + ): Promise> + /** + * Send an action to a device. + * + * Throws if the requested device does not exits. + * Throws if the action is not valid for the requested device. + * @param connection Connection data including client and header details + * @param event User event string + * @param deviceId Device to target + * @param action Action to perform + */ + peripheralDeviceAction( + connection: Meteor.Connection, + event: string, + deviceId: PeripheralDeviceId, + action: PeripheralDeviceAction + ): Promise> +} + +// This interface should be auto-generated in future +export interface APIPeripheralDevice { + id: string + name: string + status: 'unknown' | 'good' | 'warning_major' | 'marning_minor' | 'bad' | 'fatal' + messages: string[] + deviceType: + | 'unknown' + | 'mos' + | 'spreadsheet' + | 'inews' + | 'playout' + | 'media_manager' + | 'package_manager' + | 'live_status' + | 'input' + connected: boolean +} + +export enum PeripheralDeviceActionType { + RESTART = 'restart', +} + +export interface PeripheralDeviceActionBase { + type: PeripheralDeviceActionType +} + +export interface PeripheralDeviceActionRestart extends PeripheralDeviceActionBase { + type: PeripheralDeviceActionType.RESTART +} + +export type PeripheralDeviceAction = PeripheralDeviceActionRestart diff --git a/packages/webui/src/lib/api/rest/v1/index.ts b/packages/webui/src/lib/api/rest/v1/index.ts new file mode 100644 index 0000000000..b539ccde63 --- /dev/null +++ b/packages/webui/src/lib/api/rest/v1/index.ts @@ -0,0 +1,7 @@ +export * from './blueprints' +export * from './buckets' +export * from './devices' +export * from './playlists' +export * from './showstyles' +export * from './studios' +export * from './system' diff --git a/packages/webui/src/lib/api/rest/v1/playlists.ts b/packages/webui/src/lib/api/rest/v1/playlists.ts new file mode 100644 index 0000000000..d3dcbb7adf --- /dev/null +++ b/packages/webui/src/lib/api/rest/v1/playlists.ts @@ -0,0 +1,261 @@ +import { ClientAPI } from '../../client' +import { + AdLibActionId, + BucketAdLibId, + BucketId, + PartId, + PartInstanceId, + PieceId, + RundownBaselineAdLibActionId, + RundownPlaylistId, + SegmentId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { QueueNextSegmentResult } from '@sofie-automation/corelib/dist/worker/studio' +import { Meteor } from 'meteor/meteor' + +/* ************************************************************************* +This file contains types and interfaces that are used by the REST API. +When making changes to these types, you should be aware of any breaking changes +and update packages/openapi accordingly if needed. +************************************************************************* */ + +export interface PlaylistsRestAPI { + /** + * Gets all available RundownPlaylists. + * @param connection Connection data including client and header details + * @param event User event string + */ + getAllRundownPlaylists( + connection: Meteor.Connection, + event: string + ): Promise>> + /** + * Activates a Playlist. + * + * Throws if there is already an active Playlist for the studio that the Playlist belongs to. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Playlist to activate. + * @param rehearsal Whether to activate into rehearsal mode. + */ + activate( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + rehearsal: boolean + ): Promise> + /** + * Deactivates a Playlist. + * + * Throws if the Playlist is not currently active. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Playlist to deactivate. + */ + deactivate( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId + ): Promise> + /** + * Executes the requested AdLib/AdLib Action. This is a "planned" AdLib (Action) that has been produced by the blueprints during the ingest process. + * + * Throws if the target Playlist is not active. + * Throws if there is not an on-at part instance. + * @returns a `ClientResponseError` if an adLib for the provided `adLibId` cannot be found. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Playlist to execute adLib in. + * @param adLibId AdLib to execute. + * @param triggerMode A string to specify a particular variation for the AdLibAction, valid actionType strings are to be read from the status API. + */ + executeAdLib( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + adLibId: AdLibActionId | RundownBaselineAdLibActionId | PieceId | BucketAdLibId, + triggerMode?: string + ): Promise> + /** + * Executes the requested Bucket AdLib/AdLib Action. This is a Bucket AdLib (Action) that has been previously inserted into a Bucket. + * It will automatically find the variation matching the showStyleBaseId and showStyleVariantId of the current Rundown. + * + * Throws if the target Playlist is not active. + * Throws if there is not an on-air part instance. + * @returns a `ClientResponseError` if a bucket or adlib for the provided ids cannot be found. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Playlist to execute adLib in. + * @param bucketId Bucket to execute the adlib from + * @param externalId External Id of the Bucket AdLib to execute. + * @param triggerMode A string to specify a particular variation for the AdLibAction, valid actionType strings are to be read from the status API. + */ + executeBucketAdLib( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + bucketId: BucketId, + externalId: string, + triggerMode?: string + ): Promise> + /** + * Moves the next point by `delta` places. Negative values are allowed to move "backwards" in the script. + * + * Throws if the target Playlist is not active. + * Throws if there is both no current or next Part. + * If delta results in an index that is greater than the number of Parts available, no action will be taken. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Playlist to target. + * @param delta Amount to move next point by (+/-) + */ + moveNextPart( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + delta: number + ): Promise> + /** + * Moves the next Segment point by `delta` places. Negative values are allowed to move "backwards" in the script. + * + * Throws if the target Playlist is not active. + * Throws if there is both no current or next Part. + * If delta results in an index that is greater than the number of Segments available, no action will be taken. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Playlist to target. + * @param delta Amount to move next Segment point by (+/-) + */ + moveNextSegment( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + delta: number + ): Promise> + /** + * Reloads a Playlist from its ingest source (e.g. MOS/Spreadsheet etc.) + * + * Throws if the target Playlist is currently active. + * @returns a `ClientResponseError` if the playlist fails to reload + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Playlist to reload. + */ + reloadPlaylist( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId + ): Promise> + /** + * Resets a Playlist back to its pre-played state. + * + * Throws if the target Playlist is currently active unless reset while on-air is enabled in settings. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Playlist to reset. + */ + resetPlaylist( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId + ): Promise> + /** + * Sets the next Part to a given PartId. + * + * Throws if the target playlist is not currently active. + * Throws if the specified Part does not exist. + * Throws if the specified Part is not playable. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Target rundown playlist. + * @param partId Part to set as next. + */ + setNextPart( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + partId: PartId + ): Promise> + /** + * Sets the next Segment to a given SegmentId. + * + * Throws if the target Playlist is not currently active. + * Throws if the specified Segment does not exist. + * Throws if the specified Segment does not contain any playable parts. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Target Playlist. + * @param segmentId Segment to set as next. + */ + setNextSegment( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + segmentId: SegmentId + ): Promise> + /** + * Queues the Segment to a given SegmentId. + * + * Throws if the target Playlist is not currently active. + * Throws if the specified Segment does not exist. + * Throws if the specified Segment does not contain any playable parts. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Target Playlist. + * @param segmentId Segment to set as next. + */ + queueNextSegment( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + segmentId: SegmentId + ): Promise> + /** + * Performs a take in the given Playlist. + * + * Throws if spcified Playlist is not active. + * Throws if specified Playlist does not have a next Part. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Target Playlist. + * @param fromPartInstanceId Part instance this take is for, used as a safety guard against performing multiple takes when only one was intended. + */ + take( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + fromPartInstanceId: PartInstanceId | undefined + ): Promise> + /** + * Clears the specified SourceLayer. + * + * Throws if specified playlist is not active. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Target Playlist. + * @param sourceLayerId Target SourceLayer. + */ + clearSourceLayer( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + sourceLayerId: string + ): Promise> + /** + * Recalls the last sticky Piece on the specified SourceLayer, if there is any. + * + * Throws if specified playlist is not active. + * Throws if specified SourceLayer is not sticky. + * Throws if there is no sticky piece for this SourceLayer. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Target Playlist. + * @param sourceLayerId Target SourceLayer. + */ + recallStickyPiece( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + sourceLayerId: string + ): Promise> +} diff --git a/packages/webui/src/lib/api/rest/v1/showstyles.ts b/packages/webui/src/lib/api/rest/v1/showstyles.ts new file mode 100644 index 0000000000..68dfa88d48 --- /dev/null +++ b/packages/webui/src/lib/api/rest/v1/showstyles.ts @@ -0,0 +1,226 @@ +import { ClientAPI } from '../../client' +import { ShowStyleBaseId, ShowStyleVariantId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { Meteor } from 'meteor/meteor' + +/* ************************************************************************* +This file contains types and interfaces that are used by the REST API. +When making changes to these types, you should be aware of any breaking changes +and update packages/openapi accordingly if needed. +************************************************************************* */ + +export interface ShowStylesRestAPI { + /** + * Returns the Ids of all ShowStyleBases available in Sofie. + * + * @param connection Connection data including client and header details + * @param event User event string + */ + getShowStyleBases( + connection: Meteor.Connection, + event: string + ): Promise>> + /** + * Adds a ShowStyleBase, returning the newly created Id. + * + * @param connection Connection data including client and header details + * @param event User event string + * @param showStyleBase ShowStyleBase to insert + */ + addShowStyleBase( + connection: Meteor.Connection, + event: string, + showStyleBase: APIShowStyleBase + ): Promise> + /** + * Gets a ShowStyleBase. + * + * Throws if the ShowStyleBase does not exist. + * @param connection Connection data including client and header details + * @param event User event string + * @param showStyleBaseId ShowStyleBaseId to fetch + */ + getShowStyleBase( + connection: Meteor.Connection, + event: string, + showStyleBaseId: ShowStyleBaseId + ): Promise> + /** + * Updates an existing ShowStyleBase, or creates it if it does not currently exist. + * + * Throws if the ShowStyleBase is currently in use in an active Rundown. + * @param connection Connection data including client and header details + * @param event User event string + * @param showStyleBaseId ShowStyleBase to update or insert + * @param showStyleBase ShowStyleBase to insert + */ + addOrUpdateShowStyleBase( + connection: Meteor.Connection, + event: string, + showStyleBaseId: ShowStyleBaseId, + showStyleBase: APIShowStyleBase + ): Promise> + /** + * Removed a ShowStyleBase. + * + * Throws if the ShowStyleBase is in use in an active Rundown. + * @param connection Connection data including client and header details + * @param event User event string + * @param showStyleBaseId ShowStyleBase to update or insert + */ + deleteShowStyleBase( + connection: Meteor.Connection, + event: string, + showStyleBaseId: ShowStyleBaseId + ): Promise> + /** + * Gets the Ids of all ShowStyleVariants that belong to a specified ShowStyleBase. + * + * Throws if the specified ShowStyleBase does not exist. + * @param connection Connection data including client and header details + * @param event User event string + * @param showStyleBaseId ShowStyleBase to fetch ShowStyleVariants for + */ + getShowStyleVariants( + connection: Meteor.Connection, + event: string, + showStyleBaseId: ShowStyleBaseId + ): Promise>> + /** + * Adds a ShowStyleVariant to a specified ShowStyleBase. + * + * Throws if the specified ShowStyleBase does not exist. + * @param connection Connection data including client and header details + * @param event User event string + * @param showStyleBaseId ShowStyleBase to add a ShowStyleVariant to + * @param showStyleVariant ShowStyleVariant to add + */ + addShowStyleVariant( + connection: Meteor.Connection, + event: string, + showStyleBaseId: ShowStyleBaseId, + showStyleVariant: APIShowStyleVariant + ): Promise> + /** + * Gets a ShowStyleVariant. + * + * Throws if the specified ShowStyleVariant does not exist. + * @param connection Connection data including client and header details + * @param event User event string + * @param showStyleBaseId ShowStyleBase the ShowStyleVariant belongs to + * @param showStyleVariant ShowStyleVariant to fetch + */ + getShowStyleVariant( + connection: Meteor.Connection, + event: string, + showStyleBaseId: ShowStyleBaseId, + showStyleVariant: ShowStyleVariantId + ): Promise> + /** + * Updates an existing ShowStyleVariant, or creates it if it does not exist. + * + * Throws if the specified ShowStyleBase does not exist. + * Throws if the ShowStyleVariant is currently in use in an active Rundown. + * @param connection Connection data including client and header details + * @param event User event string + * @param showStyleBaseId ShowStyleBase to add a ShowStyleVariant to + * @param showStyleVariantId ShowStyleVariant Id to add/update + * @param showStyleVariant ShowStyleVariant to add/update + */ + addOrUpdateShowStyleVariant( + connection: Meteor.Connection, + event: string, + showStyleBaseId: ShowStyleBaseId, + showStyleVariantId: ShowStyleVariantId, + showStyleVariant: APIShowStyleVariant + ): Promise> + /** + * Deletes a specified ShowStyleVariant. + * + * Throws if the specified ShowStyleBase does not exist. + * @param connection Connection data including client and header details + * @param event User event string + * @param showStyleBaseId ShowStyleBase the ShowStyleVariant belongs to + * @param showStyleVariantId ShowStyleVariant to delete + */ + deleteShowStyleVariant( + connection: Meteor.Connection, + event: string, + showStyleBaseId: ShowStyleBaseId, + showStyleVariantId: ShowStyleVariantId + ): Promise> + /** + * Send an action to a ShowStyleBase. + * + * Throws if the requested ShowStyleBase does not exits. + * Throws if the action is not valid for the requested ShowStyleBase. + * @param connection Connection data including client and header details + * @param event User event string + * @param showStyleBaseId ShowStyleBase to target + * @param action Action to perform + */ + showStyleBaseAction( + connection: Meteor.Connection, + event: string, + showStyleBaseId: ShowStyleBaseId, + action: ShowStyleBaseAction + ): Promise> +} + +export enum ShowStyleBaseActionType { + BLUEPRINT_UPGRADE = 'blueprint_upgrade', +} + +export interface ShowStyleBaseActionBase { + type: ShowStyleBaseActionType +} + +export interface ShowStyleBaseActionBlueprintUpgrade extends ShowStyleBaseActionBase { + type: ShowStyleBaseActionType.BLUEPRINT_UPGRADE +} + +export type ShowStyleBaseAction = ShowStyleBaseActionBlueprintUpgrade + +export interface APIShowStyleBase { + name: string + blueprintId: string + blueprintConfigPresetId?: string + outputLayers: APIOutputLayer[] + sourceLayers: APISourceLayer[] + config: object +} + +export interface APIShowStyleVariant { + name: string + showStyleBaseId: string + config: object + rank: number +} + +export interface APIOutputLayer { + id: string + name: string + rank: number + isPgm: boolean +} + +export interface APISourceLayer { + id: string + name: string + abbreviation?: string + rank: number + layerType: + | 'unknown' + | 'camera' + | 'vt' + | 'remote' + | 'script' + | 'graphics' + | 'splits' + | 'audio' + | 'lower-third' + | 'live-speak' + | 'transition' + | 'local' + | 'studio-screen' + exclusiveGroup?: string +} diff --git a/packages/webui/src/lib/api/rest/v1/studios.ts b/packages/webui/src/lib/api/rest/v1/studios.ts new file mode 100644 index 0000000000..ce3e967ed8 --- /dev/null +++ b/packages/webui/src/lib/api/rest/v1/studios.ts @@ -0,0 +1,186 @@ +import { ClientAPI } from '../../client' +import { PeripheralDeviceId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { Meteor } from 'meteor/meteor' + +/* ************************************************************************* +This file contains types and interfaces that are used by the REST API. +When making changes to these types, you should be aware of any breaking changes +and update packages/openapi accordingly if needed. +************************************************************************* */ + +export interface StudiosRestAPI { + /** + * Gets the Ids of all Studios. + * + * @param connection Connection data including client and header details + * @param event User event string + */ + getStudios(connection: Meteor.Connection, event: string): Promise>> + /** + * Adds a new Studio, returns the Id of the newly created Studio. + * + * @param connection Connection data including client and header details + * @param event User event string + * @param studio Studio to add + */ + addStudio( + connection: Meteor.Connection, + event: string, + studio: APIStudio + ): Promise> + /** + * Gets a Studio, if it exists. + * + * Throws if the specified Studio does not exist. + * @param connection Connection data including client and header details + * @param event User event string + * @param studioId Id of the Studio to fetch + */ + getStudio( + connection: Meteor.Connection, + event: string, + studioId: StudioId + ): Promise> + /** + * Adds a new Studio or updates an already existing one. + * + * Throws if the Studio already exists and is in use in an active Rundown. + * @param connection Connection data including client and header details + * @param event User event string + * @param studioId Id of the Studio to add or update + * @param studio Studio to add or update + */ + addOrUpdateStudio( + connection: Meteor.Connection, + event: string, + studioId: StudioId, + studio: APIStudio + ): Promise> + /** + * Deletes a Studio. + * + * Throws if the specified Studio is in use in an active Rundown. + * @param connection Connection data including client and header details + * @param event User event string + * @param studioId Id of the Studio to delete + */ + deleteStudio( + connection: Meteor.Connection, + event: string, + studioId: StudioId + ): Promise> + /** + * Send an action to a studio. + * + * Throws if the requested studio does not exits. + * Throws if the action is not valid for the requested studio. + * @param connection Connection data including client and header details + * @param event User event string + * @param studioId Studio to target + * @param action Action to perform + */ + studioAction( + connection: Meteor.Connection, + event: string, + studioId: StudioId, + action: StudioAction + ): Promise> + /** + * Fetches all of the peripheral devices attached to a studio. + * + * Throws if the requested Studio does not exist. + * @param connection Connection data including client and header details + * @param event User event string + * @param studioId Studio to fetch devices for + */ + getPeripheralDevicesForStudio( + connection: Meteor.Connection, + event: string, + studioId: StudioId + ): Promise>> + /** + * Assigns a device to a studio. + * + * Throws if the device is already attached to a studio. + * @param connection Connection data including client and header details + * @param event User event string + * @param studioId Studio to attach to + * @param deviceId Device to attach + */ + attachDeviceToStudio( + connection: Meteor.Connection, + event: string, + studioId: StudioId, + deviceId: PeripheralDeviceId + ): Promise> + /** + * Detaches a device from a studio. + * + * @param connection Connection data including client and header details + * @param event User event string + * @param studioId Studio to detach from + * @param deviceId Device to detach + */ + detachDeviceFromStudio( + connection: Meteor.Connection, + event: string, + studioId: StudioId, + deviceId: PeripheralDeviceId + ): Promise> + /** + * Sets a route set to the described state + * + * Throws if specified studioId does not exist + * Throws if specified route set does not exist + * Throws if `state` is `false` and the specified route set cannot be deactivated. + * @param connection Connection data including client and header details + * @param event User event string + * @param studioId Studio to target + * @param routeSetId Route set within studio + * @param state Whether state should be set to active (true) or inactive (false) + */ + switchRouteSet( + connection: Meteor.Connection, + event: string, + studioId: StudioId, + routeSetId: string, + state: boolean + ): Promise> +} + +export enum StudioActionType { + BLUEPRINT_UPGRADE = 'blueprint_upgrade', +} + +export interface StudioActionBase { + type: StudioActionType +} + +export interface StudioActionBlueprintUpgrade extends StudioActionBase { + type: StudioActionType.BLUEPRINT_UPGRADE +} + +export type StudioAction = StudioActionBlueprintUpgrade + +export interface APIStudio { + name: string + blueprintId?: string + blueprintConfigPresetId?: string + supportedShowStyleBase?: string[] + config: object + settings: APIStudioSettings +} + +export interface APIStudioSettings { + frameRate: number + mediaPreviewsUrl: string + slackEvaluationUrls?: string[] + supportedMediaFormats?: string[] + supportedAudioStreams?: string[] + enablePlayFromAnywhere?: boolean + forceMultiGatewayMode?: boolean + multiGatewayNowSafeLatency?: number + allowRundownResetOnAir?: boolean + preserveOrphanedSegmentPositionInRundown?: boolean + minimumTakeSpan?: number +} diff --git a/packages/webui/src/lib/api/rest/v1/system.ts b/packages/webui/src/lib/api/rest/v1/system.ts new file mode 100644 index 0000000000..cf999eec0f --- /dev/null +++ b/packages/webui/src/lib/api/rest/v1/system.ts @@ -0,0 +1,71 @@ +import { BlueprintId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ClientAPI } from '../../client' +import { Meteor } from 'meteor/meteor' + +/* ************************************************************************* +This file contains types and interfaces that are used by the REST API. +When making changes to these types, you should be aware of any breaking changes +and update packages/openapi accordingly if needed. +************************************************************************* */ + +export interface SystemRestAPI { + /* + * Assigns a specified Blueprint to the system. + * + * Throws if the specified Blueprint does not exist. + * Throws if the specified Blueprint is not a 'system' Blueprint. + * @param connection Connection data including client and header details + * @param event User event string + * @param blueprintId Blueprint to assign + */ + assignSystemBlueprint( + connection: Meteor.Connection, + event: string, + blueprintId: BlueprintId + ): Promise> + /** + * Unassigns the assigned system Blueprint, if any Blueprint is currently assigned. + * + * @param connection Connection data including client and header details + * @param event User event string + */ + unassignSystemBlueprint(connection: Meteor.Connection, event: string): Promise> + /** + * Get the pending migration steps at the system level. + * + * @param connection Connection data including client and header details + * @param event User event string + */ + getPendingMigrations( + connection: Meteor.Connection, + event: string + ): Promise> + /** + * Apply system-level migrations. + * + * Throws if any of the specified migrations have already been applied. + * @param connection Connection data including client and header details + * @param event User event string + * @param inputs Migration data to apply + */ + applyPendingMigrations( + connection: Meteor.Connection, + event: string, + inputs: MigrationData + ): Promise> +} + +export interface PendingMigrationStep { + stepId: string + attributeId: string +} + +export type PendingMigrations = Array + +export interface MigrationStepData { + stepId: string + attributeId: string + migrationValue: string | number | boolean +} + +export type MigrationData = Array diff --git a/packages/webui/src/lib/api/rundown.ts b/packages/webui/src/lib/api/rundown.ts new file mode 100644 index 0000000000..6586fe7940 --- /dev/null +++ b/packages/webui/src/lib/api/rundown.ts @@ -0,0 +1,9 @@ +import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +export interface NewRundownAPI { + rundownPlaylistNeedsResync(playlistId: RundownPlaylistId): Promise +} + +export enum RundownAPIMethods { + 'rundownPlaylistNeedsResync' = 'rundown.rundownPlaylistNeedsResync', +} diff --git a/packages/webui/src/lib/api/rundownLayouts.ts b/packages/webui/src/lib/api/rundownLayouts.ts new file mode 100644 index 0000000000..9160006328 --- /dev/null +++ b/packages/webui/src/lib/api/rundownLayouts.ts @@ -0,0 +1,420 @@ +import { + RundownLayoutBase, + RundownLayout, + DashboardLayout, + RundownLayoutType, + RundownLayoutElementBase, + RundownLayoutFilterBase, + RundownLayoutElementType, + RundownLayoutExternalFrame, + RundownLayoutAdLibRegion, + PieceDisplayStyle, + RundownLayoutPieceCountdown, + RundownViewLayout, + RundownLayoutRundownHeader, + RundownLayoutShelfBase, + CustomizableRegions, + RundownLayoutPlaylistStartTimer, + RundownLayoutPlaylistEndTimer, + RundownLayoutNextBreakTiming, + RundownLayoutEndWords, + RundownLayoutSegmentTiming, + RundownLayoutPartTiming, + RundownLayoutTextLabel, + RundownLayoutPlaylistName, + RundownLayoutTimeOfDay, + RundownLayoutSytemStatus, + RundownLayoutShowStyleDisplay, + RundownLayoutWithFilters, + DashboardLayoutFilter, + RundownLayoutKeyboardPreview, + RundownLayoutNextInfo, + RundownLayoutPresenterView, + RundownLayoutStudioName, + RundownLayoutSegmentName, + RundownLayoutPartName, + RundownLayoutColoredBox, + RundownLayoutMiniRundown, +} from '../collections/RundownLayouts' +import * as _ from 'underscore' +import { literal } from '../lib' +import { TFunction } from 'i18next' +import { Settings } from '../Settings' +import { RundownLayoutId, ShowStyleBaseId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +export interface NewRundownLayoutsAPI { + createRundownLayout( + name: string, + type: RundownLayoutType, + showStyleBaseId: ShowStyleBaseId, + regionId: string + ): Promise + removeRundownLayout(id: RundownLayoutId): Promise +} + +export enum RundownLayoutsAPIMethods { + 'removeRundownLayout' = 'rundownLayout.removeRundownLayout', + 'createRundownLayout' = 'rundownLayout.createRundownLayout', +} + +export interface LayoutDescriptor { + supportedFilters: RundownLayoutElementType[] + filtersTitle?: string // e.g. tabs/panels +} + +export interface CustomizableRegionSettingsManifest { + _id: string + title: string + layouts: Array + navigationLink: (studioId: StudioId, layoutId: RundownLayoutId) => string +} + +export interface CustomizableRegionLayout { + _id: string + type: RundownLayoutType + filtersTitle?: string + supportedFilters: RundownLayoutElementType[] +} + +class RundownLayoutsRegistry { + private shelfLayouts: Map = new Map() + private rundownViewLayouts: Map = new Map() + private miniShelfLayouts: Map = new Map() + private rundownHeaderLayouts: Map = new Map() + private presenterViewLayouts: Map = new Map() + + public registerShelfLayout(id: RundownLayoutType, description: LayoutDescriptor) { + this.shelfLayouts.set(id, description) + } + + public registerRundownViewLayout(id: RundownLayoutType, description: LayoutDescriptor) { + this.rundownViewLayouts.set(id, description) + } + + public registerMiniShelfLayout(id: RundownLayoutType, description: LayoutDescriptor) { + this.miniShelfLayouts.set(id, description) + } + + public registerRundownHeaderLayouts(id: RundownLayoutType, description: LayoutDescriptor) { + this.rundownHeaderLayouts.set(id, description) + } + + public registerPresenterViewLayout(id: RundownLayoutType, description: LayoutDescriptor) { + this.presenterViewLayouts.set(id, description) + } + + public isShelfLayout(regionId: CustomizableRegions) { + return regionId === CustomizableRegions.Shelf + } + + public isRudownViewLayout(regionId: CustomizableRegions) { + return regionId === CustomizableRegions.RundownView + } + + public isMiniShelfLayout(regionId: CustomizableRegions) { + return regionId === CustomizableRegions.MiniShelf + } + + public isRundownHeaderLayout(regionId: CustomizableRegions) { + return regionId === CustomizableRegions.RundownHeader + } + + public isPresenterViewLayout(regionId: CustomizableRegions) { + return regionId === CustomizableRegions.PresenterView + } + + private wrapToCustomizableRegionLayout( + layouts: Map, + t: TFunction + ): CustomizableRegionLayout[] { + return Array.from(layouts.entries()).map(([layoutType, descriptor]) => { + return literal({ + _id: layoutType, + type: layoutType, + ...descriptor, + filtersTitle: descriptor.filtersTitle ? t(descriptor.filtersTitle) : undefined, + }) + }) + } + + public GetSettingsManifest(t: TFunction): CustomizableRegionSettingsManifest[] { + return [ + { + _id: CustomizableRegions.RundownView, + title: t('Rundown View Layouts'), + layouts: this.wrapToCustomizableRegionLayout(this.rundownViewLayouts, t), + navigationLink: (studioId, layoutId) => `/activeRundown/${studioId}?rundownViewLayout=${layoutId}`, + }, + { + _id: CustomizableRegions.Shelf, + title: t('Shelf Layouts'), + layouts: this.wrapToCustomizableRegionLayout(this.shelfLayouts, t), + navigationLink: (studioId, layoutId) => `/activeRundown/${studioId}/shelf?layout=${layoutId}`, + }, + { + _id: CustomizableRegions.MiniShelf, + title: t('Mini Shelf Layouts'), + layouts: this.wrapToCustomizableRegionLayout(this.miniShelfLayouts, t), + navigationLink: (studioId, layoutId) => `/activeRundown/${studioId}?miniShelfLayout=${layoutId}`, + }, + { + _id: CustomizableRegions.RundownHeader, + title: t('Rundown Header Layouts'), + layouts: this.wrapToCustomizableRegionLayout(this.rundownHeaderLayouts, t), + navigationLink: (studioId, layoutId) => `/activeRundown/${studioId}?rundownHeaderLayout=${layoutId}`, + }, + { + _id: CustomizableRegions.PresenterView, + title: t('Presenter View Layouts'), + layouts: this.wrapToCustomizableRegionLayout(this.presenterViewLayouts, t), + navigationLink: (studioId, layoutId) => `/countdowns/${studioId}/presenter?presenterLayout=${layoutId}`, + }, + ] + } +} + +export namespace RundownLayoutsAPI { + const registry = new RundownLayoutsRegistry() + const rundownLayoutSupportedFilters = [ + RundownLayoutElementType.ADLIB_REGION, + RundownLayoutElementType.EXTERNAL_FRAME, + RundownLayoutElementType.FILTER, + RundownLayoutElementType.PIECE_COUNTDOWN, + RundownLayoutElementType.NEXT_INFO, + ] + if (Settings.enableKeyboardPreview) { + rundownLayoutSupportedFilters.push(RundownLayoutElementType.KEYBOARD_PREVIEW) + } + registry.registerShelfLayout(RundownLayoutType.RUNDOWN_LAYOUT, { + filtersTitle: 'Tabs', + supportedFilters: rundownLayoutSupportedFilters, + }) + const dashboardLayoutSupportedFilters = [ + RundownLayoutElementType.ADLIB_REGION, + RundownLayoutElementType.EXTERNAL_FRAME, + RundownLayoutElementType.FILTER, + RundownLayoutElementType.PIECE_COUNTDOWN, + RundownLayoutElementType.NEXT_INFO, + RundownLayoutElementType.TEXT_LABEL, + RundownLayoutElementType.MINI_RUNDOWN, + ] + if (Settings.enableKeyboardPreview) { + rundownLayoutSupportedFilters.push(RundownLayoutElementType.KEYBOARD_PREVIEW) + } + registry.registerShelfLayout(RundownLayoutType.DASHBOARD_LAYOUT, { + filtersTitle: 'Panels', + supportedFilters: dashboardLayoutSupportedFilters, + }) + registry.registerMiniShelfLayout(RundownLayoutType.DASHBOARD_LAYOUT, { + supportedFilters: [RundownLayoutElementType.FILTER], + }) + registry.registerMiniShelfLayout(RundownLayoutType.RUNDOWN_LAYOUT, { + supportedFilters: [RundownLayoutElementType.FILTER], + }) + registry.registerRundownViewLayout(RundownLayoutType.RUNDOWN_VIEW_LAYOUT, { + supportedFilters: [], + }) + registry.registerRundownHeaderLayouts(RundownLayoutType.RUNDOWN_HEADER_LAYOUT, { + supportedFilters: [], + }) + registry.registerRundownHeaderLayouts(RundownLayoutType.DASHBOARD_LAYOUT, { + filtersTitle: 'Layout Elements', + supportedFilters: [ + RundownLayoutElementType.PIECE_COUNTDOWN, + RundownLayoutElementType.PLAYLIST_START_TIMER, + RundownLayoutElementType.PLAYLIST_END_TIMER, + RundownLayoutElementType.NEXT_BREAK_TIMING, + RundownLayoutElementType.END_WORDS, + RundownLayoutElementType.SEGMENT_TIMING, + RundownLayoutElementType.PART_TIMING, + RundownLayoutElementType.TEXT_LABEL, + RundownLayoutElementType.PLAYLIST_NAME, + RundownLayoutElementType.TIME_OF_DAY, + RundownLayoutElementType.SHOWSTYLE_DISPLAY, + RundownLayoutElementType.SYSTEM_STATUS, + RundownLayoutElementType.COLORED_BOX, + ], + }) + registry.registerPresenterViewLayout(RundownLayoutType.CLOCK_PRESENTER_VIEW_LAYOUT, { + supportedFilters: [], + }) + registry.registerPresenterViewLayout(RundownLayoutType.DASHBOARD_LAYOUT, { + filtersTitle: 'Layout Elements', + supportedFilters: [ + RundownLayoutElementType.PART_TIMING, + RundownLayoutElementType.TEXT_LABEL, + RundownLayoutElementType.SEGMENT_TIMING, + RundownLayoutElementType.PLAYLIST_END_TIMER, + RundownLayoutElementType.NEXT_BREAK_TIMING, + RundownLayoutElementType.TIME_OF_DAY, + RundownLayoutElementType.PLAYLIST_NAME, + RundownLayoutElementType.STUDIO_NAME, + RundownLayoutElementType.SEGMENT_NAME, + RundownLayoutElementType.PART_NAME, + RundownLayoutElementType.COLORED_BOX, + ], + }) + + export function getSettingsManifest(t: TFunction): CustomizableRegionSettingsManifest[] { + return registry.GetSettingsManifest(t) + } + + export function isLayoutWithFilters(layout: RundownLayoutBase): layout is RundownLayoutWithFilters { + return Object.keys(layout).includes('filters') + } + + export function isLayoutForShelf(layout: RundownLayoutBase): layout is RundownLayoutShelfBase { + return registry.isShelfLayout(layout.regionId) + } + + export function isLayoutForPresenterView(layout: RundownLayoutBase): layout is RundownLayoutPresenterView { + return registry.isPresenterViewLayout(layout.regionId) + } + + export function isLayoutForRundownView(layout: RundownLayoutBase): layout is RundownViewLayout { + return registry.isRudownViewLayout(layout.regionId) + } + + export function isLayoutForMiniShelf(layout: RundownLayoutBase): layout is RundownLayoutShelfBase { + return registry.isMiniShelfLayout(layout.regionId) + } + + export function isLayoutForRundownHeader(layout: RundownLayoutBase): layout is RundownLayoutRundownHeader { + return registry.isRundownHeaderLayout(layout.regionId) + } + + export function isRundownViewLayout(layout: RundownLayoutBase): layout is RundownViewLayout { + return layout.type === RundownLayoutType.RUNDOWN_VIEW_LAYOUT + } + + export function isRundownLayout(layout: RundownLayoutBase): layout is RundownLayout { + // we need to check if filters are defined, because RundownLayout is a RundownLayoutWithFilters, and RundownLayoutBase doesn't require it + return layout.type === RundownLayoutType.RUNDOWN_LAYOUT && (layout as RundownLayout).filters !== undefined + } + + export function isDashboardLayout(layout: RundownLayoutBase): layout is DashboardLayout { + // we need to check if filters are defined, because DashboardLayout is a RundownLayoutWithFilters, and RundownLayoutBase doesn't require it + return layout.type === RundownLayoutType.DASHBOARD_LAYOUT && (layout as DashboardLayout).filters !== undefined + } + + export function isRundownHeaderLayout(layout: RundownLayoutBase): layout is RundownLayoutRundownHeader { + return layout.type === RundownLayoutType.RUNDOWN_HEADER_LAYOUT + } + + export function isDefaultLayout(layout: RundownLayoutBase): boolean { + return layout.isDefaultLayout + } + + export function isFilter(element: RundownLayoutElementBase): element is RundownLayoutFilterBase { + return element.type === undefined || element.type === RundownLayoutElementType.FILTER + } + + export function isExternalFrame(element: RundownLayoutElementBase): element is RundownLayoutExternalFrame { + return element.type === RundownLayoutElementType.EXTERNAL_FRAME + } + + export function isAdLibRegion(element: RundownLayoutElementBase): element is RundownLayoutAdLibRegion { + return element.type === RundownLayoutElementType.ADLIB_REGION + } + + export function isPieceCountdown(element: RundownLayoutElementBase): element is RundownLayoutPieceCountdown { + return element.type === RundownLayoutElementType.PIECE_COUNTDOWN + } + + export function isDashboardLayoutFilter(element: RundownLayoutElementBase): element is DashboardLayoutFilter { + return element.type === RundownLayoutElementType.FILTER + } + + export function isKeyboardMap(element: RundownLayoutElementBase): element is RundownLayoutKeyboardPreview { + return element.type === RundownLayoutElementType.KEYBOARD_PREVIEW + } + + export function isNextInfo(element: RundownLayoutElementBase): element is RundownLayoutNextInfo { + return element.type === RundownLayoutElementType.NEXT_INFO + } + + export function isMiniRundown(element: RundownLayoutElementBase): element is RundownLayoutMiniRundown { + return element.type === RundownLayoutElementType.MINI_RUNDOWN + } + + export function isPlaylistStartTimer( + element: RundownLayoutElementBase + ): element is RundownLayoutPlaylistStartTimer { + return element.type === RundownLayoutElementType.PLAYLIST_START_TIMER + } + + export function isPlaylistEndTimer(element: RundownLayoutElementBase): element is RundownLayoutPlaylistEndTimer { + return element.type === RundownLayoutElementType.PLAYLIST_END_TIMER + } + + export function isNextBreakTiming(element: RundownLayoutElementBase): element is RundownLayoutNextBreakTiming { + return element.type === RundownLayoutElementType.NEXT_BREAK_TIMING + } + + export function isEndWords(element: RundownLayoutElementBase): element is RundownLayoutEndWords { + return element.type === RundownLayoutElementType.END_WORDS + } + + export function isSegmentTiming(element: RundownLayoutElementBase): element is RundownLayoutSegmentTiming { + return element.type === RundownLayoutElementType.SEGMENT_TIMING + } + + export function isPartTiming(element: RundownLayoutElementBase): element is RundownLayoutPartTiming { + return element.type === RundownLayoutElementType.PART_TIMING + } + + export function isTextLabel(element: RundownLayoutElementBase): element is RundownLayoutTextLabel { + return element.type === RundownLayoutElementType.TEXT_LABEL + } + + export function isPlaylistName(element: RundownLayoutElementBase): element is RundownLayoutPlaylistName { + return element.type === RundownLayoutElementType.PLAYLIST_NAME + } + + export function isStudioName(element: RundownLayoutElementBase): element is RundownLayoutStudioName { + return element.type === RundownLayoutElementType.STUDIO_NAME + } + + export function isTimeOfDay(element: RundownLayoutElementBase): element is RundownLayoutTimeOfDay { + return element.type === RundownLayoutElementType.TIME_OF_DAY + } + + export function isSystemStatus(element: RundownLayoutElementBase): element is RundownLayoutSytemStatus { + return element.type === RundownLayoutElementType.SYSTEM_STATUS + } + + export function isShowStyleDisplay(element: RundownLayoutElementBase): element is RundownLayoutShowStyleDisplay { + return element.type === RundownLayoutElementType.SHOWSTYLE_DISPLAY + } + + export function isSegmentName(element: RundownLayoutElementBase): element is RundownLayoutSegmentName { + return element.type === RundownLayoutElementType.SEGMENT_NAME + } + + export function isPartName(element: RundownLayoutElementBase): element is RundownLayoutPartName { + return element.type === RundownLayoutElementType.PART_NAME + } + + export function isColoredBox(element: RundownLayoutElementBase): element is RundownLayoutColoredBox { + return element.type === RundownLayoutElementType.COLORED_BOX + } + + export function adLibRegionToFilter(element: RundownLayoutAdLibRegion): RundownLayoutFilterBase { + return { + ..._.pick(element, '_id', 'name', 'rank', 'tags'), + rundownBaseline: true, + type: RundownLayoutElementType.FILTER, + sourceLayerIds: [], + sourceLayerTypes: [], + outputLayerIds: [], + label: [], + displayStyle: PieceDisplayStyle.BUTTONS, + currentSegment: false, + showThumbnailsInList: false, + nextInCurrentPart: false, + oneNextPerSourceLayer: false, + hideDuplicates: false, + disableHoverInspector: false, + } + } +} diff --git a/packages/webui/src/lib/api/rundownNotifications.ts b/packages/webui/src/lib/api/rundownNotifications.ts new file mode 100644 index 0000000000..03da0e731a --- /dev/null +++ b/packages/webui/src/lib/api/rundownNotifications.ts @@ -0,0 +1,59 @@ +import { TrackedNote } from '@sofie-automation/corelib/dist/dataModel/Notes' +import { + AdLibActionId, + BucketAdLibActionId, + BucketAdLibId, + BucketId, + PartId, + PieceId, + PieceInstanceId, + RundownBaselineAdLibActionId, + RundownId, + RundownPlaylistId, + SegmentId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ProtectedString } from '../lib' +import { PieceContentStatusObj } from './pieceContentStatus' +import { ITranslatableMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' + +export type UISegmentPartNoteId = ProtectedString<'UISegmentPartNote'> +export interface UISegmentPartNote { + _id: UISegmentPartNoteId + playlistId: RundownPlaylistId + rundownId: RundownId + segmentId: SegmentId + + note: TrackedNote +} + +export type UIPieceContentStatusId = ProtectedString<'UIPieceContentStatus'> +export interface UIPieceContentStatus { + _id: UIPieceContentStatusId + + segmentRank: number + partRank: number + + rundownId: RundownId + partId: PartId | undefined + segmentId: SegmentId | undefined + + pieceId: PieceId | AdLibActionId | RundownBaselineAdLibActionId | PieceInstanceId + isPieceInstance: boolean + + name: string | ITranslatableMessage + segmentName: string | undefined + + status: PieceContentStatusObj +} + +export type UIBucketContentStatusId = ProtectedString<'UIBucketContentStatus'> +export interface UIBucketContentStatus { + _id: UIBucketContentStatusId + + bucketId: BucketId + docId: BucketAdLibActionId | BucketAdLibId + + name: string | ITranslatableMessage + + status: PieceContentStatusObj +} diff --git a/packages/webui/src/lib/api/shapshot.ts b/packages/webui/src/lib/api/shapshot.ts new file mode 100644 index 0000000000..cc307c9a8e --- /dev/null +++ b/packages/webui/src/lib/api/shapshot.ts @@ -0,0 +1,23 @@ +import { RundownPlaylistId, SnapshotId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +export interface NewSnapshotAPI { + storeSystemSnapshot(hashedToken: string, studioId: StudioId | null, reason: string): Promise + storeRundownPlaylist( + hashedToken: string, + playlistId: RundownPlaylistId, + reason: string, + full?: boolean + ): Promise + storeDebugSnapshot(hashedToken: string, studioId: StudioId, reason: string): Promise + restoreSnapshot(snapshotId: SnapshotId, restoreDebugData: boolean): Promise + removeSnapshot(snapshotId: SnapshotId): Promise +} + +export enum SnapshotAPIMethods { + storeSystemSnapshot = 'snapshot.systemSnapshot', + storeRundownPlaylist = 'snapshot.rundownPlaylistSnapshot', + storeDebugSnapshot = 'snapshot.debugSnaphot', + + restoreSnapshot = 'snapshot.restoreSnaphot', + removeSnapshot = 'snapshot.removeSnaphot', +} diff --git a/packages/webui/src/lib/api/showStyles.ts b/packages/webui/src/lib/api/showStyles.ts new file mode 100644 index 0000000000..fee84cccfc --- /dev/null +++ b/packages/webui/src/lib/api/showStyles.ts @@ -0,0 +1,54 @@ +import { ShowStyleBaseId, ShowStyleVariantId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { HotkeyDefinition, OutputLayers, SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' +import { DBShowStyleVariant } from '@sofie-automation/corelib/dist/dataModel/ShowStyleVariant' + +export interface NewShowStylesAPI { + insertShowStyleBase(): Promise + insertShowStyleVariant(showStyleBaseId: ShowStyleBaseId): Promise + importShowStyleVariant(showStyleVariant: Omit): Promise + importShowStyleVariantAsNew(showStyleVariant: DBShowStyleVariant): Promise + removeShowStyleBase(showStyleBaseId: ShowStyleBaseId): Promise + removeShowStyleVariant(showStyleVariantId: ShowStyleVariantId): Promise + reorderShowStyleVariant(showStyleVariantId: ShowStyleVariantId, newRank: number): Promise + + getCreateAdlibTestingRundownOptions(): Promise +} + +export enum ShowStylesAPIMethods { + 'insertShowStyleBase' = 'showstyles.insertShowStyleBase', + 'insertShowStyleVariant' = 'showstyles.insertShowStyleVariant', + 'importShowStyleVariant' = 'showstyles.importShowStyleVariant', + 'importShowStyleVariantAsNew' = 'showstyles.importShowStyleVariantAsNew', + 'removeShowStyleBase' = 'showstyles.removeShowStyleBase', + 'removeShowStyleVariant' = 'showstyles.removeShowStyleVariant', + 'reorderShowStyleVariant' = 'showstyles.reorderShowStyleVariant', + + getCreateAdlibTestingRundownOptions = 'showstyles.getCreateAdlibTestingRundownOptions', +} + +/** + * A minimal version of DBShowStyleBase, intended for the playout portions of the UI. + * Note: The settings ui uses the raw types + * This intentionally does not extend ShowStyleBase, so that we have fine-grained control over the properties exposed + */ +export interface UIShowStyleBase { + _id: ShowStyleBaseId + + /** Name of this show style */ + name: string + + /** A list of hotkeys, used to display a legend of hotkeys for the user in GUI */ + hotkeyLegend?: Array + + /** "Outputs" in the UI */ + outputLayers: OutputLayers + /** "Layers" in the GUI */ + sourceLayers: SourceLayers +} + +export interface CreateAdlibTestingRundownOption { + studioId: StudioId + showStyleVariantId: ShowStyleVariantId + + label: string +} diff --git a/packages/webui/src/lib/api/studios.ts b/packages/webui/src/lib/api/studios.ts new file mode 100644 index 0000000000..ee232fe436 --- /dev/null +++ b/packages/webui/src/lib/api/studios.ts @@ -0,0 +1,37 @@ +import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { + IStudioSettings, + MappingsExt, + StudioRouteSet, + StudioRouteSetExclusivityGroup, +} from '@sofie-automation/corelib/dist/dataModel/Studio' + +export interface NewStudiosAPI { + insertStudio(): Promise + removeStudio(studioId: StudioId): Promise +} + +export enum StudiosAPIMethods { + 'insertStudio' = 'studio.insertStudio', + 'removeStudio' = 'studio.removeStudio', +} + +/** + * A minimal version of DBStudio, intended for the playout portions of the UI. + * Note: The settings ui uses the raw types + * This intentionally does not extend Studio, so that we have fine-grained control over the properties exposed + */ +export interface UIStudio { + _id: StudioId + + /** User-presentable name for the studio installation */ + name: string + + /** Mappings between the physical devices / outputs and logical ones */ + mappings: MappingsExt + + settings: IStudioSettings + + routeSets: Record + routeSetExclusivityGroups: Record +} diff --git a/packages/webui/src/lib/api/system.ts b/packages/webui/src/lib/api/system.ts new file mode 100644 index 0000000000..d9c39c0758 --- /dev/null +++ b/packages/webui/src/lib/api/system.ts @@ -0,0 +1,43 @@ +import { TranslationsBundleId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { TranslationsBundle } from '../collections/TranslationsBundles' +import { ClientAPI } from './client' + +export interface CollectionCleanupResult { + [index: string]: { + collectionName: string + docsToRemove: number + } +} + +export interface BenchmarkResult { + mongoWriteSmall: number + mongoWriteBig: number + mongoRead: number + mongoIndexedRead: number + cpuCalculations: number + cpuStringifying: number +} +export interface SystemBenchmarkResults { + description: string + results: BenchmarkResult +} + +export interface SystemAPI { + cleanupIndexes(actuallyRemoveOldIndexes: boolean): Promise + cleanupOldData(actuallyRemoveOldData: boolean): Promise + + runCronjob(): Promise + doSystemBenchmark(): Promise + + getTranslationBundle(bundleId: TranslationsBundleId): Promise> + generateSingleUseToken(): Promise> +} + +export enum SystemAPIMethods { + 'cleanupIndexes' = 'system.cleanupIndexes', + 'cleanupOldData' = 'system.cleanupOldData', + 'runCronjob' = 'system.runCronjob', + 'doSystemBenchmark' = 'system.doSystemBenchmark', + 'getTranslationBundle' = 'system.getTranslationBundle', + 'generateSingleUseToken' = 'system.generateSingleUseToken', +} diff --git a/packages/webui/src/lib/api/systemStatus.ts b/packages/webui/src/lib/api/systemStatus.ts new file mode 100644 index 0000000000..507e2f18ca --- /dev/null +++ b/packages/webui/src/lib/api/systemStatus.ts @@ -0,0 +1,61 @@ +import { ProtectedString } from '../lib' +import { StatusCode } from '@sofie-automation/blueprints-integration' +import { PeripheralDeviceId, SystemInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +export type { SystemInstanceId } + +export type ExternalStatus = 'OK' | 'FAIL' | 'WARNING' | 'UNDEFINED' + +export interface CheckObj { + description: string + status: ExternalStatus + updated: string // Timestamp, on the form "2017-05-11T18:00:10+02:00" (new Date().toISOString()) + statusMessage?: string + errors?: Array + + // internal fields (not according to spec): + _status: StatusCode +} +export interface CheckError { + type: string + time: string // Timestamp, on the form "2017-05-11T18:00:10+02:00" (new Date().toISOString()) + message: string +} +export interface StatusResponseBase { + status: ExternalStatus + name: string + updated: string + + statusMessage?: string // Tekstlig beskrivelse av status. (Eks: OK, Running, Standby, Completed successfully, 2/3 nodes running, Slow response time). + instanceId?: ProtectedString + utilises?: Array + consumers?: Array + version?: '3' // version of healthcheck + appVersion?: string + + checks?: Array + components?: Array + + // internal fields (not according to spec): + _internal: { + // statusCode: StatusCode, + statusCodeString: string + messages: Array + versions: { [component: string]: string } + } + _status: StatusCode +} +export interface StatusResponse extends StatusResponseBase { + documentation: string +} +export interface Component extends StatusResponseBase { + documentation?: string +} + +export interface NewSystemStatusAPI { + getSystemStatus(): Promise + getDebugStates(peripheralDeviceId: PeripheralDeviceId): Promise +} +export enum SystemStatusAPIMethods { + 'getSystemStatus' = 'systemStatus.getSystemStatus', + 'getDebugStates' = 'systemStatus.getDebugStates', +} diff --git a/packages/webui/src/lib/api/triggeredActions.ts b/packages/webui/src/lib/api/triggeredActions.ts new file mode 100644 index 0000000000..2f6c0828fb --- /dev/null +++ b/packages/webui/src/lib/api/triggeredActions.ts @@ -0,0 +1,24 @@ +import { ITranslatableMessage, SomeAction } from '@sofie-automation/blueprints-integration' +import { ShowStyleBaseId, TriggeredActionId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBBlueprintTrigger } from '../collections/TriggeredActions' + +export interface NewTriggeredActionsAPI { + createTriggeredActions( + showStyleBaseId: ShowStyleBaseId | null, + base?: CreateTriggeredActionsContent + ): Promise + removeTriggeredActions(id: TriggeredActionId): Promise +} + +export interface CreateTriggeredActionsContent { + _rank?: number + name?: ITranslatableMessage | string + triggers?: Record + actions?: Record + styleClassNames?: string +} + +export enum TriggeredActionsAPIMethods { + 'removeTriggeredActions' = 'triggeredActions.removeTriggeredActions', + 'createTriggeredActions' = 'triggeredActions.createTriggeredActions', +} diff --git a/packages/webui/src/lib/api/triggers/MountedTriggers.ts b/packages/webui/src/lib/api/triggers/MountedTriggers.ts new file mode 100644 index 0000000000..9efcdd05ba --- /dev/null +++ b/packages/webui/src/lib/api/triggers/MountedTriggers.ts @@ -0,0 +1,84 @@ +import { ISourceLayer, ITranslatableMessage } from '@sofie-automation/blueprints-integration' +import { + AdLibActionId, + PeripheralDeviceId, + PieceId, + RundownBaselineAdLibActionId, + TriggeredActionId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ProtectedString } from '@sofie-automation/corelib/dist/protectedString' +import { IWrappedAdLib } from './actionFilterChainCompilers' + +export type { + DeviceActionId, + DeviceTriggerMountedActionId, + ShiftRegisterActionArguments, + DeviceTriggerMountedAction, + PreviewWrappedAdLibId, + PreviewWrappedAdLib, + IWrappedAdLibBase, +} from '@sofie-automation/shared-lib/dist/input-gateway/deviceTriggerPreviews' + +export type MountedTrigger = (MountedGenericTrigger | MountedAdLibTrigger) & MountedHotkeyMixin +export type MountedDeviceTrigger = (MountedGenericTrigger | MountedAdLibTrigger) & MountedDeviceMixin + +/** A generic action that will be triggered by hotkeys (generic, i.e. non-AdLib) */ +export interface MountedGenericTrigger extends MountedTriggerCommon { + _id: MountedGenericTriggerId + /** The ID of the action that will be triggered */ + triggeredActionId: TriggeredActionId + /** Hint that all actions of this trigger are adLibs */ + adLibOnly: boolean +} + +type MountedGenericTriggerId = ProtectedString<'mountedGenericTriggerId'> + +interface MountedTriggerCommon { + /** Rank of the Action that is mounted under `keys` */ + _rank: number + /** A label of the action, if available */ + name?: string | ITranslatableMessage +} + +export interface MountedHotkeyMixin { + /** Keys or combos that have a listener mounted to */ + keys: string[] + /** Final keys in the combos, that can be used for figuring out where on the keyboard this action is mounted */ + finalKeys: string[] +} + +export type DeviceTriggerArguments = Record + +interface MountedDeviceMixin { + deviceId: string + triggerId: string + values?: DeviceTriggerArguments +} + +/** An AdLib action that will be triggered by hotkeys (can be AdLib, RundownBaselineAdLib, AdLib Action, Clear source layer, Sticky, etc.) */ +export interface MountedAdLibTrigger extends MountedTriggerCommon { + _id: MountedAdLibTriggerId + /** The ID of the action that will be triggered */ + triggeredActionId: TriggeredActionId + /** The type of the adLib being targeted */ + type: IWrappedAdLib['type'] + /** The ID in the collection specified by `type` */ + targetId: AdLibActionId | RundownBaselineAdLibActionId | PieceId | ISourceLayer['_id'] + /** SourceLayerId of the target, if available */ + sourceLayerId?: ISourceLayer['_id'] + /** A label of the target if available */ + targetName?: string | ITranslatableMessage +} + +export type MountedAdLibTriggerId = ProtectedString<'mountedAdLibTriggerId'> + +export type DeviceTriggerPreviewId = ProtectedString<'deviceTriggerPreviewId'> + +export interface UIDeviceTriggerPreview { + _id: DeviceTriggerPreviewId + peripheralDeviceId: PeripheralDeviceId + triggerDeviceId: string + triggerId: string + timestamp: number + values?: DeviceTriggerArguments +} diff --git a/packages/webui/src/lib/api/triggers/README.md b/packages/webui/src/lib/api/triggers/README.md new file mode 100644 index 0000000000..a759faa0c1 --- /dev/null +++ b/packages/webui/src/lib/api/triggers/README.md @@ -0,0 +1,37 @@ +# Action Triggers - Library + +This module is responsible for providing action compilation and execution facilities to any other modules that will +trigger Actions. Right now, only the client-side triggers are implemented and thus the only user of this module is +`TriggersHandler` React component and the Settings GUI components for creating Action Triggers. + +## actionFactory + +This is a factory creating _ExecutableAction_ objects as described in the `DBTriggeredActions.actions[]` items objects. +An Action object always has an `.execute()` method and may also optionally implement a `.preview()` reactive variable +that will return an array of `IWrappedAdLib` objects. The `IWrappedAdLib` interface is a useful abstraction of various +AdLib objects present in the Sofie system that are ultimately presented to the user as a homogenized AdLib object. These +include Rundown and RundownBaseline AdLibs and AdLib Actions, Source Layer Clear actions and Sticky Source Layer AdLibs. + +> I would strongly suggest reworking the entire Shelf system to also use this IWrappedAdLib and replace the awful and +> hackish AdLibPieceUi that is plagueing the Shelf and making everything complicated and ugly. -- Jan Starzak, +> 2021-08-31 + +An action takes a `ActionContext` context object that describes the context in which a given action is being executed. +This allows to limit the amount of reactivity and observers registed onto collections, while still allowing `.preview()` +to be fully reactive within a given context. + +## actionFilterChainCompilers + +In order for an Action description (`DBTriggeredActions.action[]`) to be executed, it needs to be converted into an +`ExecutableAction` object. The action depends on the context it is going to be running in and a `filterChain[]` +description of what particular objects a given action should target. This filter chain generally needs to be compiled +into a simple reactive function that registers a minimal amount of observers on collections and/or does a minimal amount +of DB operations. In order to make that step as quick as possible and fetch as little data as possible, the +`actionFilterChainCompilers` converts the `filterChain[]` into a reactive function that then can be executed within a +context. What sort of a context is neccessary depends on the Action type. + +## universalDoUserActionAdapter + +Because the idea for Action Triggers is that it allows setting up both client- and server-side triggers, a unified way +of executing methods was neccessary. `universalDoUserActionAdapter` provides an isometric implementation of +`doUserAction` that works both client- and server-side. diff --git a/packages/webui/src/lib/api/triggers/RundownViewEventBus.ts b/packages/webui/src/lib/api/triggers/RundownViewEventBus.ts new file mode 100644 index 0000000000..303db1c10a --- /dev/null +++ b/packages/webui/src/lib/api/triggers/RundownViewEventBus.ts @@ -0,0 +1,202 @@ +import EventEmitter from 'events' +import { ShelfTabs } from '../../../client/ui/Shelf/Shelf' +import { PieceUi } from '../../../client/ui/SegmentTimeline/SegmentTimelineContainer' +import { IAdLibListItem } from '../../../client/ui/Shelf/AdLibListItem' +import { BucketAdLibItem } from '../../../client/ui/Shelf/RundownViewBuckets' +import { Bucket } from '../../collections/Buckets' +import { + BucketId, + PartId, + PartInstanceId, + PieceId, + PieceInstanceId, + RundownId, + SegmentId, + TriggeredActionId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' + +export enum RundownViewEvents { + ACTIVATE_RUNDOWN_PLAYLIST = 'activateRundownPlaylist', + DEACTIVATE_RUNDOWN_PLAYLIST = 'deactivateRundownPlaylist', + RESYNC_RUNDOWN_PLAYLIST = 'resyncRundownPlaylist', + RESET_RUNDOWN_PLAYLIST = 'resetRundownPlaylist', + TAKE = 'take', + REWIND_SEGMENTS = 'rundownRewindSegments', + GO_TO_LIVE_SEGMENT = 'goToLiveSegment', + GO_TO_TOP = 'goToTop', + SEGMENT_ZOOM_ON = 'segmentZoomOn', + SEGMENT_ZOOM_OFF = 'segmentZoomOff', + REVEAL_IN_SHELF = 'revealInShelf', + SWITCH_SHELF_TAB = 'switchShelfTab', + SHELF_STATE = 'shelfState', + MINI_SHELF_QUEUE_ADLIB = 'miniShelfQueueAdLib', + GO_TO_PART = 'goToPart', + GO_TO_PART_INSTANCE = 'goToPartInstance', + SELECT_PIECE = 'selectPiece', + HIGHLIGHT = 'highlight', + TRIGGER_ACTION = 'triggerAction', + + RENAME_BUCKET_ADLIB = 'renameBucketAdLib', + DELETE_BUCKET_ADLIB = 'deleteBucketAdLib', + + EMPTY_BUCKET = 'emptyBucket', + RENAME_BUCKET = 'renameBucket', + DELETE_BUCKET = 'deleteBucket', + CREATE_BUCKET = 'createBucket', + + CREATE_SNAPSHOT_FOR_DEBUG = 'createSnapshotForDebug', + + TOGGLE_SHELF_DROPZONE = 'toggleShelfDropzone', + ITEM_DROPPED = 'itemDropped', +} + +export interface IEventContext { + context?: any +} + +type BaseEvent = IEventContext + +export interface ActivateRundownPlaylistEvent extends IEventContext { + rehearsal?: boolean +} + +export type DeactivateRundownPlaylistEvent = IEventContext + +export interface RevealInShelfEvent extends IEventContext { + pieceId: PieceId +} + +export interface SwitchToShelfTabEvent extends IEventContext { + tab: ShelfTabs | string +} + +export interface ShelfStateEvent extends IEventContext { + state: boolean | 'toggle' +} + +export interface MiniShelfQueueAdLibEvent extends IEventContext { + forward: boolean +} + +export interface GoToPartEvent extends IEventContext { + segmentId: SegmentId + partId: PartId + zoomInToFit?: boolean +} + +export interface GoToPartInstanceEvent extends IEventContext { + segmentId: SegmentId + partInstanceId: PartInstanceId + zoomInToFit?: boolean +} + +export interface SelectPieceEvent extends IEventContext { + piece: PieceUi | BucketAdLibItem | IAdLibListItem +} + +export interface HighlightEvent extends IEventContext { + rundownId?: RundownId + segmentId?: SegmentId + partId?: PartId + pieceId?: PieceId | PieceInstanceId +} + +export interface BucketAdLibEvent extends IEventContext { + bucket: Bucket + piece: BucketAdLibItem +} + +export interface BucketEvent extends IEventContext { + bucket: Bucket +} + +export interface TriggerActionEvent extends IEventContext { + actionId: TriggeredActionId +} + +export interface ToggleShelfDropzoneEvent extends IEventContext { + display: boolean + id: string +} + +export interface ItemDroppedEvent extends IEventContext { + id: string + message?: string + error?: string + bucketId: BucketId + ev: any +} + +class RundownViewEventBus0 extends EventEmitter { + emit(event: RundownViewEvents.ACTIVATE_RUNDOWN_PLAYLIST, e: ActivateRundownPlaylistEvent): boolean + emit(event: RundownViewEvents.DEACTIVATE_RUNDOWN_PLAYLIST, e: DeactivateRundownPlaylistEvent): boolean + emit(event: RundownViewEvents.RESYNC_RUNDOWN_PLAYLIST, e: BaseEvent): boolean + emit(event: RundownViewEvents.RESET_RUNDOWN_PLAYLIST, e: BaseEvent): boolean + emit(event: RundownViewEvents.TAKE, e: BaseEvent): boolean + emit(event: RundownViewEvents.REWIND_SEGMENTS): boolean + emit(event: RundownViewEvents.GO_TO_LIVE_SEGMENT): boolean + emit(event: RundownViewEvents.GO_TO_TOP): boolean + emit(event: RundownViewEvents.SEGMENT_ZOOM_ON): boolean + emit(event: RundownViewEvents.SEGMENT_ZOOM_OFF): boolean + emit(event: RundownViewEvents.SHELF_STATE, e: ShelfStateEvent): boolean + emit(event: RundownViewEvents.REVEAL_IN_SHELF, e: RevealInShelfEvent): boolean + emit(event: RundownViewEvents.SWITCH_SHELF_TAB, e: SwitchToShelfTabEvent): boolean + emit(event: RundownViewEvents.MINI_SHELF_QUEUE_ADLIB, e: MiniShelfQueueAdLibEvent): boolean + emit(event: RundownViewEvents.GO_TO_PART, e: GoToPartEvent): boolean + emit(event: RundownViewEvents.GO_TO_PART_INSTANCE, e: GoToPartInstanceEvent): boolean + emit(event: RundownViewEvents.SELECT_PIECE, e: SelectPieceEvent): boolean + emit(event: RundownViewEvents.HIGHLIGHT, e: HighlightEvent): boolean + emit(event: RundownViewEvents.TRIGGER_ACTION, e: TriggerActionEvent): boolean + emit(event: RundownViewEvents.EMPTY_BUCKET, e: BucketEvent): boolean + emit(event: RundownViewEvents.DELETE_BUCKET, e: BucketEvent): boolean + emit(event: RundownViewEvents.RENAME_BUCKET, e: BucketEvent): boolean + emit(event: RundownViewEvents.CREATE_BUCKET, e: IEventContext): boolean + emit(event: RundownViewEvents.DELETE_BUCKET_ADLIB, e: BucketAdLibEvent): boolean + emit(event: RundownViewEvents.RENAME_BUCKET_ADLIB, e: BucketAdLibEvent): boolean + emit(event: RundownViewEvents.CREATE_SNAPSHOT_FOR_DEBUG, e: BaseEvent): boolean + emit(event: RundownViewEvents.TOGGLE_SHELF_DROPZONE, e: ToggleShelfDropzoneEvent): boolean + emit(event: RundownViewEvents.ITEM_DROPPED, e: ItemDroppedEvent): boolean + emit(event: string, ...args: any[]) { + return super.emit(event, ...args) + } + + on(event: RundownViewEvents.ACTIVATE_RUNDOWN_PLAYLIST, listener: (e: ActivateRundownPlaylistEvent) => void): this + on( + event: RundownViewEvents.DEACTIVATE_RUNDOWN_PLAYLIST, + listener: (e: DeactivateRundownPlaylistEvent) => void + ): this + on(event: RundownViewEvents.RESYNC_RUNDOWN_PLAYLIST, listener: (e: BaseEvent) => void): this + on(event: RundownViewEvents.RESET_RUNDOWN_PLAYLIST, listener: (e: BaseEvent) => void): this + on(event: RundownViewEvents.TAKE, listener: (e: BaseEvent) => void): this + on(event: RundownViewEvents.REWIND_SEGMENTS, listener: () => void): this + on(event: RundownViewEvents.GO_TO_LIVE_SEGMENT, listener: () => void): this + on(event: RundownViewEvents.GO_TO_TOP, listener: () => void): this + on(event: RundownViewEvents.SEGMENT_ZOOM_ON, listener: () => void): this + on(event: RundownViewEvents.SEGMENT_ZOOM_OFF, listener: () => void): this + on(event: RundownViewEvents.REVEAL_IN_SHELF, listener: (e: RevealInShelfEvent) => void): this + on(event: RundownViewEvents.SHELF_STATE, listener: (e: ShelfStateEvent) => void): this + on(event: RundownViewEvents.SWITCH_SHELF_TAB, listener: (e: SwitchToShelfTabEvent) => void): this + on(event: RundownViewEvents.MINI_SHELF_QUEUE_ADLIB, listener: (e: MiniShelfQueueAdLibEvent) => void): this + on(event: RundownViewEvents.GO_TO_PART, listener: (e: GoToPartEvent) => void): this + on(event: RundownViewEvents.GO_TO_PART_INSTANCE, listener: (e: GoToPartInstanceEvent) => void): this + on(event: RundownViewEvents.SELECT_PIECE, listener: (e: SelectPieceEvent) => void): this + on(event: RundownViewEvents.HIGHLIGHT, listener: (e: HighlightEvent) => void): this + on(event: RundownViewEvents.TRIGGER_ACTION, listener: (e: TriggerActionEvent) => void): this + on(event: RundownViewEvents.EMPTY_BUCKET, listener: (e: BucketEvent) => void): this + on(event: RundownViewEvents.DELETE_BUCKET, listener: (e: BucketEvent) => void): this + on(event: RundownViewEvents.RENAME_BUCKET, listener: (e: BucketEvent) => void): this + on(event: RundownViewEvents.CREATE_BUCKET, listener: (e: IEventContext) => void): this + on(event: RundownViewEvents.DELETE_BUCKET_ADLIB, listener: (e: BucketAdLibEvent) => void): this + on(event: RundownViewEvents.RENAME_BUCKET_ADLIB, listener: (e: BucketAdLibEvent) => void): this + on(event: RundownViewEvents.CREATE_SNAPSHOT_FOR_DEBUG, listener: (e: BaseEvent) => void): this + on(event: RundownViewEvents.TOGGLE_SHELF_DROPZONE, listener: (e: ToggleShelfDropzoneEvent) => void): this + on(event: RundownViewEvents.ITEM_DROPPED, listener: (e: ItemDroppedEvent) => void): this + on(event: string, listener: (...args: any[]) => void) { + return super.on(event, listener) + } +} + +const RundownViewEventBus = new RundownViewEventBus0() +RundownViewEventBus.setMaxListeners(Number.MAX_SAFE_INTEGER) + +export default RundownViewEventBus diff --git a/packages/webui/src/lib/api/triggers/actionFactory.ts b/packages/webui/src/lib/api/triggers/actionFactory.ts new file mode 100644 index 0000000000..cff5ca6f21 --- /dev/null +++ b/packages/webui/src/lib/api/triggers/actionFactory.ts @@ -0,0 +1,616 @@ +import { + ClientActions, + IAdlibPlayoutActionArguments, + IBaseFilterLink, + IGUIContextFilterLink, + IRundownPlaylistFilterLink, + ITriggeredActionBase, + PlayoutActions, + SomeAction, + Time, +} from '@sofie-automation/blueprints-integration' +import { TFunction } from 'i18next' +import { Meteor } from 'meteor/meteor' +import { Tracker } from 'meteor/tracker' +import { MeteorCall } from '../methods' +import { PartInstance } from '../../collections/PartInstances' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBShowStyleBase, SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' +import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { assertNever, DummyReactiveVar } from '../../lib' +import { logger } from '../../logging' +import RundownViewEventBus, { RundownViewEvents } from './RundownViewEventBus' +import { UserAction } from '../../userAction' +import { doUserAction } from './universalDoUserActionAdapter' +import { + AdLibFilterChainLink, + compileAdLibFilter, + rundownPlaylistFilter, + IWrappedAdLib, +} from './actionFilterChainCompilers' +import { ClientAPI } from '../client' +import { ReactiveVar } from 'meteor/reactive-var' +import { PartId, PartInstanceId, RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartInstances, Parts } from '../../collections/libCollections' +import { RundownPlaylistCollectionUtil } from '../../collections/rundownPlaylistUtil' +import { hashSingleUseToken } from '../userActions' +import { DeviceActions } from '@sofie-automation/shared-lib/dist/core/model/ShowStyle' +import { UserError, UserErrorMessage } from '@sofie-automation/corelib/dist/error' + +// as described in this issue: https://github.com/Microsoft/TypeScript/issues/14094 +type Without = { [P in Exclude]?: never } +// eslint-disable-next-line @typescript-eslint/ban-types +type XOR = T | U extends object ? (Without & U) | (Without & T) : T | U + +export interface ReactivePlaylistActionContext { + rundownPlaylistId: ReactiveVar + rundownPlaylist: ReactiveVar< + Pick + > + + currentRundownId: ReactiveVar + currentSegmentPartIds: ReactiveVar + nextSegmentPartIds: ReactiveVar + currentPartInstanceId: ReactiveVar + currentPartId: ReactiveVar + nextPartId: ReactiveVar +} + +interface PlainPlaylistContext { + rundownPlaylist: DBRundownPlaylist + currentRundownId: RundownId | null + currentSegmentPartIds: PartId[] + nextSegmentPartIds: PartId[] + currentPartId: PartId | null + nextPartId: PartId | null +} + +interface PlainStudioContext { + studio: DBStudio + showStyleBase: DBShowStyleBase +} + +type PlainActionContext = XOR + +export type ActionContext = XOR + +type ActionExecutor = (t: TFunction, e: any, ctx: ActionContext) => void + +/** + * An action compiled down to a single function that can be executed + * + * @interface ExecutableAction + */ +export interface ExecutableAction { + action: ITriggeredActionBase['action'] + /** Execute the action */ + execute: ActionExecutor +} + +/** + * Optionally, the ExecutableAction can support a preview. Currently this is only implemented for AdLib actions. + * This will then return a list of the targeted AdLibs using the normalized form of `IWrappedAdLib` + * + * @interface PreviewableAction + * @extends {ExecutableAction} + */ +interface PreviewableAction extends ExecutableAction { + preview: (ctx: ReactivePlaylistActionContext) => IWrappedAdLib[] +} + +interface ExecutableAdLibAction extends PreviewableAction { + action: PlayoutActions.adlib +} + +export function isPreviewableAction(action: ExecutableAction): action is PreviewableAction { + return action.action && 'preview' in action && typeof action['preview'] === 'function' +} +function createRundownPlaylistContext( + context: ActionContext, + filterChain: IBaseFilterLink[] +): ReactivePlaylistActionContext | undefined { + if (filterChain.length < 1) { + return undefined + } else if (filterChain[0].object === 'view' && context.rundownPlaylistId) { + return context as ReactivePlaylistActionContext + } else if (filterChain[0].object === 'view' && context.rundownPlaylist) { + const playlistContext = context as PlainPlaylistContext + return { + rundownPlaylistId: new DummyReactiveVar(playlistContext.rundownPlaylist._id), + rundownPlaylist: new DummyReactiveVar(playlistContext.rundownPlaylist), + currentRundownId: new DummyReactiveVar(playlistContext.currentRundownId), + currentPartId: new DummyReactiveVar(playlistContext.currentPartId), + nextPartId: new DummyReactiveVar(playlistContext.nextPartId), + currentSegmentPartIds: new DummyReactiveVar(playlistContext.currentSegmentPartIds), + nextSegmentPartIds: new DummyReactiveVar(playlistContext.nextSegmentPartIds), + currentPartInstanceId: new DummyReactiveVar( + playlistContext.rundownPlaylist.currentPartInfo?.partInstanceId ?? null + ), + } + } else if (filterChain[0].object === 'rundownPlaylist' && context.studio && Meteor.isServer) { + const playlist = rundownPlaylistFilter( + context.studio._id, + filterChain.filter((link) => link.object === 'rundownPlaylist') as IRundownPlaylistFilterLink[] + ) + + if (playlist) { + let currentPartId: PartId | null = null, + nextPartId: PartId | null = null, + currentPartInstance: PartInstance | null = null, + currentSegmentPartIds: PartId[] = [], + nextSegmentPartIds: PartId[] = [] + + if (playlist.currentPartInfo) { + currentPartInstance = PartInstances.findOne(playlist.currentPartInfo.partInstanceId) ?? null + const currentPart = currentPartInstance?.part ?? null + if (currentPart) { + currentPartId = currentPart._id + currentSegmentPartIds = Parts.find({ + segmentId: currentPart.segmentId, + }).map((part) => part._id) + } + } + if (playlist.nextPartInfo) { + const nextPart = PartInstances.findOne(playlist.nextPartInfo.partInstanceId)?.part ?? null + if (nextPart) { + nextPartId = nextPart._id + nextSegmentPartIds = Parts.find({ + segmentId: nextPart.segmentId, + }).map((part) => part._id) + } + } + + return { + rundownPlaylistId: new DummyReactiveVar(playlist?._id), + rundownPlaylist: new DummyReactiveVar(playlist), + currentRundownId: new DummyReactiveVar( + currentPartInstance?.rundownId ?? + RundownPlaylistCollectionUtil.getRundownsOrdered(playlist)[0]?._id ?? + null + ), + currentPartId: new DummyReactiveVar(currentPartId), + currentSegmentPartIds: new DummyReactiveVar(currentSegmentPartIds), + nextPartId: new DummyReactiveVar(nextPartId), + nextSegmentPartIds: new DummyReactiveVar(nextSegmentPartIds), + currentPartInstanceId: new DummyReactiveVar(playlist.currentPartInfo?.partInstanceId ?? null), + } + } + } else { + throw new Meteor.Error(501, 'Invalid filter combination') + } +} + +/** + * The big one. This compiles the AdLib filter chain and then executes appropriate UserAction's, depending on a + * particular AdLib type + * + * @param {AdLibFilterChainLink[]} filterChain + * @param {SourceLayers} sourceLayers + * @return {*} {ExecutableAdLibAction} + */ +function createAdLibAction( + filterChain: AdLibFilterChainLink[], + sourceLayers: SourceLayers, + actionArguments: IAdlibPlayoutActionArguments | undefined +): ExecutableAdLibAction { + const compiledAdLibFilter = compileAdLibFilter(filterChain, sourceLayers) + + return { + action: PlayoutActions.adlib, + preview: (ctx) => { + const innerCtx = createRundownPlaylistContext(ctx, filterChain) + + if (innerCtx) { + try { + return compiledAdLibFilter(innerCtx) + } catch (e) { + logger.error(e) + return [] + } + } else { + return [] + } + }, + execute: (t, e, ctx) => { + const innerCtx = createRundownPlaylistContext(ctx, filterChain) + + if (!innerCtx) { + logger.warn(`Could not create RundownPlaylist context for executable AdLib Action`, filterChain) + return + } + const currentPartInstanceId = innerCtx.rundownPlaylist.get().currentPartInfo?.partInstanceId + + const sourceLayerIdsToClear: string[] = [] + Tracker.nonreactive(() => compiledAdLibFilter(innerCtx)).forEach((wrappedAdLib) => { + switch (wrappedAdLib.type) { + case 'adLibPiece': + doUserAction(t, e, UserAction.START_ADLIB, async (e, ts) => + currentPartInstanceId + ? MeteorCall.userAction.segmentAdLibPieceStart( + e, + ts, + innerCtx.rundownPlaylistId.get(), + currentPartInstanceId, + wrappedAdLib.item._id, + false + ) + : ClientAPI.responseSuccess(undefined) + ) + break + case 'rundownBaselineAdLibItem': + doUserAction(t, e, UserAction.START_GLOBAL_ADLIB, async (e, ts) => + currentPartInstanceId + ? MeteorCall.userAction.baselineAdLibPieceStart( + e, + ts, + innerCtx.rundownPlaylistId.get(), + currentPartInstanceId, + wrappedAdLib.item._id, + false + ) + : ClientAPI.responseSuccess(undefined) + ) + break + case 'adLibAction': + doUserAction(t, e, UserAction.START_ADLIB, async (e, ts) => + MeteorCall.userAction.executeAction( + e, + ts, + innerCtx.rundownPlaylistId.get(), + wrappedAdLib._id, + wrappedAdLib.item.actionId, + wrappedAdLib.item.userData, + (actionArguments && actionArguments.triggerMode) || undefined + ) + ) + break + case 'rundownBaselineAdLibAction': + doUserAction(t, e, UserAction.START_GLOBAL_ADLIB, async (e, ts) => + MeteorCall.userAction.executeAction( + e, + ts, + innerCtx.rundownPlaylistId.get(), + wrappedAdLib._id, + wrappedAdLib.item.actionId, + wrappedAdLib.item.userData, + (actionArguments && actionArguments.triggerMode) || undefined + ) + ) + break + case 'clearSourceLayer': + // defer this action to send a single clear action all at once + sourceLayerIdsToClear.push(wrappedAdLib.sourceLayerId) + break + case 'sticky': + doUserAction(t, e, UserAction.START_STICKY_PIECE, async (e, ts) => + MeteorCall.userAction.sourceLayerStickyPieceStart( + e, + ts, + innerCtx.rundownPlaylistId.get(), + wrappedAdLib.sourceLayerId // + ) + ) + break + default: + assertNever(wrappedAdLib) + return + } + }) + + if (currentPartInstanceId && sourceLayerIdsToClear.length > 0) { + doUserAction(t, e, UserAction.CLEAR_SOURCELAYER, async (e, ts) => + MeteorCall.userAction.sourceLayerOnPartStop( + e, + ts, + innerCtx.rundownPlaylistId.get(), + currentPartInstanceId, + sourceLayerIdsToClear + ) + ) + } + }, + } +} + +function createShelfAction(_filterChain: IGUIContextFilterLink[], state: boolean | 'toggle'): ExecutableAction { + return { + action: ClientActions.shelf, + execute: () => { + RundownViewEventBus.emit(RundownViewEvents.SHELF_STATE, { + state, + }) + }, + } +} + +function createMiniShelfQueueAdLibAction(_filterChain: IGUIContextFilterLink[], forward: boolean): ExecutableAction { + return { + action: ClientActions.miniShelfQueueAdLib, + execute: (_t, e) => { + RundownViewEventBus.emit(RundownViewEvents.MINI_SHELF_QUEUE_ADLIB, { + forward, + context: e, + }) + }, + } +} + +function createGoToOnAirLineAction(_filterChain: IGUIContextFilterLink[]): ExecutableAction { + return { + action: ClientActions.goToOnAirLine, + execute: () => { + RundownViewEventBus.emit(RundownViewEvents.GO_TO_LIVE_SEGMENT) + }, + } +} + +function createRewindSegmentsAction(_filterChain: IGUIContextFilterLink[]): ExecutableAction { + return { + action: ClientActions.rewindSegments, + execute: () => { + RundownViewEventBus.emit(RundownViewEvents.REWIND_SEGMENTS) + }, + } +} + +function createRundownPlaylistSoftTakeAction(_filterChain: IGUIContextFilterLink[]): ExecutableAction { + return { + action: PlayoutActions.take, + execute: (_t, e) => { + RundownViewEventBus.emit(RundownViewEvents.TAKE, { + context: e, + }) + }, + } +} + +function createRundownPlaylistSoftActivateAction( + _filterChain: IGUIContextFilterLink[], + rehearsal: boolean +): ExecutableAction { + return { + action: PlayoutActions.activateRundownPlaylist, + execute: (_t, e) => { + RundownViewEventBus.emit(RundownViewEvents.ACTIVATE_RUNDOWN_PLAYLIST, { + context: e, + rehearsal, + }) + }, + } +} + +function createRundownPlaylistSoftDeactivateAction(): ExecutableAction { + return { + action: PlayoutActions.deactivateRundownPlaylist, + execute: (_t, e) => { + RundownViewEventBus.emit(RundownViewEvents.DEACTIVATE_RUNDOWN_PLAYLIST, { + context: e, + }) + }, + } +} + +function createRundownPlaylistSoftResyncAction(_filterChain: IGUIContextFilterLink[]): ExecutableAction { + return { + action: PlayoutActions.resyncRundownPlaylist, + execute: (_t, e) => { + RundownViewEventBus.emit(RundownViewEvents.RESYNC_RUNDOWN_PLAYLIST, { + context: e, + }) + }, + } +} + +function createShowEntireCurrentSegmentAction(_filterChain: IGUIContextFilterLink[], on: boolean): ExecutableAction { + return { + action: ClientActions.showEntireCurrentSegment, + execute: () => { + if (on) { + RundownViewEventBus.emit(RundownViewEvents.SEGMENT_ZOOM_ON) + } else { + RundownViewEventBus.emit(RundownViewEvents.SEGMENT_ZOOM_OFF) + } + }, + } +} + +function createRundownPlaylistSoftResetRundownAction(_filterChain: IGUIContextFilterLink[]): ExecutableAction { + return { + action: PlayoutActions.resetRundownPlaylist, + execute: (_t, e) => { + RundownViewEventBus.emit(RundownViewEvents.RESET_RUNDOWN_PLAYLIST, { + context: e, + }) + }, + } +} + +function createTakeRundownSnapshotAction(_filterChain: IGUIContextFilterLink[]): ExecutableAction { + return { + action: PlayoutActions.createSnapshotForDebug, + execute: (_t, e) => { + RundownViewEventBus.emit(RundownViewEvents.CREATE_SNAPSHOT_FOR_DEBUG, { + context: e, + }) + }, + } +} + +/** + * A utility method to create an ExecutableAction wrapping a simple UserAction call that takes some variables from + * InternalActionContext as input + * + * @param {SomeAction} action + * @param {UserAction} userAction + * @param {(e: any, ctx: InternalActionContext) => Promise>} userActionExec + * @return {*} {ExecutableAction} + */ +function createUserActionWithCtx( + action: SomeAction, + userAction: UserAction, + userActionExec: (e: string, ts: Time, ctx: ReactivePlaylistActionContext) => Promise> +): ExecutableAction { + return { + action: action.action, + execute: (t, e, ctx) => { + const innerCtx = Tracker.nonreactive(() => createRundownPlaylistContext(ctx, action.filterChain)) + if (innerCtx) { + doUserAction(t, e, userAction, async (e, ts) => userActionExec(e, ts, innerCtx)) + } + }, + } +} + +/** + * This is a factory method to create the ExecutableAction from a SomeAction-type description + * @param action + * @param sourceLayers + * @returns + */ +export function createAction(action: SomeAction, sourceLayers: SourceLayers): ExecutableAction { + switch (action.action) { + case ClientActions.shelf: + return createShelfAction(action.filterChain, action.state) + case ClientActions.goToOnAirLine: + return createGoToOnAirLineAction(action.filterChain) + case ClientActions.rewindSegments: + return createRewindSegmentsAction(action.filterChain) + case PlayoutActions.adlib: + return createAdLibAction(action.filterChain, sourceLayers, action.arguments || undefined) + case PlayoutActions.activateRundownPlaylist: + if (action.force) { + return createUserActionWithCtx( + action, + UserAction.DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, + async (e, ts, ctx) => + MeteorCall.userAction.forceResetAndActivate( + e, + ts, + ctx.rundownPlaylistId.get(), + !!action.rehearsal || false + ) + ) + } else { + if (isActionTriggeredFromUiContext(action)) { + return createRundownPlaylistSoftActivateAction( + action.filterChain as IGUIContextFilterLink[], + !!action.rehearsal + ) + } else { + return createUserActionWithCtx(action, UserAction.ACTIVATE_RUNDOWN_PLAYLIST, async (e, ts, ctx) => + MeteorCall.userAction.activate(e, ts, ctx.rundownPlaylistId.get(), !!action.rehearsal || false) + ) + } + } + case PlayoutActions.deactivateRundownPlaylist: + if (isActionTriggeredFromUiContext(action)) { + return createRundownPlaylistSoftDeactivateAction() + } + return createUserActionWithCtx(action, UserAction.DEACTIVATE_RUNDOWN_PLAYLIST, async (e, ts, ctx) => + MeteorCall.userAction.deactivate(e, ts, ctx.rundownPlaylistId.get()) + ) + case PlayoutActions.activateAdlibTestingMode: + return createUserActionWithCtx(action, UserAction.ACTIVATE_ADLIB_TESTING, async (e, ts, ctx) => { + const rundownId = ctx.currentRundownId.get() + if (rundownId) { + return MeteorCall.userAction.activateAdlibTestingMode(e, ts, ctx.rundownPlaylistId.get(), rundownId) + } else { + return ClientAPI.responseError(UserError.create(UserErrorMessage.InactiveRundown)) + } + }) + case PlayoutActions.take: + if (isActionTriggeredFromUiContext(action)) { + return createRundownPlaylistSoftTakeAction(action.filterChain as IGUIContextFilterLink[]) + } else { + return createUserActionWithCtx(action, UserAction.TAKE, async (e, ts, ctx) => + MeteorCall.userAction.take(e, ts, ctx.rundownPlaylistId.get(), ctx.currentPartInstanceId.get()) + ) + } + case PlayoutActions.hold: + return createUserActionWithCtx(action, UserAction.ACTIVATE_HOLD, async (e, ts, ctx) => + MeteorCall.userAction.activateHold(e, ts, ctx.rundownPlaylistId.get(), !!action.undo) + ) + case PlayoutActions.disableNextPiece: + return createUserActionWithCtx(action, UserAction.DISABLE_NEXT_PIECE, async (e, ts, ctx) => + MeteorCall.userAction.disableNextPiece(e, ts, ctx.rundownPlaylistId.get(), !!action.undo) + ) + case PlayoutActions.createSnapshotForDebug: + if (isActionTriggeredFromUiContext(action)) { + return createTakeRundownSnapshotAction(action.filterChain as IGUIContextFilterLink[]) + } else { + return createUserActionWithCtx(action, UserAction.CREATE_SNAPSHOT_FOR_DEBUG, async (e, ts, ctx) => + MeteorCall.system.generateSingleUseToken().then(async (tokenResult) => { + if (ClientAPI.isClientResponseError(tokenResult) || !tokenResult.result) throw tokenResult + return MeteorCall.userAction.storeRundownSnapshot( + e, + ts, + hashSingleUseToken(tokenResult.result), + ctx.rundownPlaylistId.get(), + `action`, + false + ) + }) + ) + } + case PlayoutActions.moveNext: + return createUserActionWithCtx(action, UserAction.MOVE_NEXT, async (e, ts, ctx) => + MeteorCall.userAction.moveNext( + e, + ts, + ctx.rundownPlaylistId.get(), + action.parts ?? 0, + action.segments ?? 0 + ) + ) + case PlayoutActions.reloadRundownPlaylistData: + if (isActionTriggeredFromUiContext(action)) { + return createRundownPlaylistSoftResyncAction(action.filterChain as IGUIContextFilterLink[]) + } else { + return createUserActionWithCtx(action, UserAction.RELOAD_RUNDOWN_PLAYLIST_DATA, async (e, ts, ctx) => + // TODO: Needs some handling of the response. Perhaps this should switch to + // an event on the RundownViewEventBus, if ran on the client? + MeteorCall.userAction.resyncRundownPlaylist(e, ts, ctx.rundownPlaylistId.get()) + ) + } + case PlayoutActions.resetRundownPlaylist: + if (isActionTriggeredFromUiContext(action)) { + return createRundownPlaylistSoftResetRundownAction(action.filterChain as IGUIContextFilterLink[]) + } else { + return createUserActionWithCtx(action, UserAction.RESET_RUNDOWN_PLAYLIST, async (e, ts, ctx) => + MeteorCall.userAction.resetRundownPlaylist(e, ts, ctx.rundownPlaylistId.get()) + ) + } + case PlayoutActions.resyncRundownPlaylist: + return createUserActionWithCtx(action, UserAction.RESYNC_RUNDOWN_PLAYLIST, async (e, ts, ctx) => + MeteorCall.userAction.resyncRundownPlaylist(e, ts, ctx.rundownPlaylistId.get()) + ) + case ClientActions.showEntireCurrentSegment: + return createShowEntireCurrentSegmentAction(action.filterChain, action.on) + case ClientActions.miniShelfQueueAdLib: + return createMiniShelfQueueAdLibAction(action.filterChain, action.forward) + case DeviceActions.modifyShiftRegister: + return { + action: action.action, + execute: () => { + // do nothing + }, + } + default: + assertNever(action) + break + } + + // return a NO-OP, if not recognized + return { + // @ts-expect-error action.action is "never", based on TypeScript rules, but if input doesn't folllow them, + // it can actually exist + action: action.action, + execute: () => { + // Nothing + }, + } +} + +function isActionTriggeredFromUiContext(action: SomeAction): boolean { + return Meteor.isClient && action.filterChain.every((link) => link.object === 'view') +} diff --git a/packages/webui/src/lib/api/triggers/actionFilterChainCompilers.ts b/packages/webui/src/lib/api/triggers/actionFilterChainCompilers.ts new file mode 100644 index 0000000000..6d3119e4ad --- /dev/null +++ b/packages/webui/src/lib/api/triggers/actionFilterChainCompilers.ts @@ -0,0 +1,802 @@ +import { + IAdLibFilterLink, + IBlueprintActionManifestDisplayContent, + IGUIContextFilterLink, + IOutputLayer, + IRundownPlaylistFilterLink, + ISourceLayer, + ITranslatableMessage, + PieceLifespan, +} from '@sofie-automation/blueprints-integration' +import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' +import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibAction' +import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibPiece' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { SourceLayers } from '@sofie-automation/corelib/dist/dataModel/ShowStyleBase' +import { assertNever, generateTranslation } from '../../lib' +import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' +import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { sortAdlibs } from '../../adlibs' +import { ReactivePlaylistActionContext } from './actionFactory' +import { FindOptions } from '../../collections/lib' +import { PartId, RundownId, SegmentId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { IWrappedAdLibBase } from '@sofie-automation/shared-lib/dist/input-gateway/deviceTriggerPreviews' +import { memoizedIsolatedAutorun } from '../../memoizedIsolatedAutorun' +import { + AdLibActions, + AdLibPieces, + Parts, + RundownBaselineAdLibActions, + RundownBaselineAdLibPieces, + RundownPlaylists, + Rundowns, + Segments, +} from '../../collections/libCollections' + +export type AdLibFilterChainLink = IRundownPlaylistFilterLink | IGUIContextFilterLink | IAdLibFilterLink + +/** This is a compiled Filter type, targetting a particular MongoCollection */ +type CompiledFilter = { + selector: MongoQuery + options: FindOptions + pick: number | undefined + limit: number | undefined + global: boolean | undefined + segment: 'current' | 'next' | undefined + part: 'current' | 'next' | undefined + arguments?: { + triggerMode: string + } + /** + * The query compiler has determined that this filter will always return an empty set, + * it's safe to skip it entirely. + */ + skip?: true +} + +type SomeAdLib = RundownBaselineAdLibItem | RundownBaselineAdLibAction | AdLibPiece | AdLibAction + +interface IWrappedAdLibType extends IWrappedAdLibBase { + _id: T['_id'] + _rank: number + partId: PartId | null + type: typeName + label: string | ITranslatableMessage + sourceLayerId?: ISourceLayer['_id'] + outputLayerId?: IOutputLayer['_id'] + expectedDuration?: number | PieceLifespan + item: T +} + +/** What follows are utility functions to wrap various AdLib objects to IWrappedAdLib */ + +function wrapAdLibAction(adLib: AdLibAction, type: 'adLibAction'): IWrappedAdLib { + return { + _id: adLib._id, + _rank: adLib.display?._rank || 0, + partId: adLib.partId, + type: type, + label: adLib.display?.label, + sourceLayerId: (adLib.display as IBlueprintActionManifestDisplayContent)?.sourceLayerId, + outputLayerId: (adLib.display as IBlueprintActionManifestDisplayContent)?.outputLayerId, + expectedDuration: undefined, + item: adLib, + } +} + +function wrapRundownBaselineAdLibAction( + adLib: RundownBaselineAdLibAction, + type: 'rundownBaselineAdLibAction' +): IWrappedAdLib { + return { + _id: adLib._id, + _rank: adLib.display?._rank ?? 0, + partId: adLib.partId ?? null, + type: type, + label: adLib.display?.label, + sourceLayerId: (adLib.display as IBlueprintActionManifestDisplayContent)?.sourceLayerId, + outputLayerId: (adLib.display as IBlueprintActionManifestDisplayContent)?.outputLayerId, + expectedDuration: undefined, + item: adLib, + } +} + +function wrapAdLibPiece( + adLib: T, + type: 'adLibPiece' | 'rundownBaselineAdLibItem' +): IWrappedAdLib { + return { + _id: adLib._id, + _rank: adLib._rank, + partId: adLib.partId ?? null, + type: type, + label: adLib.name, + sourceLayerId: adLib.sourceLayerId, + outputLayerId: adLib.outputLayerId, + expectedDuration: adLib.expectedDuration || adLib.lifespan, + item: adLib, + } +} + +export type IWrappedAdLib = + | IWrappedAdLibType + | IWrappedAdLibType + | IWrappedAdLibType + | IWrappedAdLibType + | { + _id: ISourceLayer['_id'] + _rank: number + partId: PartId | null + type: 'clearSourceLayer' + label: string | ITranslatableMessage + sourceLayerId: ISourceLayer['_id'] + outputLayerId: undefined + expectedDuration: undefined + item: ISourceLayer + } + | { + _id: ISourceLayer['_id'] + _rank: number + partId: PartId | null + type: 'sticky' + label: string | ITranslatableMessage + sourceLayerId: ISourceLayer['_id'] + outputLayerId: undefined + expectedDuration: undefined + item: ISourceLayer + } + +/** What follows are methods to compile a filterChain to a CompiledFilter (a MongoQuery with options and some + * additional flags, used for performance optimization ) */ + +function sharedSourceLayerFilterCompiler( + filterChain: IAdLibFilterLink[], + sourceLayers: SourceLayers, + targetType: 'clear' | 'sticky' +): { + global: boolean | undefined + skip: true | undefined + sourceLayerIds: string[] | undefined +} { + let global: boolean | undefined = undefined + let skip: true | undefined = undefined + let sourceLayerIds: string[] | undefined = undefined + + filterChain.forEach((link) => { + switch (link.field) { + case 'global': + global = link.value + if (global === false) { + skip = true + } + return + case 'label': + // skip this filter, we assume clear ad-libs have no labels for the purpose of the triggers + skip = true + return + case 'outputLayerId': + // skip this filter, we assume clear ad-libs have no output layers for the purpose of the triggers + skip = true + return + case 'sourceLayerId': + sourceLayerIds = link.value + return + case 'sourceLayerType': + sourceLayerIds = Object.values(sourceLayers) + .map((sourceLayer) => + sourceLayer && link.value.includes(sourceLayer.type) ? sourceLayer._id : undefined + ) + .filter(Boolean) as string[] + return + case 'segment': + // skip this filter, clear adlibs are global + skip = true + return + case 'part': + // skip this filter, clear adlibs are global + skip = true + return + case 'tag': + // skip this filter, clear adlibs have no tags + skip = true + return + case 'type': + if (link.value !== targetType) { + skip = true + } + return + case 'limit': + // we can skip the limit stage here, it's going to be done at the final step anyway and + // it doesn't speed anything up + return + case 'pick': + // we can skip the pick stage here, it's done later anyway + return + case 'pickEnd': + // we can skip the pick stage here, it's done later anyway + return + default: + assertNever(link) + return + } + }) + + return { + global, + skip, + sourceLayerIds, + } +} + +function compileAndRunClearFilter(filterChain: IAdLibFilterLink[], sourceLayers: SourceLayers): IWrappedAdLib[] { + const { skip, sourceLayerIds } = sharedSourceLayerFilterCompiler(filterChain, sourceLayers, 'clear') + + let result: IWrappedAdLib[] = [] + + if (!skip) { + result = Object.values(sourceLayers) + .filter( + (sourceLayer): sourceLayer is ISourceLayer => + !!( + sourceLayer && + (sourceLayerIds ? sourceLayerIds.includes(sourceLayer._id) : true) && + sourceLayer.isClearable + ) + ) + .map((sourceLayer) => { + return { + _id: sourceLayer._id, + _rank: sourceLayer._rank, + item: sourceLayer, + type: 'clearSourceLayer', + sourceLayerId: sourceLayer._id, + label: generateTranslation('Clear {{layerName}}', { layerName: sourceLayer.name }), + expectedDuration: undefined, + outputLayerId: undefined, + } as IWrappedAdLib + }) + .sort((a, b) => a._rank - b._rank) + } + + return result +} + +function compileAndRunStickyFilter(filterChain: IAdLibFilterLink[], sourceLayers: SourceLayers): IWrappedAdLib[] { + const { skip, sourceLayerIds } = sharedSourceLayerFilterCompiler(filterChain, sourceLayers, 'sticky') + + let result: IWrappedAdLib[] = [] + + if (!skip) { + result = Object.values(sourceLayers) + .filter( + (sourceLayer): sourceLayer is ISourceLayer => + !!( + sourceLayer && + (sourceLayerIds ? sourceLayerIds.includes(sourceLayer._id) : true) && + sourceLayer.isSticky === true + ) + ) + .map((sourceLayer) => { + return { + _id: sourceLayer._id, + _rank: sourceLayer._rank, + item: sourceLayer, + type: 'sticky', + sourceLayerId: sourceLayer._id, + label: generateTranslation('Last {{layerName}}', { layerName: sourceLayer.name }), + expectedDuration: undefined, + outputLayerId: undefined, + } as IWrappedAdLib + }) + .sort((a, b) => a._rank - b._rank) + } + + return result +} + +type AdLibActionType = RundownBaselineAdLibAction | AdLibAction + +function compileAdLibActionFilter( + filterChain: IAdLibFilterLink[], + sourceLayers: SourceLayers +): CompiledFilter { + const selector: MongoQuery = {} + const options: FindOptions = {} + let pick: number | undefined = undefined + let limit: number | undefined = undefined + let global: boolean | undefined = undefined + let skip: true | undefined = undefined + let segment: 'current' | 'next' | undefined = undefined + let part: 'current' | 'next' | undefined = undefined + + filterChain.forEach((link) => { + switch (link.field) { + case 'global': + selector['partId'] = { + $exists: !link.value, + } + global = link.value + return + case 'label': + selector['display.label.key'] = { + $regex: Array.isArray(link.value) ? link.value.join('|') : link.value, + } + return + case 'outputLayerId': + selector['display.outputLayerId'] = { + $in: link.value, + } + return + case 'sourceLayerId': + selector['display.sourceLayerId'] = { + $in: link.value, + } + return + case 'sourceLayerType': + selector['display.sourceLayerId'] = { + $in: Object.values(sourceLayers) + .map((sourceLayer) => + sourceLayer && link.value.includes(sourceLayer.type) ? sourceLayer._id : undefined + ) + .filter(Boolean) as string[], + } + return + case 'segment': + if (global) { + skip = true + } + segment = link.value + return + case 'part': + if (global) { + skip = true + } + part = link.value + return + case 'tag': + selector['display.tags'] = { + $all: link.value, + } + return + case 'type': + if (link.value !== 'adLibAction') { + skip = true + } + return + case 'limit': + limit = link.value + return + case 'pick': + pick = link.value + return + case 'pickEnd': + pick = (link.value + 1) * -1 + return + default: + assertNever(link) + return + } + }) + + return { + selector, + options, + global, + segment, + part, + limit, + pick, + skip, + } +} + +type AdLibPieceType = RundownBaselineAdLibItem | AdLibPiece + +function compileAdLibPieceFilter( + filterChain: IAdLibFilterLink[], + sourceLayers: SourceLayers +): CompiledFilter { + const selector: MongoQuery = {} + const options: FindOptions = {} + let pick: number | undefined = undefined + let limit: number | undefined = undefined + let global: boolean | undefined = undefined + let skip: true | undefined = undefined + let segment: 'current' | 'next' | undefined = undefined + let part: 'current' | 'next' | undefined = undefined + + filterChain.forEach((link) => { + switch (link.field) { + case 'global': + selector['partId'] = { + $exists: !link.value, + } + global = link.value + return + case 'label': + selector['name'] = { + $regex: Array.isArray(link.value) ? link.value.join('|') : link.value, + } + return + case 'outputLayerId': + selector['outputLayerId'] = { + $in: link.value, + } + return + case 'sourceLayerId': + selector['sourceLayerId'] = { + $in: link.value, + } + return + case 'sourceLayerType': + selector['sourceLayerId'] = { + $in: Object.values(sourceLayers) + .map((sourceLayer) => + sourceLayer && link.value.includes(sourceLayer.type) ? sourceLayer._id : undefined + ) + .filter(Boolean) as string[], + } + return + case 'segment': + if (global) { + skip = true + } + segment = link.value + return + case 'part': + if (global) { + skip = true + } + part = link.value + return + case 'tag': + selector['tags'] = { + $all: link.value, + } + return + case 'type': + if (link.value !== 'adLib') { + skip = true + } + return + case 'limit': + limit = link.value + return + case 'pick': + pick = link.value + return + case 'pickEnd': + pick = (link.value + 1) * -1 + return + default: + assertNever(link) + return + } + }) + + return { + selector, + options, + global, + segment, + part, + limit, + pick, + skip, + } +} + +/** + * Compile the filter chain and return a reactive function that will return the result set for this adLib filter + * @param filterChain + * @param sourceLayers + * @returns + */ +export function compileAdLibFilter( + filterChain: AdLibFilterChainLink[], + sourceLayers: SourceLayers +): (context: ReactivePlaylistActionContext) => IWrappedAdLib[] { + const onlyAdLibLinks = filterChain.filter((link) => link.object === 'adLib') as IAdLibFilterLink[] + const adLibPieceTypeFilter = compileAdLibPieceFilter(onlyAdLibLinks, sourceLayers) + const adLibActionTypeFilter = compileAdLibActionFilter(onlyAdLibLinks, sourceLayers) + + const clearAdLibs = compileAndRunClearFilter(onlyAdLibLinks, sourceLayers) + const stickyAdLibs = compileAndRunStickyFilter(onlyAdLibLinks, sourceLayers) + + return (context: ReactivePlaylistActionContext) => { + let rundownBaselineAdLibItems: IWrappedAdLib[] = [] + let adLibPieces: IWrappedAdLib[] = [] + let rundownBaselineAdLibActions: IWrappedAdLib[] = [] + let adLibActions: IWrappedAdLib[] = [] + const segmentPartIds = + adLibPieceTypeFilter.segment === 'current' + ? context.currentSegmentPartIds.get() + : adLibPieceTypeFilter.segment === 'next' + ? context.nextSegmentPartIds.get() + : undefined + + const singlePartId = + adLibPieceTypeFilter.part === 'current' + ? context.currentPartId.get() + : adLibPieceTypeFilter.part === 'next' + ? context.nextPartId.get() + : undefined + + /** Note: undefined means that all parts are to be considered */ + let partFilter: PartId[] | undefined = undefined + + // Figure out the intersection of the segment current/next filter + // and the part current/next filter. + // It is possible to say "only from current segment" & "only from next part" + // with the result being empty, if the next part is in another segment + if (segmentPartIds === undefined && singlePartId !== undefined) { + if (singlePartId !== null) { + partFilter = [singlePartId] + } else { + partFilter = [] + } + } else if (segmentPartIds !== undefined && singlePartId === undefined) { + partFilter = segmentPartIds + } else if (segmentPartIds !== undefined && singlePartId !== undefined) { + if (singlePartId !== null && segmentPartIds.includes(singlePartId)) { + partFilter = [singlePartId] + } else { + partFilter = [] + } + } + + { + let skip = adLibPieceTypeFilter.skip + const currentNextOverride: MongoQuery = {} + + if (partFilter) { + if (partFilter.length === 0) { + skip = true + } else { + currentNextOverride['partId'] = { + $in: partFilter, + } + } + } + + const currentRundownId = context.currentRundownId.get() + if (!skip && currentRundownId) { + if (adLibPieceTypeFilter.global === undefined || adLibPieceTypeFilter.global === true) + rundownBaselineAdLibItems = RundownBaselineAdLibPieces.find( + { + ...adLibPieceTypeFilter.selector, + ...currentNextOverride, + rundownId: currentRundownId, + } as MongoQuery, + adLibPieceTypeFilter.options + ).map((item) => wrapAdLibPiece(item, 'rundownBaselineAdLibItem')) + if (adLibPieceTypeFilter.global === undefined || adLibPieceTypeFilter.global === false) + adLibPieces = AdLibPieces.find( + { + ...adLibPieceTypeFilter.selector, + ...currentNextOverride, + rundownId: currentRundownId, + } as MongoQuery, + adLibPieceTypeFilter.options + ).map((item) => wrapAdLibPiece(item, 'adLibPiece')) + } + } + + { + let skip = adLibActionTypeFilter.skip + const currentNextOverride: MongoQuery = {} + + if (partFilter) { + if (partFilter.length === 0) { + skip = true + } else { + currentNextOverride['partId'] = { + $in: partFilter, + } + } + } + + const currentRundownId = context.currentRundownId.get() + if (!skip && currentRundownId) { + if (adLibActionTypeFilter.global === undefined || adLibActionTypeFilter.global === true) + rundownBaselineAdLibActions = RundownBaselineAdLibActions.find( + { + ...adLibActionTypeFilter.selector, + ...currentNextOverride, + rundownId: currentRundownId, + } as MongoQuery, + adLibActionTypeFilter.options + ).map((item) => wrapRundownBaselineAdLibAction(item, 'rundownBaselineAdLibAction')) + if (adLibActionTypeFilter.global === undefined || adLibActionTypeFilter.global === false) + adLibActions = AdLibActions.find( + { + ...adLibActionTypeFilter.selector, + ...currentNextOverride, + rundownId: currentRundownId, + } as MongoQuery, + adLibActionTypeFilter.options + ).map((item) => wrapAdLibAction(item, 'adLibAction')) + } + } + + const rundownRankMap = new Map() + const segmentRankMap = new Map() + const partRankMap = new Map() + { + if (partFilter === undefined || partFilter.length > 0) { + // Note: We need to return an array from within memoizedIsolatedAutorun, + // because _.isEqual (used in memoizedIsolatedAutorun) doesn't work with Maps.. + + const rundownPlaylistId = context.rundownPlaylistId.get() + const rundownRanks = memoizedIsolatedAutorun(() => { + const playlist = RundownPlaylists.findOne(rundownPlaylistId, { + projection: { + rundownIdsInOrder: 1, + }, + }) as Pick | undefined + + if (playlist?.rundownIdsInOrder) { + return playlist.rundownIdsInOrder + } else { + const rundowns = Rundowns.find( + { + playlistId: rundownPlaylistId, + }, + { + fields: { + _id: 1, + }, + } + ).fetch() as Pick[] + + return rundowns.map((r) => r._id) + } + }, `rundownsRanksForPlaylist_${rundownPlaylistId}`) + rundownRanks.forEach((id, index) => { + rundownRankMap.set(id, index) + }) + + const segmentRanks = memoizedIsolatedAutorun( + () => + Segments.find( + { + rundownId: { $in: Array.from(rundownRankMap.keys()) }, + }, + { + fields: { + _id: 1, + _rank: 1, + }, + } + ).fetch() as Pick[], + `segmentRanksForRundowns_${Array.from(rundownRankMap.keys()).join(',')}` + ) + segmentRanks.forEach((segment) => { + segmentRankMap.set(segment._id, segment._rank) + }) + + const partRanks = memoizedIsolatedAutorun(() => { + if (!partFilter) { + return Parts.find( + { + rundownId: { $in: Array.from(rundownRankMap.keys()) }, + }, + { + fields: { + _id: 1, + segmentId: 1, + rundownId: 1, + _rank: 1, + }, + } + ).fetch() as Pick[] + } else { + return Parts.find( + { _id: { $in: partFilter } }, + { + fields: { + _id: 1, + segmentId: 1, + rundownId: 1, + _rank: 1, + }, + } + ).fetch() as Pick[] + } + }, `partRanks_${JSON.stringify(partFilter ?? rundownRankMap.keys())}`) + + partRanks.forEach((part) => { + partRankMap.set(part._id, part) + }) + } + } + + let resultingAdLibs: IWrappedAdLib[] = [] + + { + resultingAdLibs = [ + ...rundownBaselineAdLibItems, + ...rundownBaselineAdLibActions, + ...adLibPieces, + ...adLibActions, + ...clearAdLibs, + ...stickyAdLibs, + ] + + // Sort the adliba: + resultingAdLibs = sortAdlibs( + resultingAdLibs.map((adlib) => { + const part = adlib.partId && partRankMap.get(adlib.partId) + const segmentRank = part?.segmentId && segmentRankMap.get(part.segmentId) + const rundownRank = part?.rundownId && rundownRankMap.get(part.rundownId) + + return { + adlib: adlib, + label: adlib.label, + adlibRank: adlib._rank, + adlibId: adlib._id, + partRank: part?._rank ?? null, + segmentRank: segmentRank ?? null, + rundownRank: rundownRank ?? null, + } + }) + ) + + // finalize the process: apply limit and pick + if (adLibPieceTypeFilter.limit !== undefined) { + resultingAdLibs = resultingAdLibs.slice(0, adLibPieceTypeFilter.limit) + } + if (adLibPieceTypeFilter.pick !== undefined && adLibPieceTypeFilter.pick >= 0) { + resultingAdLibs = [resultingAdLibs[adLibPieceTypeFilter.pick]] + } else if (adLibPieceTypeFilter.pick !== undefined && adLibPieceTypeFilter.pick < 0) { + resultingAdLibs = [resultingAdLibs[resultingAdLibs.length + adLibPieceTypeFilter.pick]] + } + } + + // remove any falsy values from the result set + return resultingAdLibs.filter(Boolean) + } +} + +export function rundownPlaylistFilter( + studioId: StudioId, + filterChain: IRundownPlaylistFilterLink[] +): DBRundownPlaylist | undefined { + const selector: MongoQuery = { + $and: [ + { + studioId, + }, + ], + } + + filterChain.forEach((link) => { + switch (link.field) { + case 'activationId': + selector['activationId'] = { + $exists: link.value, + } + break + case 'name': + selector['name'] = { + $regex: link.value, + } + break + case 'studioId': + selector['$and']?.push({ + studioId: { + $regex: link.value as any, + }, + }) + break + default: + assertNever(link) + break + } + }) + + return RundownPlaylists.findOne(selector) +} diff --git a/packages/webui/src/lib/api/triggers/triggerTypeSelectors.ts b/packages/webui/src/lib/api/triggers/triggerTypeSelectors.ts new file mode 100644 index 0000000000..2dbc0f5f07 --- /dev/null +++ b/packages/webui/src/lib/api/triggers/triggerTypeSelectors.ts @@ -0,0 +1,18 @@ +import { + SomeBlueprintTrigger, + IBlueprintHotkeyTrigger, + TriggerType, + IBlueprintDeviceTrigger, +} from '@sofie-automation/blueprints-integration' + +export function isHotkeyTrigger(trigger: SomeBlueprintTrigger | undefined): trigger is IBlueprintHotkeyTrigger { + if (!trigger) return false + if (trigger.type === TriggerType.hotkey) return true + return false +} + +export function isDeviceTrigger(trigger: SomeBlueprintTrigger | undefined): trigger is IBlueprintDeviceTrigger { + if (!trigger) return false + if (trigger.type === TriggerType.device) return true + return false +} diff --git a/packages/webui/src/lib/api/triggers/universalDoUserActionAdapter.ts b/packages/webui/src/lib/api/triggers/universalDoUserActionAdapter.ts new file mode 100644 index 0000000000..f6ac538e4b --- /dev/null +++ b/packages/webui/src/lib/api/triggers/universalDoUserActionAdapter.ts @@ -0,0 +1,26 @@ +import { TFunction } from 'i18next' +import { Meteor } from 'meteor/meteor' +import { ClientAPI } from '../client' +import { doUserAction as clientDoUserAction } from '../../clientUserAction' +import { UserAction } from '../../userAction' +import { getCurrentTime, Time } from '../../lib' + +export function doUserAction( + t: TFunction, + userEvent: string, + action: UserAction, + fcn: (event: string, timeStamp: Time) => Promise>, + callback?: (err: any, res?: Result) => void | boolean, + okMessage?: string +): void { + if (Meteor.isClient) { + clientDoUserAction(t, userEvent, action, fcn, callback, okMessage) + } else { + fcn(userEvent, getCurrentTime()).then( + (value) => + typeof callback === 'function' && + (ClientAPI.isClientResponseSuccess(value) ? callback(undefined, value.result) : callback(value)), + (reason) => typeof callback === 'function' && callback(reason) + ) + } +} diff --git a/packages/webui/src/lib/api/upgradeStatus.ts b/packages/webui/src/lib/api/upgradeStatus.ts new file mode 100644 index 0000000000..d3a5891d4e --- /dev/null +++ b/packages/webui/src/lib/api/upgradeStatus.ts @@ -0,0 +1,40 @@ +import { ITranslatableMessage } from '@sofie-automation/blueprints-integration' +import { StudioId, ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ProtectedString } from '../lib' + +export type UIBlueprintUpgradeStatusId = ProtectedString<'UIBlueprintUpgradeStatus'> + +export type UIBlueprintUpgradeStatus = UIBlueprintUpgradeStatusStudio | UIBlueprintUpgradeStatusShowStyle + +export interface UIBlueprintUpgradeStatusBase { + _id: UIBlueprintUpgradeStatusId + + documentType: 'studio' | 'showStyle' + documentId: StudioId | ShowStyleBaseId + + name: string + + /** + * If set, there is something wrong that must be resolved before the config can be validated or applied + */ + invalidReason?: ITranslatableMessage + + /** + * Whether the 'fixup' must be run before the config can be validated or applied + */ + pendingRunOfFixupFunction: boolean + + /** + * User facing list of changes to be reviewed + */ + changes: ITranslatableMessage[] +} + +export interface UIBlueprintUpgradeStatusStudio extends UIBlueprintUpgradeStatusBase { + documentType: 'studio' + documentId: StudioId +} +export interface UIBlueprintUpgradeStatusShowStyle extends UIBlueprintUpgradeStatusBase { + documentType: 'showStyle' + documentId: ShowStyleBaseId +} diff --git a/packages/webui/src/lib/api/user.ts b/packages/webui/src/lib/api/user.ts new file mode 100644 index 0000000000..8b5a61dd3c --- /dev/null +++ b/packages/webui/src/lib/api/user.ts @@ -0,0 +1,34 @@ +import { Accounts } from '../../client/ui/Account/fake-accounts' +import { UserProfile } from '../../lib/collections/Users' +import { protectString } from '../lib' +import { UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +export interface NewUserAPI { + enrollUser(email: string, name: string): Promise + requestPasswordReset(email: string): Promise + removeUser(): Promise +} +export enum UserAPIMethods { + 'enrollUser' = 'user.enrollUser', + 'requestPasswordReset' = 'user.requestPasswordReset', + 'removeUser' = 'user.removeUser', +} + +export interface CreateNewUserData { + email: string + profile: UserProfile + password?: string + createOrganization?: { + name: string + applications: string[] + broadcastMediums: string[] + } +} +export async function createUser(newUser: CreateNewUserData): Promise { + // This is available both client-side and server side. + // The reason for that is that the client-side should use Accounts.createUser right away + // so that the password aren't sent in "plaintext" to the server. + + const userId = await Accounts.createUserAsync(newUser) + return protectString(userId) +} diff --git a/packages/webui/src/lib/api/userActions.ts b/packages/webui/src/lib/api/userActions.ts new file mode 100644 index 0000000000..064ba02d2d --- /dev/null +++ b/packages/webui/src/lib/api/userActions.ts @@ -0,0 +1,447 @@ +import { ClientAPI } from '../api/client' +import { MethodContext } from './methods' +import { EvaluationBase } from '../collections/Evaluations' +import { Bucket } from '../collections/Buckets' +import { IngestAdlib, ActionUserData } from '@sofie-automation/blueprints-integration' +import { BucketAdLib } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibPiece' +import { AdLibActionCommon } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' +import { BucketAdLibAction } from '@sofie-automation/corelib/dist/dataModel/BucketAdLibAction' +import { Time } from '../lib' +import { ExecuteActionResult, QueueNextSegmentResult } from '@sofie-automation/corelib/dist/worker/studio' +import { + AdLibActionId, + BucketId, + MediaWorkFlowId, + PartId, + PartInstanceId, + PeripheralDeviceId, + PieceId, + PieceInstanceId, + RundownBaselineAdLibActionId, + RundownId, + RundownPlaylistId, + SegmentId, + ShowStyleBaseId, + ShowStyleVariantId, + SnapshotId, + StudioId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' +import shajs from 'sha.js' + +export interface NewUserActionAPI extends MethodContext { + take( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + fromPartInstanceId: PartInstanceId | null + ): Promise> + setNext( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + partId: PartId, + timeOffset?: number + ): Promise> + setNextSegment( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + segmentId: SegmentId + ): Promise> + queueNextSegment( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + segmentId: SegmentId | null + ): Promise> + moveNext( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + partDelta: number, + segmentDelta: number + ): Promise> + prepareForBroadcast( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId + ): Promise> + resetRundownPlaylist( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId + ): Promise> + resetAndActivate( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + rehearsal?: boolean + ): Promise> + activate( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + rehearsal: boolean + ): Promise> + deactivate( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId + ): Promise> + forceResetAndActivate( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + rehearsal: boolean + ): Promise> + disableNextPiece( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + undo?: boolean + ): Promise> + pieceTakeNow( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + partInstanceId: PartInstanceId, + pieceInstanceIdOrPieceIdToCopy: PieceInstanceId | PieceId + ): Promise> + setInOutPoints( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + partId: PartId, + pieceId: PieceId, + inPoint: number, + duration: number + ): Promise> + executeAction( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + actionDocId: AdLibActionId | RundownBaselineAdLibActionId, + actionId: string, + userData: ActionUserData, + triggerMode?: string + ): Promise> + segmentAdLibPieceStart( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + partInstanceId: PartInstanceId, + adLibPieceId: PieceId, + queue: boolean + ): Promise> + sourceLayerOnPartStop( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + partInstanceId: PartInstanceId, + sourceLayerIds: string[] + ): Promise> + baselineAdLibPieceStart( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + partInstanceId: PartInstanceId, + adlibPieceId: PieceId, + queue: boolean + ): Promise> + sourceLayerStickyPieceStart( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + sourceLayerId: string + ): Promise> + bucketAdlibImport( + _userEvent: string, + eventTime: Time, + bucketId: BucketId, + showStyleBaseId: ShowStyleBaseId, + ingestItem: IngestAdlib + ): Promise> + bucketAdlibStart( + _userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + partInstanceId: PartInstanceId, + bucketAdlibId: PieceId, + queue?: boolean + ): Promise> + activateHold( + userEvent: string, + eventTime: Time, + rundownPlaylistId: RundownPlaylistId, + undo?: boolean + ): Promise> + saveEvaluation( + userEvent: string, + eventTime: Time, + evaluation: EvaluationBase + ): Promise> + storeRundownSnapshot( + userEvent: string, + eventTime: Time, + token: string, + playlistId: RundownPlaylistId, + reason: string, + full: boolean + ): Promise> + removeRundownPlaylist( + userEvent: string, + eventTime: Time, + playlistId: RundownPlaylistId + ): Promise> + resyncRundownPlaylist( + userEvent: string, + eventTime: Time, + playlistId: RundownPlaylistId + ): Promise> + DEBUG_crashStudioWorker( + userEvent: string, + eventTime: Time, + playlistId: RundownPlaylistId + ): Promise> + removeRundown(userEvent: string, eventTime: Time, rundownId: RundownId): Promise> + resyncRundown( + userEvent: string, + eventTime: Time, + rundownId: RundownId + ): Promise> + unsyncRundown(userEvent: string, eventTime: Time, rundownId: RundownId): Promise> // + mediaRestartWorkflow( + userEvent: string, + eventTime: Time, + workflowId: MediaWorkFlowId + ): Promise> + mediaAbortWorkflow( + userEvent: string, + eventTime: Time, + workflowId: MediaWorkFlowId + ): Promise> + mediaPrioritizeWorkflow( + userEvent: string, + eventTime: Time, + workflowId: MediaWorkFlowId + ): Promise> + mediaRestartAllWorkflows(userEvent: string, eventTime: Time): Promise> + mediaAbortAllWorkflows(userEvent: string, eventTime: Time): Promise> + packageManagerRestartExpectation( + userEvent: string, + eventTime: Time, + deviceId: PeripheralDeviceId, + workId: string + ): Promise> + packageManagerRestartAllExpectations( + userEvent: string, + eventTime: Time, + studioId: StudioId + ): Promise> + packageManagerAbortExpectation( + userEvent: string, + eventTime: Time, + deviceId: PeripheralDeviceId, + workId: string + ): Promise> + packageManagerRestartPackageContainer( + userEvent: string, + eventTime: Time, + deviceId: PeripheralDeviceId, + containerId: string + ): Promise> + regenerateRundownPlaylist( + userEvent: string, + eventTime: Time, + playlistId: RundownPlaylistId + ): Promise> + restartCore(userEvent: string, eventTime: Time, token: string): Promise> + guiFocused(userEvent: string, eventTime: Time, viewInfo?: any[]): Promise> + guiBlurred(userEvent: string, eventTime: Time, viewInfo?: any[]): Promise> + bucketsRemoveBucket(userEvent: string, eventTime: Time, id: BucketId): Promise> + bucketsModifyBucket( + userEvent: string, + eventTime: Time, + id: BucketId, + bucket: Partial> + ): Promise> + bucketsEmptyBucket(userEvent: string, eventTime: Time, id: BucketId): Promise> + bucketsCreateNewBucket( + userEvent: string, + eventTime: Time, + studioId: StudioId, + name: string + ): Promise> + bucketsRemoveBucketAdLib(userEvent: string, eventTime: Time, id: PieceId): Promise> + bucketsRemoveBucketAdLibAction( + userEvent: string, + eventTime: Time, + id: AdLibActionId + ): Promise> + bucketsModifyBucketAdLib( + userEvent: string, + eventTime: Time, + id: PieceId, + bucket: Partial> + ): Promise> + bucketsModifyBucketAdLibAction( + userEvent: string, + eventTime: Time, + id: AdLibActionId, + action: Partial> + ): Promise> + bucketsSaveActionIntoBucket( + userEvent: string, + eventTime: Time, + studioId: StudioId, + bucketId: BucketId, + action: AdLibActionCommon | BucketAdLibAction + ): Promise> + switchRouteSet( + userEvent: string, + eventTime: Time, + studioId: StudioId, + routeSetId: string, + state: boolean + ): Promise> + moveRundown( + userEvent: string, + eventTime: Time, + rundownId: RundownId, + intoPlaylistId: RundownPlaylistId | null, + rundownsIdsInPlaylistInOrder: RundownId[] + ): Promise> + restoreRundownOrder( + userEvent: string, + eventTime: Time, + playlistId: RundownPlaylistId + ): Promise> + disablePeripheralSubDevice( + userEvent: string, + eventTime: Time, + peripheralDeviceId: PeripheralDeviceId, + subDeviceId: string, + disable: boolean + ): Promise> + activateAdlibTestingMode( + userEvent: string, + eventTime: number, + playlistId: RundownPlaylistId, + rundownId: RundownId + ): Promise> + + createAdlibTestingRundownForShowStyleVariant( + userEvent: string, + eventTime: Time, + studioId: StudioId, + showStyleVariantId: ShowStyleVariantId + ): Promise> +} + +export enum UserActionAPIMethods { + 'take' = 'userAction.take', + 'setNext' = 'userAction.setNext', + 'setNextSegment' = 'userAction.setNextSegment', + 'queueNextSegment' = 'userAction.queueNextSegment', + 'moveNext' = 'userAction.moveNext', + + 'prepareForBroadcast' = 'userAction.prepareForBroadcast', + 'resetRundownPlaylist' = 'userAction.resetRundownPlaylist', + 'resetAndActivate' = 'userAction.resetAndActivate', + 'forceResetAndActivate' = 'userAction.forceResetAndActivate', + 'activate' = 'userAction.activate', + 'deactivate' = 'userAction.deactivate', + 'unsyncRundown' = 'userAction.unsyncRundown', + + 'disableNextPiece' = 'userAction.disableNextPiece', + 'pieceTakeNow' = 'userAction.pieceTakeNow', + 'setInOutPoints' = 'userAction.pieceSetInOutPoints', + 'executeAction' = 'userAction.executeAction', + + 'bucketAdlibImport' = 'userAction.bucketAdlibImport', + 'bucketAdlibStart' = 'userAction.bucketAdlibStart', + + 'bucketsCreateNewBucket' = 'userAction.createBucket', + 'bucketsRemoveBucket' = 'userAction.removeBucket', + 'bucketsEmptyBucket' = 'userAction.emptyBucket', + 'bucketsModifyBucket' = 'userAction.modifyBucket', + 'bucketsRemoveBucketAdLib' = 'userAction.removeBucketAdLib', + 'bucketsRemoveBucketAdLibAction' = 'userAction.removeBucketAdLibAction', + 'bucketsModifyBucketAdLib' = 'userAction.bucketsModifyBucketAdLib', + 'bucketsModifyBucketAdLibAction' = 'userAction.bucketsModifyBucketAdLibAction', + 'bucketsSaveActionIntoBucket' = 'userAction.bucketsSaveActionIntoBucket', + + 'segmentAdLibPieceStart' = 'userAction.segmentAdLibPieceStart', + 'sourceLayerOnPartStop' = 'userAction.sourceLayerOnPartStop', + 'baselineAdLibPieceStart' = 'userAction.baselineAdLibPieceStart', + + 'sourceLayerStickyPieceStart' = 'userAction.sourceLayerStickyPieceStart', + + 'activateHold' = 'userAction.activateHold', + + 'saveEvaluation' = 'userAction.saveEvaluation', + + 'storeRundownSnapshot' = 'userAction.storeRundownSnapshot', + + 'removeRundownPlaylist' = 'userAction.removeRundownPlaylist', + 'resyncRundownPlaylist' = 'userAction.resyncRundownPlaylist', + + 'DEBUG_crashStudioWorker' = 'userAction.DEBUG_crashStudioWorker', + + 'removeRundown' = 'userAction.removeRundown', + 'resyncRundown' = 'userAction.resyncRundown', + + 'moveRundown' = 'userAction.moveRundown', + 'restoreRundownOrder' = 'userAction.restoreRundownOrder', + + 'mediaRestartWorkflow' = 'userAction.mediamanager.restartWorkflow', + 'mediaAbortWorkflow' = 'userAction.mediamanager.abortWorkflow', + 'mediaRestartAllWorkflows' = 'userAction.mediamanager.restartAllWorkflows', + 'mediaAbortAllWorkflows' = 'userAction.mediamanager.abortAllWorkflows', + 'mediaPrioritizeWorkflow' = 'userAction.mediamanager.mediaPrioritizeWorkflow', + + 'packageManagerRestartExpectation' = 'userAction.packagemanager.restartExpectation', + 'packageManagerRestartAllExpectations' = 'userAction.packagemanager.restartAllExpectations', + 'packageManagerAbortExpectation' = 'userAction.packagemanager.abortExpectation', + 'packageManagerRestartPackageContainer' = 'userAction.packagemanager.restartPackageContainer', + + 'regenerateRundownPlaylist' = 'userAction.ingest.regenerateRundownPlaylist', + 'restartCore' = 'userAction.system.restartCore', + + 'guiFocused' = 'userAction.focused', + 'guiBlurred' = 'userAction.blurred', + + 'switchRouteSet' = 'userAction.switchRouteSet', + + 'disablePeripheralSubDevice' = 'userAction.system.disablePeripheralSubDevice', + + 'activateAdlibTestingMode' = 'userAction.activateAdlibTestingMode', + + 'createAdlibTestingRundownForShowStyleVariant' = 'userAction.createAdlibTestingRundownForShowStyleVariant', +} + +export interface ReloadRundownPlaylistResponse { + rundownsResponses: { + rundownId: RundownId + response: TriggerReloadDataResponse + }[] +} + +export enum TriggerReloadDataResponse { + /** When reloading has been successfully completed */ + COMPLETED = 'ok', + /** When reloading has successfully started, and will finish asynchronously */ + WORKING = 'working', + /** When reloading cannot continue, because the rundown is missing */ + MISSING = 'missing', +} + +export const SINGLE_USE_TOKEN_SALT = 'token_' + +export function hashSingleUseToken(token: string): string { + // nocommit - a hack because the crypto polyfill doesn't work for some reason + return shajs('sha1') + .update(SINGLE_USE_TOKEN_SALT + token) + .digest('base64') + .replace(/[+/=]/g, '_') +} diff --git a/packages/webui/src/lib/check.ts b/packages/webui/src/lib/check.ts new file mode 100644 index 0000000000..09bccf800f --- /dev/null +++ b/packages/webui/src/lib/check.ts @@ -0,0 +1,62 @@ +import { check as MeteorCheck, Match as orgMatch } from 'meteor/check' + +/* tslint:disable variable-name */ + +export function check(value: unknown, pattern: Match.Pattern): void { + // This is a wrapper for Meteor.check, since that asserts the returned type too strictly + if (checkDisabled) { + return + } + + const passed = MeteorCheck(value, pattern) + + return passed +} +// todo: checkTOBEMOVED +export namespace Match { + export const Any = orgMatch.Any + // export const String = orgMatch.String + export const Integer = orgMatch.Integer + // export const Boolean = orgMatch.Boolean + // export const undefined = orgMatch.undefined + // export const Object = orgMatch.Object + + export type Pattern = orgMatch.Pattern + export type PatternMatch = orgMatch.PatternMatch + + export function Maybe( + pattern: T + ): orgMatch.Matcher | undefined | null> | undefined { + if (checkDisabled) return + return orgMatch.Maybe(pattern) + } + export function Optional(pattern: T): orgMatch.Matcher | undefined> | undefined { + if (checkDisabled) return + return orgMatch.Optional(pattern) + } + export function ObjectIncluding( + dico: T + ): orgMatch.Matcher> | undefined { + if (checkDisabled) return + return orgMatch.ObjectIncluding(dico) + } + export function OneOf(...patterns: T): orgMatch.Matcher> | undefined { + if (checkDisabled) return + return orgMatch.OneOf(...patterns) + } + export function Where(condition: (val: any) => val is T): orgMatch.Matcher | undefined { + if (checkDisabled) return + return orgMatch.Where(condition) + } + export function test(value: unknown, pattern: T): value is PatternMatch { + if (checkDisabled) return true + return orgMatch.test(value, pattern) + } +} +let checkDisabled = false +export function disableChecks(): void { + checkDisabled = true +} +export function enableChecks(): void { + checkDisabled = false +} diff --git a/meteor/lib/clientUserAction.ts b/packages/webui/src/lib/clientUserAction.ts similarity index 100% rename from meteor/lib/clientUserAction.ts rename to packages/webui/src/lib/clientUserAction.ts diff --git a/packages/webui/src/lib/collections/Buckets.ts b/packages/webui/src/lib/collections/Buckets.ts new file mode 100644 index 0000000000..72750e7507 --- /dev/null +++ b/packages/webui/src/lib/collections/Buckets.ts @@ -0,0 +1,25 @@ +import { BucketId, StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +/** + * A Bucket is an container for AdLib pieces that do not come from a MOS gateway and are + * free-floating between mutliple rundowns/rundown playlists + */ +export interface Bucket { + _id: BucketId + /** A user-presentable name for a bucket */ + name: string + /** Rank used for sorting buckets */ + _rank: number + + /** The studio this bucket belongs to, */ + studioId: StudioId + // /** Only the owner can delete a bucket from the RundownView UI. Anyone who can see the bucket can add and remove stuff from it. */ + // userId: string | null + + /** The default width of the bucket. Can possibly be runtime-modified by the user (stored in localStorage?) */ + width?: number + + /** Scaling factors for the buttons. Quite possibly not settable in the UI at all? */ + buttonWidthScale: number + buttonHeightScale: number +} diff --git a/packages/webui/src/lib/collections/CoreSystem.ts b/packages/webui/src/lib/collections/CoreSystem.ts new file mode 100644 index 0000000000..7cc883aee6 --- /dev/null +++ b/packages/webui/src/lib/collections/CoreSystem.ts @@ -0,0 +1,260 @@ +import { LogLevel, protectString } from '../lib' +import { Meteor } from 'meteor/meteor' +import * as semver from 'semver' +import _ from 'underscore' +import { CoreSystemId, BlueprintId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { StatusCode } from '@sofie-automation/blueprints-integration' + +export const SYSTEM_ID: CoreSystemId = protectString('core') + +/** + * Criticality level for service messages. Specification of criticality in server + * messages from sofie-monitor: + * https://github.com/nrkno/sofie-monitor/blob/master/src/data/serviceMessages/ServiceMessage.ts + * + * @export + * @enum {number} + */ +export enum Criticality { + /** Subject matter will affect operations. */ + CRITICAL = 1, + /** Operations will not be affected, but non-critical functions may be affected or the result may be undesirable. */ + WARNING = 2, + /** General information */ + NOTIFICATION = 3, +} + +export interface ServiceMessage { + id: string + criticality: Criticality + message: string + sender?: string + timestamp: number +} + +export interface ExternalServiceMessage extends Omit { + timestamp: Date +} + +export enum SofieLogo { + Default = 'default', + Pride = 'pride', + Norway = 'norway', + Christmas = 'christmas', +} + +export interface ICoreSystem { + _id: CoreSystemId // always is 'core' + /** Timestamp of creation, (ie the time the database was created) */ + created: number + /** Last modified time */ + modified: number + /** Database version, on the form x.y.z */ + version: string + /** Previous version, on the form x.y.z */ + previousVersion: string | null + + /** Id of the blueprint used by this system */ + blueprintId?: BlueprintId + + /** Support info */ + support?: { + message: string + } + + systemInfo?: { + message: string + enabled: boolean + } + + evaluations?: { + enabled: boolean + heading: string + message: string + } + + /** A user-defined name for the installation */ + name?: string + + /** What log-level to set. Defaults to SILLY */ + logLevel?: LogLevel + + /** Service messages currently valid for this instance */ + serviceMessages: { + [index: string]: ServiceMessage + } + + /** elastic APM (application performance monitoring) settings */ + apm?: { + enabled?: boolean + /** + * How many of the transactions to monitor. + * Set to: + * -1 to log nothing (max performance), + * 0.5 to log 50% of the transactions, + * 1 to log all transactions + */ + transactionSampleRate?: number + } + enableMonitorBlockedThread?: boolean + + /** Cron jobs running nightly */ + cron?: { + casparCGRestart?: { + enabled: boolean + } + storeRundownSnapshots?: { + enabled: boolean + rundownNames?: string[] + } + } + + logo?: SofieLogo +} + +/** In the beginning, there was the database, and the database was with Sofie, and the database was Sofie. + * And Sofie said: The version of the database is to be GENESIS_SYSTEM_VERSION so that the migration scripts will run. + */ +export const GENESIS_SYSTEM_VERSION = '0.0.0' + +export type Version = string +export type VersionRange = string + +function isReferenceOrUndefined(v: string | undefined): boolean { + return !v || v.startsWith('http') || v.startsWith('git') || v.startsWith('file') +} + +export function stripVersion(v: string): string { + if (isReferenceOrUndefined(v)) { + return '0.0.0' + } else { + const valid = semver.parse(v) + if (!valid) throw new Meteor.Error(500, `Invalid version: "${v}"`) + + return `${valid.major}.${valid.minor}.${valid.patch}` + } +} +export function parseRange(r: string | VersionRange | undefined): VersionRange { + if (isReferenceOrUndefined(r)) { + return '^0.0.0' // anything goes.. + } + const range = semver.validRange(r) + if (!range) throw new Meteor.Error(500, `Invalid range: "${r}"`) + return range +} +export function parseVersion(v: string | Version | undefined): Version { + if (isReferenceOrUndefined(v)) { + return '0.0.0' // fallback + } + const valid = semver.valid(v) + if (!valid) throw new Meteor.Error(500, `Invalid version: "${v}"`) + return valid +} + +export function isPrerelease(v: string): boolean { + if (isReferenceOrUndefined(v)) { + return true + } else { + const valid = semver.parse(v) + if (!valid) throw new Meteor.Error(500, `Invalid version: "${v}"`) + + return valid.prerelease.length > 0 + } +} +export function parseCoreIntegrationCompatabilityRange(v: string): string { + if (isReferenceOrUndefined(v)) { + return '0.0' + } else { + const valid = semver.parse(v) + if (!valid) throw new Meteor.Error(500, `Invalid version: "${v}"`) + + // patch releases shouldn't break things, so we always want to accept an older patch + valid.patch = 0 + + return `~${valid.format()}` + } +} +/** + * Compares two versions and returns a system Status + * @param currentVersion + * @param targetRange + */ +export function compareSemverVersions( + currentVersion: Version | null, + targetRange: VersionRange, + allowPrerelease: boolean, + fixMessage: string, + meName: string, + theyName: string +): { statusCode: StatusCode; messages: string[] } { + if (currentVersion) currentVersion = semver.clean(currentVersion) + + if (currentVersion) { + if ( + semver.satisfies(currentVersion, targetRange, { + includePrerelease: allowPrerelease, + }) + ) { + return { + statusCode: StatusCode.GOOD, + messages: [`${meName} version: ${currentVersion}`], + } + } else { + try { + const currentV = new semver.SemVer(currentVersion, { includePrerelease: true }) + const expectV = new semver.SemVer(stripVersion(targetRange), { includePrerelease: true }) + + const message = + `Version mismatch: ${meName} version: "${currentVersion}" does not satisfy expected version of ${theyName}: "${targetRange}"` + + (fixMessage ? ` (${fixMessage})` : '') + + if (!expectV || !currentV) { + return { + statusCode: StatusCode.BAD, + messages: [message], + } + } else if (expectV.major !== currentV.major) { + return { + statusCode: StatusCode.BAD, + messages: [message], + } + } else if (expectV.minor !== currentV.minor) { + return { + statusCode: StatusCode.WARNING_MAJOR, + messages: [message], + } + } else if (expectV.patch !== currentV.patch) { + return { + statusCode: StatusCode.WARNING_MINOR, + messages: [message], + } + } else if (!_.isEqual(expectV.prerelease, currentV.prerelease)) { + return { + statusCode: StatusCode.WARNING_MINOR, + messages: [message], + } + } else { + return { + statusCode: StatusCode.BAD, + messages: [message], + } + } + // the expectedVersion may be a proper range, in which case the new semver.SemVer will throw an error, even though the semver.satisfies check would work. + } catch (e) { + const message = + `Version mismatch: ${meName} version: "${currentVersion}" does not satisfy expected version range of ${theyName}: "${targetRange}"` + + (fixMessage ? ` (${fixMessage})` : '') + + return { + statusCode: StatusCode.BAD, + messages: [message], + } + } + } + } else { + return { + statusCode: StatusCode.FATAL, + messages: [`Current ${meName} version missing (when comparing with ${theyName})`], + } + } +} diff --git a/packages/webui/src/lib/collections/Evaluations.ts b/packages/webui/src/lib/collections/Evaluations.ts new file mode 100644 index 0000000000..c182f13243 --- /dev/null +++ b/packages/webui/src/lib/collections/Evaluations.ts @@ -0,0 +1,24 @@ +import { Time } from '../lib' +import { + EvaluationId, + StudioId, + RundownPlaylistId, + SnapshotId, + OrganizationId, + UserId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' + +export interface Evaluation extends EvaluationBase { + _id: EvaluationId + organizationId: OrganizationId | null + userId: UserId | null + timestamp: Time +} +export interface EvaluationBase { + studioId: StudioId + playlistId: RundownPlaylistId + answers: { + [key: string]: string + } + snapshots?: Array +} diff --git a/packages/webui/src/lib/collections/ExpectedPackages.ts b/packages/webui/src/lib/collections/ExpectedPackages.ts new file mode 100644 index 0000000000..b4349899cd --- /dev/null +++ b/packages/webui/src/lib/collections/ExpectedPackages.ts @@ -0,0 +1,62 @@ +import { ExpectedPackage } from '@sofie-automation/blueprints-integration' +import { assertNever, literal } from '../lib' +import { StudioLight } from '@sofie-automation/corelib/dist/dataModel/Studio' +import deepExtend from 'deep-extend' + +export function getPreviewPackageSettings( + expectedPackage: ExpectedPackage.Any +): ExpectedPackage.SideEffectPreviewSettings | undefined { + let packagePath: string | undefined + + if (expectedPackage.type === ExpectedPackage.PackageType.MEDIA_FILE) { + packagePath = expectedPackage.content.filePath + } else if (expectedPackage.type === ExpectedPackage.PackageType.QUANTEL_CLIP) { + packagePath = expectedPackage.content.guid || expectedPackage.content.title + } else if (expectedPackage.type === ExpectedPackage.PackageType.JSON_DATA) { + packagePath = undefined // Not supported + } else { + assertNever(expectedPackage) + } + if (packagePath) { + return { + path: packagePath + '_preview.webm', + } + } + return undefined +} +export function getThumbnailPackageSettings( + expectedPackage: ExpectedPackage.Any +): ExpectedPackage.SideEffectThumbnailSettings | undefined { + let packagePath: string | undefined + + if (expectedPackage.type === ExpectedPackage.PackageType.MEDIA_FILE) { + packagePath = expectedPackage.content.filePath + } else if (expectedPackage.type === ExpectedPackage.PackageType.QUANTEL_CLIP) { + packagePath = expectedPackage.content.guid || expectedPackage.content.title + } else if (expectedPackage.type === ExpectedPackage.PackageType.JSON_DATA) { + packagePath = undefined // Not supported + } else { + assertNever(expectedPackage) + } + if (packagePath) { + return { + path: packagePath + '_thumbnail.png', + } + } + return undefined +} +export function getSideEffect( + expectedPackage: ExpectedPackage.Base, + studio: Pick +): ExpectedPackage.Base['sideEffect'] { + return deepExtend( + {}, + literal({ + previewContainerId: studio.previewContainerIds[0], // just pick the first. Todo: something else? + thumbnailContainerId: studio.thumbnailContainerIds[0], // just pick the first. Todo: something else? + previewPackageSettings: getPreviewPackageSettings(expectedPackage as ExpectedPackage.Any), + thumbnailPackageSettings: getThumbnailPackageSettings(expectedPackage as ExpectedPackage.Any), + }), + expectedPackage.sideEffect + ) +} diff --git a/packages/webui/src/lib/collections/Organization.ts b/packages/webui/src/lib/collections/Organization.ts new file mode 100644 index 0000000000..60008d2635 --- /dev/null +++ b/packages/webui/src/lib/collections/Organization.ts @@ -0,0 +1,33 @@ +import { OrganizationId, UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +/** An organization is the entity that owns data (studios, rundowns, etc..) in Sofie */ +export interface DBOrganizationBase { + /** Name of the organization */ + name: string + applications: string[] + broadcastMediums: string[] +} +export interface DBOrganization extends DBOrganizationBase { + _id: OrganizationId + + userRoles: { [userId: string]: UserRoles } + + notes?: string + + created: number + modified: number +} +export interface UserAdmin { + userId: UserId + // permissions: // add later +} +export interface UserRoles { + /** Can play out things in a studio */ + studio?: boolean + /** Can access and modify the settings */ + configurator?: boolean + /** Can enable developer features including test tools */ + developer?: boolean + /** Is Organization admin */ + admin?: boolean +} diff --git a/packages/webui/src/lib/collections/PartInstances.ts b/packages/webui/src/lib/collections/PartInstances.ts new file mode 100644 index 0000000000..34a38519cd --- /dev/null +++ b/packages/webui/src/lib/collections/PartInstances.ts @@ -0,0 +1,40 @@ +import { protectString, unprotectString } from '../lib' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { PartId, RundownPlaylistActivationId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' + +export interface PartInstance extends DBPartInstance { + isTemporary: boolean +} + +export function wrapPartToTemporaryInstance( + playlistActivationId: RundownPlaylistActivationId, + part: DBPart +): PartInstance { + return { + isTemporary: true, + _id: protectString(`${part._id}_tmp_instance`), + rundownId: part.rundownId, + segmentId: part.segmentId, + playlistActivationId, + segmentPlayoutId: protectString(''), // Only needed when stored in the db, and filled in nearer the time + takeCount: -1, + rehearsal: false, + part: part, + } +} + +export function findPartInstanceInMapOrWrapToTemporary>( + partInstancesMap: Map, + part: DBPart +): T { + return partInstancesMap.get(part._id) || (wrapPartToTemporaryInstance(protectString(''), part) as T) +} + +export function findPartInstanceOrWrapToTemporary>( + partInstances: { [partId: string]: T | undefined }, + part: DBPart +): T { + return partInstances[unprotectString(part._id)] || (wrapPartToTemporaryInstance(protectString(''), part) as T) +} diff --git a/packages/webui/src/lib/collections/PeripheralDevices.ts b/packages/webui/src/lib/collections/PeripheralDevices.ts new file mode 100644 index 0000000000..c901ad708f --- /dev/null +++ b/packages/webui/src/lib/collections/PeripheralDevices.ts @@ -0,0 +1 @@ +export * from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' diff --git a/packages/webui/src/lib/collections/RundownLayouts.ts b/packages/webui/src/lib/collections/RundownLayouts.ts new file mode 100644 index 0000000000..1a171fc49a --- /dev/null +++ b/packages/webui/src/lib/collections/RundownLayouts.ts @@ -0,0 +1,435 @@ +import { ISourceLayer, SourceLayerType } from '@sofie-automation/blueprints-integration' +import { RundownLayoutId, UserId, ShowStyleBaseId, BlueprintId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +/** + * The view targeted by this layout: + * RUNDOWN_LAYOUT: a Rundown view for highly scripted shows: a show split into Segments and Parts, + * accurate timing on each of those with over/under etc. + * DASHBOARD_LAYOUT: a Dashboard view for AdLib shows (low-scripted): a list of buttons and some generic show layout + * + * @export + * @enum {string} + */ +export enum RundownLayoutType { + RUNDOWN_VIEW_LAYOUT = 'rundown_view_layout', + RUNDOWN_LAYOUT = 'rundown_layout', + DASHBOARD_LAYOUT = 'dashboard_layout', + RUNDOWN_HEADER_LAYOUT = 'rundown_header_layout', + MINI_SHELF_LAYOUT = 'mini_shelf_layout', + CLOCK_PRESENTER_VIEW_LAYOUT = 'clock_presenter_view_layout', +} + +export enum CustomizableRegions { + RundownView = 'rundown_view_layouts', + Shelf = 'shelf_layouts', + MiniShelf = 'mini_shelf_layouts', + RundownHeader = 'rundown_header_layouts', + PresenterView = 'presenter_view_layouts', +} + +/** + * Display style to be used by this filter + * + * @export + * @enum {string} + */ +export enum PieceDisplayStyle { + LIST = 'list', + BUTTONS = 'buttons', +} + +export enum RundownLayoutElementType { + FILTER = 'filter', + EXTERNAL_FRAME = 'external_frame', + ADLIB_REGION = 'adlib_region', + KEYBOARD_PREVIEW = 'keyboard_preview', // This is used by TV2 + PIECE_COUNTDOWN = 'piece_countdown', + NEXT_INFO = 'next_info', + PLAYLIST_START_TIMER = 'playlist_start_timer', + PLAYLIST_END_TIMER = 'playlist_end_timer', + NEXT_BREAK_TIMING = 'next_break_timing', + END_WORDS = 'end_words', + SEGMENT_TIMING = 'segment_timing', + PART_TIMING = 'part_timing', + TEXT_LABEL = 'text_label', + PLAYLIST_NAME = 'playlist_name', + STUDIO_NAME = 'studio_name', + TIME_OF_DAY = 'time_of_day', + SYSTEM_STATUS = 'system_status', + SHOWSTYLE_DISPLAY = 'showstyle_display', + SEGMENT_NAME = 'segment_name', + PART_NAME = 'part_name', + COLORED_BOX = 'colored_box', + MINI_RUNDOWN = 'mini_rundown', +} + +export interface RundownLayoutElementBase { + _id: string + name: string + rank: number + type?: RundownLayoutElementType // if not set, the value is RundownLayoutElementType.FILTER +} + +/** + * An interface for filters that check for a piece to be present on a source layer to change their behaviour (or in order to perform any action at all). + * If `requiredLayerIds` is empty / undefined, the filter should be treated as "always active". + * @param requiredLayerIds Layers that the filter will check for some active ('live') piece. (Match any layer in array). + * @param additionalLayers Layers that must be active in addition to the active layers, i.e. "any of `requiredLayerIds`, with at least one of `additionalLayers`". + * @param requireAllAdditionalSourcelayers Require all layers in `additionalLayers` to contain an active piece. + */ +export interface RequiresActiveLayers { + requiredLayerIds?: string[] + additionalLayers?: string[] + /** + * Require that all additional sourcelayers be active. + * This allows behaviour to be tied to a combination of e.g. script + VT. + */ + requireAllAdditionalSourcelayers: boolean +} + +export interface RundownLayoutExternalFrame extends RundownLayoutElementBase { + type: RundownLayoutElementType.EXTERNAL_FRAME + url: string + scale: number + disableFocus?: boolean + dropzoneUrl?: string +} + +export enum RundownLayoutAdLibRegionRole { + QUEUE = 'queue', + TAKE = 'take', + PROGRAM = 'program', +} + +export interface RundownLayoutAdLibRegion extends RundownLayoutElementBase { + type: RundownLayoutElementType.ADLIB_REGION + tags: string[] | undefined + role: RundownLayoutAdLibRegionRole + adlibRank: number + labelBelowPanel: boolean + thumbnailSourceLayerIds: string[] | undefined + thumbnailPriorityNextPieces: boolean + hideThumbnailsForActivePieces: boolean + showBlackIfNoThumbnailPiece: boolean +} + +export interface RundownLayoutPieceCountdown extends RundownLayoutElementBase { + type: RundownLayoutElementType.PIECE_COUNTDOWN + sourceLayerIds: string[] | undefined +} + +export interface RundownLayoutPieceCountdown extends RundownLayoutElementBase { + type: RundownLayoutElementType.PIECE_COUNTDOWN + sourceLayerIds: string[] | undefined +} + +export interface RundownLayoutNextInfo extends RundownLayoutElementBase { + type: RundownLayoutElementType.NEXT_INFO + showSegmentName: boolean + showPartTitle: boolean + hideForDynamicallyInsertedParts: boolean +} + +export interface RundownLayoutMiniRundown extends RundownLayoutElementBase { + type: RundownLayoutElementType.MINI_RUNDOWN +} + +export interface RundownLayoutPlaylistStartTimer extends RundownLayoutElementBase { + type: RundownLayoutElementType.PLAYLIST_START_TIMER + plannedStartText: string + hideDiff: boolean + hidePlannedStart: boolean +} + +export interface RundownLayoutPlaylistEndTimer extends RundownLayoutElementBase { + type: RundownLayoutElementType.PLAYLIST_END_TIMER + headerHeight: string + plannedEndText: string + hidePlannedEndLabel: boolean + hideDiffLabel: boolean + hideCountdown: boolean + hideDiff: boolean + hidePlannedEnd: boolean +} + +export interface RundownLayoutNextBreakTiming extends RundownLayoutElementBase { + type: RundownLayoutElementType.NEXT_BREAK_TIMING +} + +export interface RundownLayoutEndWords extends RundownLayoutElementBase, RequiresActiveLayers { + type: RundownLayoutElementType.PLAYLIST_END_TIMER + hideLabel: boolean +} + +export interface RundownLayoutSegmentTiming extends RundownLayoutElementBase, RequiresActiveLayers { + type: RundownLayoutElementType.SEGMENT_TIMING + timingType: 'count_down' | 'count_up' + hideLabel: boolean +} + +export interface RundownLayoutPartTiming extends RundownLayoutElementBase, RequiresActiveLayers { + type: RundownLayoutElementType.PART_TIMING + timingType: 'count_down' | 'count_up' + speakCountDown: boolean + hideLabel: boolean +} + +export interface RundownLayoutTextLabel extends RundownLayoutElementBase { + type: RundownLayoutElementType.TEXT_LABEL + text: string +} + +export interface RundownLayoutPlaylistName extends RundownLayoutElementBase { + type: RundownLayoutElementType.PLAYLIST_NAME + showCurrentRundownName: boolean +} + +export interface RundownLayoutStudioName extends RundownLayoutElementBase { + type: RundownLayoutElementType.STUDIO_NAME +} + +export interface RundownLayoutTimeOfDay extends RundownLayoutElementBase { + type: RundownLayoutElementType.TIME_OF_DAY + hideLabel: boolean +} + +export interface RundownLayoutSytemStatus extends RundownLayoutElementBase { + type: RundownLayoutElementType.SYSTEM_STATUS +} + +export interface RundownLayoutShowStyleDisplay extends RundownLayoutElementBase { + type: RundownLayoutElementType.SHOWSTYLE_DISPLAY +} + +export interface RundownLayoutSegmentName extends RundownLayoutElementBase { + type: RundownLayoutElementType.SEGMENT_NAME + segment: 'current' | 'next' +} + +export interface RundownLayoutPartName extends RundownLayoutElementBase { + type: RundownLayoutElementType.PART_NAME + part: 'current' | 'next' + showPieceIconColor: boolean +} + +export interface RundownLayoutColoredBox extends RundownLayoutElementBase { + type: RundownLayoutElementType.COLORED_BOX + iconColor: string +} + +/** + * A filter to be applied against the AdLib Pieces. If a member is undefined, the pool is not tested + * against that filter. A member must match all of the sub-filters to be included in a filter view + * + * @export + * @interface RundownLayoutFilter + */ +export interface RundownLayoutFilterBase extends RundownLayoutElementBase { + type: RundownLayoutElementType.FILTER + sourceLayerIds: string[] | undefined + sourceLayerTypes: SourceLayerType[] | undefined + outputLayerIds: string[] | undefined + label: string[] | undefined + /** + * If a tag filter is starting with a "!" char, it will turn into a NOT filter, i.e., the tag must + * not be present, for the item to match the filter + * + * TODO: This should be made more explicit in the datastructure somehow. + */ + tags: string[] | undefined + displayStyle: PieceDisplayStyle + showThumbnailsInList: boolean + hideDuplicates: boolean + currentSegment: boolean + nextInCurrentPart: boolean + oneNextPerSourceLayer: boolean + /** + * true: include Rundown Baseline AdLib Pieces + * false: do not include Rundown Baseline AdLib Pieces + * 'only': show only Rundown Baseline AdLib Pieces matching this filter + */ + rundownBaseline: boolean | 'only' + disableHoverInspector: boolean +} + +export interface RundownLayoutFilter extends RundownLayoutFilterBase { + default: boolean +} + +export interface RundownLayoutKeyboardPreview extends RundownLayoutElementBase { + type: RundownLayoutElementType.KEYBOARD_PREVIEW +} + +export enum DashboardPanelUnit { + /** Dashboard panels are defined in absolute (em) units */ + EM = 'em', + /** Dashboard panels are defined in percent so that they scale with container/window size */ + PERCENT = '%', +} + +export interface DashboardPanelBase { + x: number + y: number + width: number + height: number + scale?: number + customClasses?: string[] +} + +export interface DashboardPanelUnits { + xUnit?: DashboardPanelUnit + yUnit?: DashboardPanelUnit + widthUnit?: DashboardPanelUnit + heightUnit?: DashboardPanelUnit +} + +type DashboardPanel = T & DashboardPanelBase & DashboardPanelUnits + +export type DashboardLayoutExternalFrame = DashboardPanel +export type DashboardLayoutAdLibRegion = DashboardPanel +export type DashboardLayoutPieceCountdown = DashboardPanel +export type DashboardLayoutNextInfo = DashboardPanel +export type DashboardLayoutPlaylistStartTimer = DashboardPanel +export type DashboardLayoutNextBreakTiming = DashboardPanel +export type DashboardLayoutPlaylistEndTimer = DashboardPanel +export type DashboardLayoutEndsWords = DashboardPanel +export type DashboardLayoutSegmentCountDown = DashboardPanel +export type DashboardLayoutPartCountDown = DashboardPanel +export type DashboardLayoutTextLabel = DashboardPanel +export type DashboardLayoutPlaylistName = DashboardPanel +export type DashboardLayoutStudioName = DashboardPanel +export type DashboardLayoutTimeOfDay = DashboardPanel +export type DashboardLayoutSystemStatus = DashboardPanel +export type DashboardLayoutShowStyleDisplay = DashboardPanel +export type DashboardLayoutSegmentName = DashboardPanel +export type DashboardLayoutPartName = DashboardPanel +export type DashboardLayoutColoredBox = DashboardPanel +export type DashboardLayoutKeyboardPreview = DashboardPanel +export type DashboardLayoutMiniRundown = DashboardPanel +export type DashboardLayoutFilter = DashboardPanel< + RundownLayoutFilterBase & { + enableSearch: boolean + + buttonWidthScale: number + buttonHeightScale: number + + includeClearInRundownBaseline: boolean + overflowHorizontally?: boolean + showAsTimeline?: boolean + hide?: boolean + displayTakeButtons?: boolean + queueAllAdlibs?: boolean + toggleOnSingleClick?: boolean + /** + * character or sequence that will be replaced with line break in buttons + */ + lineBreak?: string + } +> +export interface MiniShelfLayoutFilter extends RundownLayoutFilterBase { + buttonWidthScale: number + buttonHeightScale: number +} + +export interface RundownLayoutBase { + _id: RundownLayoutId + showStyleBaseId: ShowStyleBaseId + blueprintId?: BlueprintId + userId?: UserId + name: string + type: RundownLayoutType + icon: string + iconColor: string + /* Customizable region that the layout modifies. */ + regionId: CustomizableRegions + isDefaultLayout: boolean +} + +export interface RundownLayoutWithFilters extends RundownLayoutBase { + filters: RundownLayoutElementBase[] +} + +export interface RundownViewLayout extends RundownLayoutBase { + type: RundownLayoutType.RUNDOWN_VIEW_LAYOUT + /** Expose as a layout that can be selected by the user in the lobby view */ + exposeAsSelectableLayout: boolean + shelfLayout: RundownLayoutId + miniShelfLayout: RundownLayoutId + rundownHeaderLayout: RundownLayoutId + liveLineProps?: RequiresActiveLayers + /** Hide the rundown divider header in playlists */ + hideRundownDivider: boolean + /** Show breaks in segment timeline list */ + showBreaksAsSegments: boolean + /** Only count down to the segment if it contains pieces on these layers */ + countdownToSegmentRequireLayers: string[] + /** Always show planned segment duration instead of counting up/down when the segment is live */ + fixedSegmentDuration: boolean + /** SourceLayer ids for which a piece duration label should be shown */ + showDurationSourceLayers: ISourceLayer['_id'][] + /** Show only the listed source layers in the RundownView (sourceLayerIds) */ + visibleSourceLayers?: string[] + /** Show only the listed output groups in the RundownView (outputLayerIds) */ + visibleOutputLayers?: string[] +} + +export interface RundownLayoutShelfBase extends RundownLayoutWithFilters { + exposeAsStandalone: boolean + openByDefault: boolean + startingHeight?: number + showInspector: boolean + disableContextMenu: boolean + hideDefaultStartExecute: boolean + /* Customizable region that the layout modifies. */ + regionId: CustomizableRegions +} + +export interface RundownLayout extends RundownLayoutShelfBase { + type: RundownLayoutType.RUNDOWN_LAYOUT +} + +export interface RundownLayoutRundownHeader extends RundownLayoutBase { + type: RundownLayoutType.RUNDOWN_HEADER_LAYOUT + plannedEndText: string + nextBreakText: string + /** When true, hide the Planned End timer when there is a rundown marked as a break in the future */ + hideExpectedEndBeforeBreak: boolean + /** When a rundown is marked as a break, show the Next Break timing */ + showNextBreakTiming: boolean + /** If true, don't treat the last rundown as a break even if it's marked as one */ + lastRundownIsNotBreak: boolean +} + +export interface RundownLayoutPresenterView extends RundownLayoutBase { + type: RundownLayoutType.CLOCK_PRESENTER_VIEW_LAYOUT +} + +export enum ActionButtonType { + TAKE = 'take', + HOLD = 'hold', + MOVE_NEXT_PART = 'move_next_part', + MOVE_NEXT_SEGMENT = 'move_next_segment', + MOVE_PREVIOUS_PART = 'move_previous_part', + MOVE_PREVIOUS_SEGMENT = 'move_previous_segment', + // ACTIVATE = 'activate', + // ACTIVATE_REHEARSAL = 'activate_rehearsal', + // DEACTIVATE = 'deactivate', + // RESET_RUNDOWN = 'reset_rundown', + QUEUE_ADLIB = 'queue_adlib', // The idea for it is that you would be able to press and hold this button + // and then click on whatever adlib you would like + READY_ON_AIR = 'ready_on_air', + STORE_SNAPSHOT = 'store_snapshot', +} + +export interface DashboardLayoutActionButton extends DashboardPanelBase { + _id: string + type: ActionButtonType + label: string + /** When set, changes the label when the button is toggled on */ + labelToggled?: string +} + +export interface DashboardLayout extends RundownLayoutShelfBase { + type: RundownLayoutType.DASHBOARD_LAYOUT + actionButtons?: DashboardLayoutActionButton[] +} diff --git a/packages/webui/src/lib/collections/Snapshots.ts b/packages/webui/src/lib/collections/Snapshots.ts new file mode 100644 index 0000000000..09071feb2e --- /dev/null +++ b/packages/webui/src/lib/collections/Snapshots.ts @@ -0,0 +1,48 @@ +import { Time } from '../lib' +import { + SnapshotId, + StudioId, + RundownId, + RundownPlaylistId, + OrganizationId, +} from '@sofie-automation/corelib/dist/dataModel/Ids' + +export enum SnapshotType { + RUNDOWNPLAYLIST = 'rundownplaylist', + SYSTEM = 'system', + DEBUG = 'debug', +} + +export interface SnapshotBase { + _id: SnapshotId + /** If set, the organization the owns this Snapshot */ + organizationId: OrganizationId | null + + type: SnapshotType + created: Time + name: string + description?: string + /** Version of the system that took the snapshot */ + version: string +} + +export interface SnapshotItem extends SnapshotBase { + fileName: string + comment: string + + studioId?: StudioId + rundownId?: RundownId + playlistId?: RundownPlaylistId +} + +export interface SnapshotRundownPlaylist extends SnapshotBase { + type: SnapshotType.RUNDOWNPLAYLIST + studioId: StudioId + playlistId: RundownPlaylistId +} +export interface SnapshotSystem extends SnapshotBase { + type: SnapshotType.SYSTEM +} +export interface SnapshotDebug extends SnapshotBase { + type: SnapshotType.DEBUG +} diff --git a/packages/webui/src/lib/collections/Studios.ts b/packages/webui/src/lib/collections/Studios.ts new file mode 100644 index 0000000000..b41c07df57 --- /dev/null +++ b/packages/webui/src/lib/collections/Studios.ts @@ -0,0 +1,98 @@ +import { omit, protectString } from '../lib' +import { LookaheadMode } from '@sofie-automation/blueprints-integration' +import { + ResultingMappingRoutes, + MappingExt, + StudioRouteType, + StudioRouteSet, + RouteMapping, +} from '@sofie-automation/corelib/dist/dataModel/Studio' +import { ReadonlyDeep } from 'type-fest' + +export function getActiveRoutes(routeSets: ReadonlyDeep>): ResultingMappingRoutes { + const routes: ResultingMappingRoutes = { + existing: {}, + inserted: [], + } + + const exclusivityGroups: { [groupId: string]: true } = {} + for (const routeSet of Object.values>(routeSets)) { + if (routeSet.active) { + let useRoute = true + if (routeSet.exclusivityGroup) { + // Fail-safe: To really make sure we're not using more than one route in the same exclusivity group: + if (exclusivityGroups[routeSet.exclusivityGroup]) { + useRoute = false + } + exclusivityGroups[routeSet.exclusivityGroup] = true + } + if (useRoute) { + for (const routeMapping of Object.values>(routeSet.routes)) { + if (routeMapping.outputMappedLayer) { + if (routeMapping.mappedLayer) { + // Route an existing layer + if (!routes.existing[routeMapping.mappedLayer]) { + routes.existing[routeMapping.mappedLayer] = [] + } + routes.existing[routeMapping.mappedLayer].push(omit(routeMapping, 'mappedLayer')) + } else { + // Insert a new routed layer + routes.inserted.push(omit(routeMapping, 'mappedLayer')) + } + } + } + } + } + } + + return routes +} +export function getRoutedMappings( + inputMappings: { [layerName: string]: M }, + mappingRoutes: ResultingMappingRoutes +): { [layerName: string]: M } { + const outputMappings: { [layerName: string]: M } = {} + + // Re-route existing layers: + for (const [inputLayer, inputMapping] of Object.entries(inputMappings)) { + const routes = mappingRoutes.existing[inputLayer] + if (routes) { + for (const route of routes) { + const routedMapping: M = + route.routeType === StudioRouteType.REMAP && + route.deviceType && + route.remapping && + route.remapping.deviceId + ? ({ + ...route.remapping, + lookahead: route.remapping.lookahead ?? LookaheadMode.NONE, + device: route.deviceType, + deviceId: protectString(route.remapping.deviceId), + } as M) + : { + ...inputMapping, + ...(route.remapping || {}), + } + outputMappings[route.outputMappedLayer] = routedMapping + } + } else { + // If no route is found at all for a mapping, pass the mapping through un-modified for backwards compatibility. + outputMappings[inputLayer] = inputMapping + } + } + + // also insert new routed layers: + for (const route of mappingRoutes.inserted) { + if (route.remapping && route.deviceType && route.remapping.deviceId) { + const routedMapping: MappingExt = { + lookahead: route.remapping.lookahead || LookaheadMode.NONE, + device: route.deviceType, + deviceId: protectString(route.remapping.deviceId), + options: {}, + ...route.remapping, + } + outputMappings[route.outputMappedLayer] = routedMapping as M + } + } + return outputMappings +} diff --git a/packages/webui/src/lib/collections/Timeline.ts b/packages/webui/src/lib/collections/Timeline.ts new file mode 100644 index 0000000000..e8ffdcb2f4 --- /dev/null +++ b/packages/webui/src/lib/collections/Timeline.ts @@ -0,0 +1,48 @@ +import { ResultingMappingRoutes } from '@sofie-automation/corelib/dist/dataModel/Studio' + +import { TimelineObjGeneric, updateLookaheadLayer } from '@sofie-automation/corelib/dist/dataModel/Timeline' + +export function getRoutedTimeline( + inputTimelineObjs: TimelineObjGeneric[], + mappingRoutes: ResultingMappingRoutes +): TimelineObjGeneric[] { + const outputTimelineObjs: TimelineObjGeneric[] = [] + + for (const obj of inputTimelineObjs) { + let inputLayer = obj.layer + '' + if (obj.isLookahead && obj.lookaheadForLayer) { + // For lookahead objects, .layer doesn't point to any real layer + inputLayer = obj.lookaheadForLayer + '' + } + const routes = mappingRoutes.existing[inputLayer] + if (routes) { + for (let i = 0; i < routes.length; i++) { + const route = routes[i] + const routedObj: TimelineObjGeneric = { + ...obj, + layer: route.outputMappedLayer, + } + if (routedObj.isLookahead && routedObj.lookaheadForLayer) { + // Update lookaheadForLayer to reference the original routed layer: + updateLookaheadLayer(routedObj) + } + if (i > 0) { + // If there are multiple routes we must rename the ids, so that they stay unique. + routedObj.id = `_${i}_${routedObj.id}` + + if (routedObj.keyframes) { + routedObj.keyframes = routedObj.keyframes.map((keyframe) => ({ + ...keyframe, + id: `_${i}_${keyframe.id}`, + })) + } + } + outputTimelineObjs.push(routedObj) + } + } else { + // If no route is found at all, pass it through (backwards compatibility) + outputTimelineObjs.push(obj) + } + } + return outputTimelineObjs +} diff --git a/packages/webui/src/lib/collections/TranslationsBundles.ts b/packages/webui/src/lib/collections/TranslationsBundles.ts new file mode 100644 index 0000000000..c411f2008b --- /dev/null +++ b/packages/webui/src/lib/collections/TranslationsBundles.ts @@ -0,0 +1,37 @@ +import { TranslationsBundleType } from '@sofie-automation/blueprints-integration' +import { TranslationsBundleId, TranslationsBundleOriginId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +export type Translation = { original: string; translation: string } + +/** + * Interface for the DB collection type for translation bundles. + * + * Note that this interface is slightly divergent from the TranslationsBundle + * type used by the blueprints, specifically in the data property. + * + * The reason for this is that (Mini)Mongo does not allow property names with dots, + * so using the literal original strings (which frequently have punctuation) as + * property names won't work. Therefore it is stored to the database as an array + * of object with explicitly names original and translated properties. + */ +export interface TranslationsBundle { + _id: TranslationsBundleId + + type: TranslationsBundleType + + /** the id of the blueprint the translations were bundled with */ + originId: TranslationsBundleOriginId + + /** language code (example: 'nb'), annotates what language the translations are for */ + language: string + /** optional namespace for the bundle */ + namespace?: string + /** encoding used for the data, typically utf-8 */ + encoding?: string + + /** A unique hash of the `data` object, to signal that the contents have updated */ + hash: string + + /** the actual translations */ + data: Translation[] +} diff --git a/packages/webui/src/lib/collections/TriggeredActions.ts b/packages/webui/src/lib/collections/TriggeredActions.ts new file mode 100644 index 0000000000..e3ef994e8e --- /dev/null +++ b/packages/webui/src/lib/collections/TriggeredActions.ts @@ -0,0 +1,54 @@ +import { ITranslatableMessage, SomeAction, SomeBlueprintTrigger } from '@sofie-automation/blueprints-integration' + +import { ShowStyleBaseId, TriggeredActionId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ObjectWithOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' + +export type DBBlueprintTrigger = SomeBlueprintTrigger + +export interface UITriggeredActionsObj { + _id: TriggeredActionId + /** Rank number for visually ordering the hotkeys */ + _rank: number + + /** Optional label to specify what this triggered action is supposed to do, a comment basically */ + name?: ITranslatableMessage | string + + /** Id of parent ShowStyleBase. If null, this is a system-wide triggered action */ + showStyleBaseId: ShowStyleBaseId | null + + /** Triggers, with attached device info alongside */ + triggers: Record + + /** A list of actions to execute */ + actions: Record + + /** Space separated list of class names to use when displaying this triggered actions */ + styleClassNames?: string +} + +export interface DBTriggeredActions { + _id: TriggeredActionId + /** Rank number for visually ordering the hotkeys */ + _rank: number + + /** Optional label to specify what this triggered action is supposed to do, a comment basically */ + name?: ITranslatableMessage | string + + /** Id of parent ShowStyleBase. If null, this is a system-wide triggered action */ + showStyleBaseId: ShowStyleBaseId | null + + /** Identifier given by the blueprints for this document. Set to null if owned by the user */ + blueprintUniqueId: string | null + + /** Triggers, with attached device info alongside */ + triggersWithOverrides: ObjectWithOverrides> + + /** A list of actions to execute */ + actionsWithOverrides: ObjectWithOverrides> + + /** Space separated list of class names to use when displaying this triggered actions */ + styleClassNames?: string +} + +/** Note: Use DBTriggeredActions instead */ +export type TriggeredActionsObj = DBTriggeredActions diff --git a/packages/webui/src/lib/collections/UserActionsLog.ts b/packages/webui/src/lib/collections/UserActionsLog.ts new file mode 100644 index 0000000000..f7e1c422f7 --- /dev/null +++ b/packages/webui/src/lib/collections/UserActionsLog.ts @@ -0,0 +1,41 @@ +import { Time, TimeDuration } from '../lib' +import { UserActionsLogItemId, OrganizationId, UserId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { TimelineHash } from '@sofie-automation/corelib/dist/dataModel/Timeline' + +export interface UserActionsLogItem { + _id: UserActionsLogItemId + + organizationId: OrganizationId | null + /** The user from which the action originated */ + userId: UserId | null + /** The cliend address (IP-address) of the requester */ + clientAddress: string + /** Timestamp for when the action was created (ie beginning of execution) */ + timestamp: Time + method: string + args: string + context: string + /** undefined=in progress, true=finished successfully, false=finished with error */ + success?: boolean + errorMessage?: string + + /** Timestamp for when the action result was sent to the Client */ + doneTime?: Time + + /** The timelineHash that resulted from the userAction. Used to set .gatewayDuration. */ + timelineHash?: TimelineHash + /** Timestamp for when the timeline was generated, used to calculate .gatewayDuration. */ + timelineGenerated?: number + + /** Timestamp (as calculated by the GUI) for when the user initiated the execution of the action */ + clientTime?: Time + + /** The time it took (within core & worker) to execute the action */ + executionTime?: TimeDuration + /** The time it took within the worker to execute */ + workerTime?: TimeDuration + /** The total time it took for playout-gateway(s) to receive and execute the timeline. */ + gatewayDuration?: TimeDuration[] + /** The time playout-gateway(s) reported it took to resolve the timeline. */ + timelineResolveDuration?: TimeDuration[] +} diff --git a/packages/webui/src/lib/collections/Users.ts b/packages/webui/src/lib/collections/Users.ts new file mode 100644 index 0000000000..29f60eddba --- /dev/null +++ b/packages/webui/src/lib/collections/Users.ts @@ -0,0 +1,57 @@ +import { Meteor } from 'meteor/meteor' +import { unprotectString } from '../lib' +import { UserRoles, DBOrganization } from './Organization' +import { UserId, OrganizationId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { Organizations } from '../collections/libCollections' + +export interface UserProfile { + name: string +} + +export interface DBUser { + // Note: This interface is partly defined by the dataset from the Meteor.users collection + + _id: UserId + createdAt: string + services: { + password: { + bcrypt: string + } + } + username: string + emails: [ + { + address: string + verified: boolean + } + ] + profile: UserProfile + organizationId: OrganizationId + superAdmin?: boolean +} + +export type User = DBUser // to be replaced by a class somet ime later? + +/** Returns the currently logged in user, or null if not logged in */ +export function getUser(): User | null { + const user = Meteor.user() as any + return user +} +export function getUserId(): UserId | null { + return (Meteor.userId() as any) || null +} +export function getUserRoles(user?: User | null, organization?: DBOrganization | null): UserRoles { + if (user === undefined) user = getUser() + if (!user) { + return {} + } + if (organization === undefined) organization = Organizations.findOne({ _id: user.organizationId }) || null + return getUserRolesFromLoadedDocuments(user, organization) +} + +export function getUserRolesFromLoadedDocuments(user: User | null, organization: DBOrganization | null): UserRoles { + if (!user) { + return {} + } + return (organization?.userRoles && organization.userRoles[unprotectString(user._id)]) || {} +} diff --git a/packages/webui/src/lib/collections/Workers.ts b/packages/webui/src/lib/collections/Workers.ts new file mode 100644 index 0000000000..67c18d6f4b --- /dev/null +++ b/packages/webui/src/lib/collections/Workers.ts @@ -0,0 +1,31 @@ +import { Time } from '@sofie-automation/blueprints-integration' +import { WorkerId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { protectString } from '../lib' + +export interface WorkerStatus { + _id: WorkerId + /** A user-facing name */ + name: string + /** The instance id is unique each time a worker starts up */ + instanceId: string + /** Timestamp for when the worker was first created */ + createdTime: Time + /** Timestamp for when the worker was last started */ + startTime: Time + /** Timestamp of last status update */ + lastUpdatedTime: Time + + /** If the worker is connected (alive) or not */ + connected: boolean + + status: string + // studioId (or other context-descriptor) +} + +export function getWorkerId(): WorkerId { + // This is a placeholder function for now. + // Later on, when we support multiple workers, this will determine unique worker names using things like + // the studio it works on, etc. + + return protectString('default') +} diff --git a/packages/webui/src/lib/collections/lib.ts b/packages/webui/src/lib/collections/lib.ts new file mode 100644 index 0000000000..67c756c7f0 --- /dev/null +++ b/packages/webui/src/lib/collections/lib.ts @@ -0,0 +1,387 @@ +import { Meteor } from 'meteor/meteor' +import { Mongo } from 'meteor/mongo' +import { ProtectedString, protectString } from '../lib' +import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import type { Collection as RawCollection, Db as RawDb } from 'mongodb' +import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' +import { MongoFieldSpecifier, MongoModifier, MongoQuery, SortSpecifier } from '@sofie-automation/corelib/dist/mongo' +import { CustomCollectionName, MeteorPubSubCustomCollections } from '../api/pubsub' +import { + PeripheralDevicePubSubCollections, + PeripheralDevicePubSubCollectionsNames, +} from '@sofie-automation/shared-lib/dist/pubsub/peripheralDevice' + +export const ClientCollections = new Map | WrappedMongoReadOnlyCollection>() +function registerClientCollection( + name: CollectionName, + collection: MongoCollection | WrappedMongoReadOnlyCollection +): void { + if (ClientCollections.has(name)) throw new Meteor.Error(`Cannot re-register collection "${name}"`) + ClientCollections.set(name, collection) +} + +export const PublicationCollections = new Map< + CustomCollectionName | PeripheralDevicePubSubCollectionsNames, + WrappedMongoReadOnlyCollection +>() + +/** + * Map of current collection objects. + * Future: Could this weakly hold the collections? + */ +export const collectionsCache = new Map>() +export function getOrCreateMongoCollection(name: string): Mongo.Collection { + const collection = collectionsCache.get(name) + if (collection) { + return collection + } + + const newCollection = new Mongo.Collection(name) + collectionsCache.set(name, newCollection) + return newCollection +} + +/** + * Wrap an existing Mongo.Collection to have async methods. Primarily to convert the built-in Users collection + * @param collection Collection to wrap + * @param name Name of the collection + */ +export function wrapMongoCollection }>( + collection: Mongo.Collection, + name: CollectionName +): MongoCollection { + const wrapped = new WrappedMongoCollection(collection, name) + + registerClientCollection(name, wrapped) + + return wrapped +} + +/** + * Create a sync in-memory Mongo Collection (for ui temporary storage) + * @param name Name of the collection (for logging) + */ +export function createInMemorySyncMongoCollection }>( + name: string +): MongoCollection { + const collection = new Mongo.Collection(null) + return new WrappedMongoCollection(collection, name) +} + +/** + * Create a Mongo Collection for use in the client (has sync apis) + * @param name Name of the collection + */ +export function createSyncMongoCollection }>( + name: CollectionName +): MongoCollection { + const collection = getOrCreateMongoCollection(name) + const wrapped = new WrappedMongoCollection(collection, name) + + registerClientCollection(name, wrapped) + + return wrapped +} + +/** + * Create a Mongo Collection for use in the client (has sync apis) + * @param name Name of the collection + */ +export function createSyncReadOnlyMongoCollection }>( + name: CollectionName +): MongoReadOnlyCollection { + const collection = getOrCreateMongoCollection(name) + const wrapped = new WrappedMongoReadOnlyCollection(collection, name) + + registerClientCollection(name, wrapped) + + return wrapped +} + +/** + * Create a Mongo Collection for a virtual collection populated by a custom-publication + * @param name Name of the custom-collection + */ +export function createSyncCustomPublicationMongoCollection< + K extends CustomCollectionName & keyof MeteorPubSubCustomCollections +>(name: K): MongoReadOnlyCollection { + const collection = new Mongo.Collection(name) + const wrapped = new WrappedMongoReadOnlyCollection(collection, name) + + if (PublicationCollections.has(name)) throw new Meteor.Error(`Cannot re-register collection "${name}"`) + PublicationCollections.set(name, wrapped) + + return wrapped +} + +export function createSyncPeripheralDeviceCustomPublicationMongoCollection< + K extends PeripheralDevicePubSubCollectionsNames & keyof PeripheralDevicePubSubCollections +>(name: K): MongoReadOnlyCollection { + const collection = new Mongo.Collection(name) + const wrapped = new WrappedMongoReadOnlyCollection(collection, name) + + if (PublicationCollections.has(name)) throw new Meteor.Error(`Cannot re-register collection "${name}"`) + PublicationCollections.set(name, wrapped) + + return wrapped +} + +class WrappedMongoReadOnlyCollection }> + implements MongoReadOnlyCollection +{ + protected readonly _collection: Mongo.Collection + + public readonly name: string | null + + constructor(collection: Mongo.Collection, name: string | null) { + this._collection = collection + this.name = name + } + + protected get _isMock() { + // @ts-expect-error re-export private property + return this._collection._isMock + } + + public get mockCollection() { + return this._collection + } + + protected wrapMongoError(e: any): never { + const str = stringifyError(e) || 'Unknown MongoDB Error' + throw new Meteor.Error((e && e.error) || 500, `Collection "${this.name}": ${str}`) + } + + find( + selector?: MongoQuery | DBInterface['_id'], + options?: FindOptions + ): MongoCursor { + try { + return this._collection.find((selector ?? {}) as any, options as any) as MongoCursor + } catch (e) { + this.wrapMongoError(e) + } + } + findOne( + selector?: MongoQuery | DBInterface['_id'], + options?: FindOneOptions + ): DBInterface | undefined { + try { + return this._collection.findOne((selector ?? {}) as any, options as any) + } catch (e) { + this.wrapMongoError(e) + } + } +} +export class WrappedMongoCollection }> + extends WrappedMongoReadOnlyCollection + implements MongoCollection +{ + insert(doc: DBInterface): DBInterface['_id'] { + try { + const resultId = this._collection.insert(doc as unknown as Mongo.OptionalId) + return protectString(resultId) + } catch (e) { + this.wrapMongoError(e) + } + } + rawCollection(): RawCollection { + return this._collection.rawCollection() as any + } + rawDatabase(): RawDb { + return this._collection.rawDatabase() as any + } + remove(selector: MongoQuery | DBInterface['_id']): number { + try { + return this._collection.remove(selector as any) + } catch (e) { + this.wrapMongoError(e) + } + } + update( + selector: MongoQuery | DBInterface['_id'] | { _id: DBInterface['_id'] }, + modifier: MongoModifier, + options?: UpdateOptions + ): number { + try { + return this._collection.update(selector as any, modifier as any, options) + } catch (e) { + this.wrapMongoError(e) + } + } + upsert( + selector: MongoQuery | DBInterface['_id'], + modifier: MongoModifier, + options?: UpsertOptions + ): { + numberAffected?: number + insertedId?: DBInterface['_id'] + } { + try { + const result = this._collection.upsert(selector as any, modifier as any, options) + return { + numberAffected: result.numberAffected, + insertedId: protectString(result.insertedId), + } + } catch (e) { + this.wrapMongoError(e) + } + } +} + +export interface MongoReadOnlyCollection }> { + /** + * Find the documents in a collection that match the selector. + * @param selector A query describing the documents to find + */ + find( + selector?: MongoQuery | DBInterface['_id'], + options?: FindOptions + ): MongoCursor + + /** + * Finds the first document that matches the selector, as ordered by sort and skip options. Returns `undefined` if no matching document is found. + * @param selector A query describing the documents to find + */ + findOne( + selector?: MongoQuery | DBInterface['_id'], + options?: FindOneOptions + ): DBInterface | undefined +} +/** + * A minimal MongoCollection type based on the Meteor Mongo.Collection type, but with our improved _id type safety. + * Note: when updating method signatures, make sure to update the implementions as new properties may not be fed through without additional work + */ +export interface MongoCollection }> + extends MongoReadOnlyCollection { + /** + * Insert a document in the collection. Returns its unique _id. + * @param doc The document to insert. May not yet have an _id attribute, in which case Meteor will generate one for you. + */ + insert(doc: DBInterface): DBInterface['_id'] + + /** + * Returns the [`Collection`](http://mongodb.github.io/node-mongodb-native/3.0/api/Collection.html) object corresponding to this collection from the + * [npm `mongodb` driver module](https://www.npmjs.com/package/mongodb) which is wrapped by `Mongo.Collection`. + */ + rawCollection(): RawCollection + + /** + * Returns the [`Db`](http://mongodb.github.io/node-mongodb-native/3.0/api/Db.html) object corresponding to this collection's database connection from the + * [npm `mongodb` driver module](https://www.npmjs.com/package/mongodb) which is wrapped by `Mongo.Collection`. + */ + rawDatabase(): RawDb + + /** + * Remove documents from the collection + * @param selector Specifies which documents to remove + */ + remove(selector: MongoQuery | DBInterface['_id']): number + + /** + * Modify one or more documents in the collection. Returns the number of matched documents. + * @param selector Specifies which documents to modify + * @param modifier Specifies how to modify the documents + */ + update( + selector: DBInterface['_id'] | { _id: DBInterface['_id'] }, + modifier: MongoModifier, + options?: UpdateOptions + ): number + update( + selector: MongoQuery, + modifier: MongoModifier, + // Require { multi } to be set when selecting multiple documents to be updated, otherwise only the first found document will be updated + options: UpdateOptions & Required> + ): number + + /** + * Modify one or more documents in the collection, or insert one if no matching documents were found. Returns an object with keys `numberAffected` (the number of documents modified) and + * `insertedId` (the unique _id of the document that was inserted, if any). + * @param selector Specifies which documents to modify + * @param modifier Specifies how to modify the documents + */ + upsert( + selector: MongoQuery | DBInterface['_id'], + modifier: MongoModifier, + options?: UpsertOptions + ): { + numberAffected?: number + insertedId?: DBInterface['_id'] + } +} + +export interface UpdateOptions { + /** True to modify all matching documents; false to only modify one of the matching documents (the default). */ + multi?: boolean + /** True to insert a document if no matching documents are found. */ + upsert?: boolean + /** + * Used in combination with MongoDB [filtered positional operator](https://docs.mongodb.com/manual/reference/operator/update/positional-filtered/) to specify which elements to + * modify in an array field. + */ + arrayFilters?: { [identifier: string]: any }[] +} +export interface UpsertOptions { + /** True to modify all matching documents; false to only modify one of the matching documents (the default). */ + multi?: boolean +} + +export type IndexSpecifier = { + [P in keyof T]?: -1 | 1 | string +} + +export interface MongoCursor }> + extends Omit, 'observe' | 'observeChanges'> { + observe(callbacks: ObserveCallbacks): Meteor.LiveQueryHandle + observeChanges(callbacks: ObserveChangesCallbacks): Meteor.LiveQueryHandle +} +export interface ObserveCallbacks { + added?(document: DBInterface): void + addedAt?(document: DBInterface, atIndex: number, before: DBInterface): void + changed?(newDocument: DBInterface, oldDocument: DBInterface): void + changedAt?(newDocument: DBInterface, oldDocument: DBInterface, indexAt: number): void + removed?(oldDocument: DBInterface): void + removedAt?(oldDocument: DBInterface, atIndex: number): void + movedTo?(document: DBInterface, fromIndex: number, toIndex: number, before: Object): void +} +export interface ObserveChangesCallbacks }> { + added?(id: DBInterface['_id'], fields: Object): void + addedBefore?(id: DBInterface['_id'], fields: Object, before: Object): void + changed?(id: DBInterface['_id'], fields: Object): void + movedBefore?(id: DBInterface['_id'], before: Object): void + removed?(id: DBInterface['_id']): void +} + +export interface FindOneOptions { + /** Sort order (default: natural order) */ + sort?: SortSpecifier + /** Number of results to skip at the beginning */ + skip?: number + /** @deprecated Dictionary of fields to return or exclude. */ + fields?: MongoFieldSpecifier + /** Dictionary of fields to return or exclude. */ + projection?: MongoFieldSpecifier + /** (Client only) Default `true`; pass `false` to disable reactivity */ + reactive?: boolean + /** Overrides `transform` on the [`Collection`](#collections) for this cursor. Pass `null` to disable transformation. */ + // transform?: Transform; + /** (Server only) Specifies a custom MongoDB readPreference for this particular cursor. Possible values are primary, primaryPreferred, secondary, secondaryPreferred and nearest. */ + readPreference?: string +} +export interface FindOptions extends FindOneOptions { + /** Maximum number of results to return */ + limit?: number + /** (Server only) Pass true to disable oplog-tailing on this query. This affects the way server processes calls to observe on this query. Disabling the oplog can be useful when working with data that updates in large batches. */ + disableOplog?: boolean + /** (Server only) When oplog is disabled (through the use of disableOplog or when otherwise not available), the frequency (in milliseconds) of how often to poll this query when observing on the server. Defaults to 10000ms (10 seconds). */ + pollingIntervalMs?: number + /** (Server only) When oplog is disabled (through the use of disableOplog or when otherwise not available), the minimum time (in milliseconds) to allow between re-polling when observing on the server. Increasing this will save CPU and mongo load at the expense of slower updates to users. Decreasing this is not recommended. Defaults to 50ms. */ + pollingThrottleMs?: number + /** (Server only) If set, instructs MongoDB to set a time limit for this cursor's operations. If the operation reaches the specified time limit (in milliseconds) without the having been completed, an exception will be thrown. Useful to prevent an (accidental or malicious) unoptimized query from causing a full collection scan that would disrupt other database users, at the expense of needing to handle the resulting error. */ + maxTimeMs?: number + /** (Server only) Overrides MongoDB's default index selection and query optimization process. Specify an index to force its use, either by its name or index specification. You can also specify { $natural : 1 } to force a forwards collection scan, or { $natural : -1 } for a reverse collection scan. Setting this is only recommended for advanced users. */ + hint?: string | object +} + +export type FieldNames = (keyof DBInterface)[] diff --git a/packages/webui/src/lib/collections/libCollections.ts b/packages/webui/src/lib/collections/libCollections.ts new file mode 100644 index 0000000000..9192074ad2 --- /dev/null +++ b/packages/webui/src/lib/collections/libCollections.ts @@ -0,0 +1,47 @@ +/** + * These collections should be in meteor/client/collections, but are used here in lib. + * Over time they should be moved across as this is decoupled from server + */ + +import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' +import { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' +import { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' +import { createSyncMongoCollection, createSyncReadOnlyMongoCollection } from './lib' +import { DBOrganization } from './Organization' +import { PartInstance } from './PartInstances' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' +import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' +import { RundownBaselineAdLibAction } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibAction' +import { RundownBaselineAdLibItem } from '@sofie-automation/corelib/dist/dataModel/RundownBaselineAdLibPiece' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' + +export const AdLibActions = createSyncReadOnlyMongoCollection(CollectionName.AdLibActions) + +export const AdLibPieces = createSyncReadOnlyMongoCollection(CollectionName.AdLibPieces) + +export const Organizations = createSyncMongoCollection(CollectionName.Organizations) + +export const PieceInstances = createSyncReadOnlyMongoCollection(CollectionName.PieceInstances) + +export const Pieces = createSyncReadOnlyMongoCollection(CollectionName.Pieces) + +export const PartInstances = createSyncReadOnlyMongoCollection(CollectionName.PartInstances) + +export const Parts = createSyncReadOnlyMongoCollection(CollectionName.Parts) + +export const RundownBaselineAdLibActions = createSyncReadOnlyMongoCollection( + CollectionName.RundownBaselineAdLibActions +) + +export const RundownBaselineAdLibPieces = createSyncReadOnlyMongoCollection( + CollectionName.RundownBaselineAdLibPieces +) + +export const RundownPlaylists = createSyncReadOnlyMongoCollection(CollectionName.RundownPlaylists) + +export const Rundowns = createSyncReadOnlyMongoCollection(CollectionName.Rundowns) + +export const Segments = createSyncReadOnlyMongoCollection(CollectionName.Segments) diff --git a/packages/webui/src/lib/collections/rundownPlaylistUtil.ts b/packages/webui/src/lib/collections/rundownPlaylistUtil.ts new file mode 100644 index 0000000000..114bc45ebb --- /dev/null +++ b/packages/webui/src/lib/collections/rundownPlaylistUtil.ts @@ -0,0 +1,372 @@ +import { PartId, RundownId, RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { Rundown, DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { normalizeArrayToMap, normalizeArrayFunc, groupByToMap } from '@sofie-automation/corelib/dist/lib' +import { + sortRundownIDsInPlaylist, + sortSegmentsInRundowns, + sortPartsInSegments, + sortPartsInSortedSegments, +} from '@sofie-automation/corelib/dist/playout/playlist' +import { unprotectString } from '@sofie-automation/corelib/dist/protectedString' +import _ from 'underscore' +import { Rundowns, Segments, Parts, PartInstances, Pieces } from './libCollections' +import { FindOptions } from './lib' +import { PartInstance } from './PartInstances' +import { MongoQuery } from '@sofie-automation/corelib/dist/mongo' +import { Piece } from '@sofie-automation/corelib/dist/dataModel/Piece' + +/** + * Direct database accessors for the RundownPlaylist + * These used to reside on the Rundown class + */ +export class RundownPlaylistCollectionUtil { + /** Returns an array of all Rundowns in the RundownPlaylist, sorted in playout order */ + static getRundownsOrdered( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): Rundown[] { + const allRundowns = RundownPlaylistCollectionUtil.getRundownsUnordered(playlist._id, selector, options) + + const rundownsMap = normalizeArrayToMap(allRundowns, '_id') + + const sortedIds = sortRundownIDsInPlaylist(playlist.rundownIdsInOrder, Array.from(rundownsMap.keys())) + + return _.compact(sortedIds.map((id) => rundownsMap.get(id))) + } + /** Returns an array of all Rundowns in the RundownPlaylist, in no predictable order */ + static getRundownsUnordered( + playlistId: RundownPlaylistId, + selector?: MongoQuery, + options?: FindOptions + ): Rundown[] { + return Rundowns.find( + { + playlistId: playlistId, + ...selector, + }, + { + sort: { + _id: 1, + }, + ...options, + } + ).fetch() + } + /** Returns an array with the id:s of all Rundowns in the RundownPlaylist, sorted in playout order */ + static getRundownOrderedIDs(playlist: Pick): RundownId[] { + const allIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) + + return sortRundownIDsInPlaylist(playlist.rundownIdsInOrder, allIds) + } + /** Returns an array with the id:s of all Rundowns in the RundownPlaylist, in no predictable order */ + static getRundownUnorderedIDs(playlist: Pick): RundownId[] { + const rundowns = Rundowns.find( + { + playlistId: playlist._id, + }, + { + fields: { + _id: 1, + }, + } + ).fetch() as Array> + + return rundowns.map((i) => i._id) + } + + /** Returns all segments joined with their rundowns in their correct oreder for this RundownPlaylist */ + static getRundownsAndSegments( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): Array<{ + rundown: Pick< + Rundown, + | '_id' + | 'name' + | 'playlistId' + | 'timing' + | 'showStyleBaseId' + | 'showStyleVariantId' + | 'endOfRundownIsShowBreak' + > + segments: DBSegment[] + }> { + const rundowns = RundownPlaylistCollectionUtil.getRundownsOrdered(playlist, undefined, { + fields: { + name: 1, + playlistId: 1, + timing: 1, + showStyleBaseId: 1, + showStyleVariantId: 1, + endOfRundownIsShowBreak: 1, + }, + }) + const segments = Segments.find( + { + rundownId: { + $in: rundowns.map((i) => i._id), + }, + ...selector, + }, + { + sort: { + rundownId: 1, + _rank: 1, + }, + ...options, + } + ).fetch() + return RundownPlaylistCollectionUtil._matchSegmentsAndRundowns(segments, rundowns) + } + /** Returns all segments in their correct order for this RundownPlaylist */ + static getSegments( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): DBSegment[] { + const rundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) + const segments = Segments.find( + { + rundownId: { + $in: rundownIds, + }, + ...selector, + }, + { + sort: { + rundownId: 1, + _rank: 1, + }, + ...options, + } + ).fetch() + return RundownPlaylistCollectionUtil._sortSegments(segments, playlist) + } + static getUnorderedParts( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): DBPart[] { + const rundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) + const parts = Parts.find( + { + ...selector, + rundownId: { + $in: rundownIds, + }, + }, + { + ...options, + sort: { + rundownId: 1, + _rank: 1, + }, + } + ).fetch() + return parts + } + /** Synchronous version of getSegmentsAndParts, to be used client-side */ + static getSegmentsAndPartsSync( + playlist: Pick, + segmentsQuery?: MongoQuery, + partsQuery?: MongoQuery, + segmentsOptions?: Omit, 'projection'>, // We are mangling fields, so block projection + partsOptions?: Omit, 'projection'> // We are mangling fields, so block projection + ): { segments: DBSegment[]; parts: DBPart[] } { + const rundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) + const segments = Segments.find( + { + rundownId: { + $in: rundownIds, + }, + ...segmentsQuery, + }, + { + ...segmentsOptions, + //@ts-expect-error This is too clever for the compiler + fields: segmentsOptions?.fields + ? { + ...segmentsOptions?.fields, + _rank: 1, + rundownId: 1, + } + : undefined, + sort: { + ...segmentsOptions?.sort, + rundownId: 1, + _rank: 1, + }, + } + ).fetch() + + const parts = Parts.find( + { + rundownId: { + $in: rundownIds, + }, + ...partsQuery, + }, + { + ...partsOptions, + //@ts-expect-error This is too clever for the compiler + fields: partsOptions?.fields + ? { + ...partsOptions?.fields, + rundownId: 1, + segmentId: 1, + _rank: 1, + } + : undefined, + sort: { + ...segmentsOptions?.sort, + rundownId: 1, + _rank: 1, + }, + } + ).fetch() + + const sortedSegments = RundownPlaylistCollectionUtil._sortSegments(segments, playlist) + return { + segments: sortedSegments, + parts: RundownPlaylistCollectionUtil._sortPartsInner(parts, sortedSegments), + } + } + static getSelectedPartInstances( + playlist: Pick, + rundownIds0?: RundownId[] + ): { + currentPartInstance: PartInstance | undefined + nextPartInstance: PartInstance | undefined + previousPartInstance: PartInstance | undefined + } { + let unorderedRundownIds = rundownIds0 + if (!unorderedRundownIds) { + unorderedRundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) + } + + const ids = _.compact([ + playlist.currentPartInfo?.partInstanceId, + playlist.previousPartInfo?.partInstanceId, + playlist.nextPartInfo?.partInstanceId, + ]) + const instances = + ids.length > 0 + ? PartInstances.find({ + rundownId: { $in: unorderedRundownIds }, + _id: { $in: ids }, + reset: { $ne: true }, + }).fetch() + : [] + + return { + currentPartInstance: instances.find((inst) => inst._id === playlist.currentPartInfo?.partInstanceId), + nextPartInstance: instances.find((inst) => inst._id === playlist.nextPartInfo?.partInstanceId), + previousPartInstance: instances.find((inst) => inst._id === playlist.previousPartInfo?.partInstanceId), + } + } + + static getAllPartInstances( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): PartInstance[] { + const unorderedRundownIds = RundownPlaylistCollectionUtil.getRundownUnorderedIDs(playlist) + + return PartInstances.find( + { + rundownId: { $in: unorderedRundownIds }, + ...selector, + }, + { + sort: { takeCount: 1 }, + ...options, + } + ).fetch() + } + /** Return a list of PartInstances, omitting the reset ones (ie only the ones that are relevant) */ + static getActivePartInstances( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): PartInstance[] { + const newSelector: MongoQuery = { + ...selector, + reset: { $ne: true }, + } + return RundownPlaylistCollectionUtil.getAllPartInstances(playlist, newSelector, options) + } + static getActivePartInstancesMap( + playlist: Pick, + selector?: MongoQuery, + options?: FindOptions + ): Record { + const instances = RundownPlaylistCollectionUtil.getActivePartInstances(playlist, selector, options) + return normalizeArrayFunc(instances, (i) => unprotectString(i.part._id)) + } + static getPiecesForParts( + parts: Array, + piecesOptions?: Omit, 'projection'> // We are mangling fields, so block projection + ): Map { + const allPieces = Pieces.find( + { startPartId: { $in: parts } }, + { + ...piecesOptions, + //@ts-expect-error This is too clever for the compiler + fields: piecesOptions?.fields + ? { + ...piecesOptions?.fields, + startPartId: 1, + } + : undefined, + } + ).fetch() + return groupByToMap(allPieces, 'startPartId') + } + + static _sortSegments>( + segments: Array, + playlist: Pick + ): TSegment[] { + return sortSegmentsInRundowns(segments, playlist.rundownIdsInOrder) + } + static _matchSegmentsAndRundowns( + segments: E[], + rundowns: T[] + ): Array<{ rundown: T; segments: E[] }> { + const rundownsMap = new Map< + RundownId, + { + rundown: T + segments: E[] + } + >() + rundowns.forEach((rundown) => { + rundownsMap.set(rundown._id, { + rundown, + segments: [], + }) + }) + segments.forEach((segment) => { + rundownsMap.get(segment.rundownId)?.segments.push(segment) + }) + return Array.from(rundownsMap.values()) + } + static _sortParts( + parts: DBPart[], + playlist: Pick, + segments: Array> + ): DBPart[] { + return sortPartsInSegments(parts, playlist.rundownIdsInOrder, segments) + } + static _sortPartsInner

>( + parts: P[], + sortedSegments: Array> + ): P[] { + return sortPartsInSortedSegments(parts, sortedSegments) + } +} diff --git a/packages/webui/src/lib/invalidatingTime.ts b/packages/webui/src/lib/invalidatingTime.ts new file mode 100644 index 0000000000..9bac36f8b8 --- /dev/null +++ b/packages/webui/src/lib/invalidatingTime.ts @@ -0,0 +1,22 @@ +import { Tracker } from 'meteor/tracker' +import { getCurrentTime } from './lib' + +/** Invalidate a reactive computation after a given amount of time */ +export function invalidateAfter(timeout: number): void { + const time = new Tracker.Dependency() + time.depend() + const t = setTimeout(() => { + time.changed() + }, timeout) + if (Tracker.currentComputation) { + Tracker.currentComputation.onInvalidate(() => { + clearTimeout(t) + }) + } +} + +/** Invalidate a reactive computation after when a given time is reached */ +export function invalidateAt(timestamp: number): void { + const timeout = Math.max(0, timestamp - getCurrentTime()) + invalidateAfter(timeout) +} diff --git a/packages/webui/src/lib/lib.ts b/packages/webui/src/lib/lib.ts new file mode 100644 index 0000000000..6450c0352f --- /dev/null +++ b/packages/webui/src/lib/lib.ts @@ -0,0 +1,559 @@ +import * as _ from 'underscore' +import { ITranslatableMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' +import { Meteor } from 'meteor/meteor' +import { ProtectedString } from '@sofie-automation/corelib/dist/protectedString' +import { logger } from './logging' + +import { Time, TimeDuration } from '@sofie-automation/shared-lib/dist/lib/lib' +import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { ReactiveVar } from 'meteor/reactive-var' +import { MeteorApply } from './MeteorApply' +export type { Time, TimeDuration } + +// Legacy compatability +export type { ProtectedString } from '@sofie-automation/shared-lib/dist/lib/protectedString' +export { + protectString, + isProtectedString, + unprotectString, +} from '@sofie-automation/shared-lib/dist/lib/protectedString' +export type { Subtract, Complete, ArrayElement, ManualPromise } from '@sofie-automation/corelib/dist/lib' +export { + getHash, + hashObj, + // + getSofieHostUrl, + omit, + flatten, + max, + min, + assertNever, + clone, + deepFreeze, + getRandomString, + getRandomId, + literal, + normalizeArrayFuncFilter, + normalizeArrayFunc, + normalizeArray, + normalizeArrayToMap, + normalizeArrayToMapFunc, + groupByToMap, + groupByToMapFunc, + deleteAllUndefinedProperties, + applyToArray, + objectPathGet, + objectPathSet, + objectPathDelete, + getRank, + createManualPromise, + formatDateAsTimecode, + formatDurationAsTimecode, + removeNullyProperties, + // deferAsync, + joinObjectPathFragments, +} from '@sofie-automation/corelib/dist/lib' + +export type PromisifyCallbacks = { + [K in keyof T]: PromisifyFunction +} +type PromisifyFunction = T extends (...args: any) => any + ? (...args: Parameters) => Promise> | ReturnType + : T + +/** + * Convenience method to convert a Meteor.apply() into a Promise + * @param callName {string} Method name + * @param args {Array} An array of arguments for the method call + * @param options (Optional) An object with options for the call. See Meteor documentation. + * @returns {Promise} A promise containing the result of the called method. + */ +export async function MeteorPromiseApply( + callName: Parameters[0], + args: Parameters[1], + options?: Parameters[2] +): Promise { + if (Meteor.isServer) { + return new Promise((resolve, reject) => { + Meteor.apply(callName, args, options, (err, res) => { + if (err) reject(err) + else resolve(res) + }) + }) + } else { + return MeteorApply(callName, args, options) + } +} + +// The diff is currently only used client-side +const systemTime = { + hasBeenSet: false, + diff: 0, + stdDev: 9999, + lastSync: 0, + timeOriginDiff: 0, +} +/** + * Returns the current (synced) time. + * If NTP-syncing is enabled, it'll be unaffected of whether the client has a well-synced computer time or not. + * @return {Time} + */ +export function getCurrentTime(): Time { + return Math.floor(Date.now() - (Meteor.isServer ? 0 : systemTime.diff)) +} +export { systemTime } + +export interface DBObj { + _id: ProtectedString + [key: string]: any +} + +export type Partial = { + [P in keyof T]?: T[P] +} +export function partial(o: Partial): Partial { + return o +} + +/** + * Formats the time as human-readable time "YYYY-MM-DD hh:ii:ss" + * @param time + */ +export function formatDateTime(time: Time): string { + const d = new Date(time) + + const yyyy: any = d.getFullYear() + let mm: any = d.getMonth() + 1 + let dd: any = d.getDate() + + let hh: any = d.getHours() + let ii: any = d.getMinutes() + let ss: any = d.getSeconds() + + if (mm < 10) mm = '0' + mm + if (dd < 10) dd = '0' + dd + if (hh < 10) hh = '0' + hh + if (ii < 10) ii = '0' + ii + if (ss < 10) ss = '0' + ss + + return `${yyyy}-${mm}-${dd} ${hh}:${ii}:${ss}` +} + +export function formatTime(time: number): string { + const ss = String(Math.ceil(time / 1000) % 60).padStart(2, '0') + const mm = String(Math.floor(time / 60000) % 60).padStart(2, '0') + const hh = String(Math.floor(time / 3600000)).padStart(2, '0') + + return `${hh}:${mm}:${ss}` +} + +/** + * Returns a string that can be used to compare objects for equality + * @param objs + */ +export function stringifyObjects(objs: unknown): string { + if (_.isArray(objs)) { + return _.map(objs, (obj) => { + if (obj !== undefined) { + return stringifyObjects(obj) + } + }).join(',') + } else if (_.isFunction(objs)) { + return '' + } else if (_.isObject(objs)) { + const objs0 = objs as any + const keys = _.sortBy(_.keys(objs), (k) => k) + + return _.compact( + _.map(keys, (key) => { + if (objs0[key] !== undefined) { + return key + '=' + stringifyObjects(objs0[key]) + } else { + return null + } + }) + ).join(',') + } else { + return objs + '' + } +} + +/** Convenience function, to be used when length of array has previously been verified */ +export function last(values: T[]): T { + return _.last(values) as T +} + +export function objectFromEntries, Val>( + entries: Array<[Key, Val]> +): Record { + return Object.fromEntries(entries) +} + +const cacheResultCache: { + [name: string]: { + ttl: number + value: any + } +} = {} +/** Cache the result of function for a limited time */ +export function cacheResult(name: string, fcn: () => T, limitTime = 1000): T { + if (Math.random() < 0.01) { + Meteor.setTimeout(cleanOldCacheResult, 10000) + } + const cache = cacheResultCache[name] + if (!cache || cache.ttl < Date.now()) { + const value: T = fcn() + cacheResultCache[name] = { + ttl: Date.now() + limitTime, + value: value, + } + return value + } else { + return cache.value + } +} +/** Cache the result of function for a limited time */ +export async function cacheResultAsync(name: string, fcn: () => Promise, limitTime = 1000): Promise { + if (Math.random() < 0.01) { + Meteor.setTimeout(cleanOldCacheResult, 10000) + } + const cache = cacheResultCache[name] + if (!cache || cache.ttl < Date.now()) { + const value: Promise = fcn() + cacheResultCache[name] = { + ttl: Date.now() + limitTime, + value: value, + } + return value + } else { + return cache.value + } +} +export function clearCacheResult(name: string): void { + delete cacheResultCache[name] +} +function cleanOldCacheResult() { + _.each(cacheResultCache, (cache, name) => { + if (cache.ttl < Date.now()) clearCacheResult(name) + }) +} +const lazyIgnoreCache: { [name: string]: number } = {} +export function lazyIgnore(name: string, f1: () => Promise | void, t: number): void { + // Don't execute the function f1 until the time t has passed. + // Subsequent calls will extend the laziness and ignore the previous call + + if (lazyIgnoreCache[name]) { + Meteor.clearTimeout(lazyIgnoreCache[name]) + } + lazyIgnoreCache[name] = Meteor.setTimeout(() => { + delete lazyIgnoreCache[name] + // if (Meteor.isClient) { + f1()?.catch((e) => { + throw new Error(e) + }) + // } else { + // waitForPromise(f1()) + // } + }, t) +} + +const ticCache: Record = {} +/** + * Performance debugging. tic() starts a timer, toc() traces the time since tic() + * @param name + */ +export function tic(name = 'default'): void { + ticCache[name] = Date.now() +} +export function toc(name = 'default', logStr?: string | Promise[]): number | undefined { + if (_.isArray(logStr)) { + _.each(logStr, (promise, i) => { + promise + .then((result) => { + toc(name, 'Promise ' + i) + return result + }) + .catch((e) => { + throw e + }) + }) + } else { + const t: number = Date.now() - ticCache[name] + if (logStr) logger.info('toc: ' + name + ': ' + logStr + ': ' + t) + return t + } +} + +// /** +// * Make Meteor.startup support async functions +// */ +// export function MeteorStartupAsync(fcn: () => Promise): void { +// Meteor.startup(() => waitForPromise(fcn())) +// } + +// /** +// * Make Meteor.wrapAsync a bit more type safe +// * The original version makes the callback be after the last non-undefined parameter, rather than after or replacing the last parameter. +// * Which makes it incredibly hard to find without iterating over all the parameters. This does that for you, so you dont need to check as many places +// */ +// export function MeteorWrapAsync(func: Function, context?: Object): any { +// // A variant of Meteor.wrapAsync to fix the bug +// // https://github.com/meteor/meteor/issues/11120 + +// return Meteor.wrapAsync((...args: any[]) => { +// // Find the callback-function: +// for (let i = args.length - 1; i >= 0; i--) { +// if (typeof args[i] === 'function') { +// if (i < args.length - 1) { +// // The callback is not the last argument, make it so then: +// const callback = args[i] +// const fixedArgs = args +// fixedArgs[i] = undefined +// fixedArgs.push(callback) + +// func.apply(context, fixedArgs) +// return +// } else { +// // The callback is the last argument, that's okay +// func.apply(context, args) +// return +// } +// } +// } +// throw new Meteor.Error(500, `Error in MeteorWrapAsync: No callback found!`) +// }) +// } + +export type Awaited = T extends PromiseLike ? Awaited : T + +// /** +// * Convert a promise to a "synchronous" Fiber function +// * Makes the Fiber wait for the promise to resolve, then return the value of the promise. +// * If the fiber rejects, the function in the Fiber will "throw" +// */ +// export const waitForPromise: (p: Promise | T) => Awaited = Meteor.wrapAsync(function waitForPromise( +// p: Promise | T, +// cb: (err: any | null, result?: any) => Awaited +// ) { +// if (Meteor.isClient) throw new Meteor.Error(500, `waitForPromise can't be used client-side`) +// if (cb === undefined && typeof p === 'function') { +// cb = p as any +// p = undefined as any +// } + +// Promise.resolve(p) +// .then((result) => { +// cb(null, result) +// }) +// .catch((e) => { +// cb(e) +// }) +// }) as (p: Promise | T) => Awaited // `wrapAsync` has opaque `Function` type +// /** +// * Convert a Fiber function into a promise +// * Makes the Fiber function to run in its own fiber and return a promise +// */ +// export async function makePromise(fcn: () => T): Promise { +// const p = new Promise((resolve, reject) => { +// Meteor.defer(() => { +// try { +// resolve(fcn()) +// } catch (e) { +// reject(e) +// } +// }) +// }) + +// return ( +// await Promise.all([ +// p, +// // Pause the current Fiber briefly, in order to allow for the deferred Fiber to start executing: +// sleep(0), +// ]) +// )[0] +// } + +export function deferAsync(fcn: () => Promise): void { + Meteor.defer(() => { + fcn().catch((e) => logger.error(stringifyError(e))) + }) +} + +/** + * Replaces all invalid characters in order to make the path a valid one + * @param path + */ +export function fixValidPath(path: string): string { + return path.replace(/([^a-z0-9_.@()-])/gi, '_') +} + +/** + * Thanks to https://github.com/Microsoft/TypeScript/issues/23126#issuecomment-395929162 + */ +export type OptionalPropertyNames = { + [K in keyof T]-?: undefined extends T[K] ? K : never +}[keyof T] +export type RequiredPropertyNames = { + [K in keyof T]-?: undefined extends T[K] ? never : K +}[keyof T] +export type OptionalProperties = Pick> +export type RequiredProperties = Pick> + +export type Diff = T extends U ? never : T // Remove types from T that are assignable to U +export type KeysByType = Diff< + { + [K in keyof TObj]: TObj[K] extends TVal ? K : never + }[keyof TObj], + undefined +> + +/** + * Returns the difference between object A and B + */ +type Difference = Pick>> +/** + * Somewhat like _.extend, but with strong types & mandated additional properties + * @param original Object to be extended + * @param extendObj properties to add + */ +export function extendMandadory(original: A, extendObj: Difference & Partial): B { + return _.extend(original, extendObj) +} + +export function trimIfString(value: T): T | string { + if (_.isString(value)) return value.trim() + return value +} + +export function firstIfArray(value: T | T[] | null | undefined): T | null | undefined +export function firstIfArray(value: T | T[] | null): T | null +export function firstIfArray(value: T | T[] | undefined): T | undefined +export function firstIfArray(value: T | T[]): T +export function firstIfArray(value: unknown): T { + return _.isArray(value) ? _.first(value) : value +} + +/** + * Wait for specified time + * @param time + */ +export async function sleep(ms: number): Promise { + return new Promise((resolve) => Meteor.setTimeout(resolve, ms)) +} + +export function isPromise(val: unknown): val is Promise { + const val0 = val as any + return _.isObject(val0) && typeof val0.then === 'function' && typeof val0.catch === 'function' +} + +/** + * This is a fast, shallow compare of two Sets. + * + * **Note**: This is a shallow compare, so it will return false if the objects in the arrays are identical, but not the same. + * + * @param a + * @param b + */ +export function equalSets(a: Set, b: Set): boolean { + if (a === b) return true + if (a.size !== b.size) return false + for (const val of a.values()) { + if (!b.has(val)) return false + } + return true +} + +/** + * This is a fast, shallow compare of two arrays that are used as unsorted lists. The ordering of the elements is ignored. + * + * **Note**: This is a shallow compare, so it will return false if the objects in the arrays are identical, but not the same. + * + * @param a + * @param b + */ +export function equivalentArrays(a: T[], b: T[]): boolean { + if (a === b) return true + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!b.includes(a[i])) return false + } + return true +} + +/** + * This is a fast, shallow compare of two arrays of the same type. + * + * **Note**: This is a shallow compare, so it will return false if the objects in the arrays are identical, but not the same. + * @param a + * @param b + */ +export function equalArrays(a: T[], b: T[]): boolean { + if (a === b) return true + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (b[i] !== a[i]) return false + } + return true +} + +/** Generate the translation for a string, to be applied later when it gets rendered */ +export function generateTranslation( + key: string, + args?: { [k: string]: any }, + namespaces?: string[] +): ITranslatableMessage { + return { + key, + args, + namespaces, + } +} + +export enum LogLevel { + SILLY = 'silly', + DEBUG = 'debug', + VERBOSE = 'verbose', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', + NONE = 'crit', +} + +export enum LocalStorageProperty { + STUDIO = 'studioMode', + CONFIGURE = 'configureMode', + DEVELOPER = 'developerMode', + TESTING = 'testingMode', + SPEAKING = 'speakingMode', + VIBRATING = 'vibratingMode', + SERVICE = 'serviceMode', + SHELF_FOLLOWS_ON_AIR = 'shelfFollowsOnAir', + SHOW_HIDDEN_SOURCE_LAYERS = 'showHiddenSourceLayers', + IGNORE_PIECE_CONTENT_STATUS = 'ignorePieceContentStatus', + UI_ZOOM_LEVEL = 'uiZoomLevel', + HELP_MODE = 'helpMode', + LOG_NOTIFICATIONS = 'logNotifications', + PROTO_ONE_PART_PER_LINE = 'proto:onePartPerLine', +} + +/** + * This just looks like a ReactiveVar, but is not reactive. + * It's used to use the same interface/typings, but when code is run on both client and server side. + * */ +export class DummyReactiveVar implements ReactiveVar { + constructor(private value: T) {} + public get(): T { + return this.value + } + public set(newValue: T): void { + this.value = newValue + } +} + +export function ensureHasTrailingSlash(input: string | null): string | undefined { + if (input) { + return input.endsWith('/') ? input : input + '/' + } else { + return undefined + } +} diff --git a/packages/webui/src/lib/logging.ts b/packages/webui/src/lib/logging.ts new file mode 100644 index 0000000000..57143093b7 --- /dev/null +++ b/packages/webui/src/lib/logging.ts @@ -0,0 +1,110 @@ +import { Meteor } from 'meteor/meteor' +import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { MeteorApply } from './MeteorApply' + +export const LOGGER_METHOD_NAME = 'logger' + +export interface LoggerInstanceFixed { + error: LeveledLogMethodFixed + warn: LeveledLogMethodFixed + help: LeveledLogMethodFixed + data: LeveledLogMethodFixed + info: LeveledLogMethodFixed + debug: LeveledLogMethodFixed + prompt: LeveledLogMethodFixed + verbose: LeveledLogMethodFixed + input: LeveledLogMethodFixed + silly: LeveledLogMethodFixed + + emerg: LeveledLogMethodFixed + alert: LeveledLogMethodFixed + crit: LeveledLogMethodFixed + warning: LeveledLogMethodFixed + notice: LeveledLogMethodFixed +} +type Winston_LogCallback = (error?: any, level?: string, msg?: string, meta?: any) => void +export interface LeveledLogMethodFixed { + (msg: any, callback: Winston_LogCallback): LoggerInstanceFixed + (msg: any, meta: any, callback: Winston_LogCallback): LoggerInstanceFixed + (msg: any, ...meta: any[]): LoggerInstanceFixed +} + +let logger: LoggerInstanceFixed +if (Meteor.isServer) { + const getLogMethod = (type: string) => { + return (...args: any[]) => { + const stringifiedArgs: string[] = args.map((arg) => { + return stringifyError(arg) + }) + + Meteor.call(LOGGER_METHOD_NAME, type, ...stringifiedArgs) + return logger + } + } + + logger = { + error: getLogMethod('error'), + warn: getLogMethod('warn'), + help: getLogMethod('help'), + data: getLogMethod('data'), + info: getLogMethod('info'), + debug: getLogMethod('debug'), + prompt: getLogMethod('prompt'), + verbose: getLogMethod('verbose'), + input: getLogMethod('input'), + silly: getLogMethod('silly'), + + emerg: getLogMethod('emerg'), + alert: getLogMethod('alert'), + crit: getLogMethod('crit'), + warning: getLogMethod('warn'), + notice: getLogMethod('notice'), + } +} else { + const getLogMethod = (type: string) => { + return (...args: any[]) => { + console.log(type, ...args) + + if (type === 'error' || type === 'warn' || type === 'info') { + // Also send log entry to server, for logging: + const stringifiedArgs: string[] = args.map((arg) => { + return stringifyError(arg) + }) + MeteorApply(LOGGER_METHOD_NAME, [type, `Client ${type}`, ...stringifiedArgs]).catch(console.error) + return logger + } + return logger + } + } + + const noop = (_type: string) => { + // do nothing + return logger + } + + logger = { + error: getLogMethod('error'), + warn: getLogMethod('warn'), + help: getLogMethod('help'), + data: getLogMethod('data'), + info: getLogMethod('info'), + debug: getLogMethod('debug'), + prompt: getLogMethod('prompt'), + verbose: getLogMethod('verbose'), + input: getLogMethod('input'), + silly: getLogMethod('silly'), + + emerg: getLogMethod('emerg'), + alert: getLogMethod('alert'), + crit: getLogMethod('crit'), + warning: getLogMethod('warn'), + notice: getLogMethod('notice'), + } + if (localStorage && localStorage.getItem('developerMode') !== '1') { + // not in developerMode, don't log everything then: + logger.debug = noop + logger.silly = noop + } +} + +export { logger } diff --git a/packages/webui/src/lib/main.ts b/packages/webui/src/lib/main.ts new file mode 100644 index 0000000000..9dee266e55 --- /dev/null +++ b/packages/webui/src/lib/main.ts @@ -0,0 +1,20 @@ +/** + * This file is the entry-point for both server side and client side code + */ + +import './collections/Buckets' +import './collections/CoreSystem' +import './collections/Evaluations' +import './collections/ExpectedPackages' +import './collections/Organization' +import './collections/PartInstances' +import './collections/RundownLayouts' +import './collections/Snapshots' +import './collections/Studios' +import './collections/Timeline' +import './collections/UserActionsLog' +import './collections/Users' +import './collections/TriggeredActions' + +import './Settings' +import './systemTime' diff --git a/packages/webui/src/lib/memoizedIsolatedAutorun.ts b/packages/webui/src/lib/memoizedIsolatedAutorun.ts new file mode 100644 index 0000000000..9d38c39da1 --- /dev/null +++ b/packages/webui/src/lib/memoizedIsolatedAutorun.ts @@ -0,0 +1,84 @@ +import { Meteor } from 'meteor/meteor' +import { Tracker } from 'meteor/tracker' +import _ from 'underscore' + +const isolatedAutorunsMem: { + [key: string]: { + dependancy: Tracker.Dependency + value: any + } +} = {} + +/** + * Create a reactive computation that will be run independently of the outer one. If the same function (using the same + * name and parameters) will be used again, this computation will only be computed once on invalidation and it's + * result will be memoized and reused on every other call. + * + * The function will be considered "same", if `functionName` and `params` match. + * + * If the `fnc` computation is invalidated, the outer computations will only be invalidated if the value returned from + * `fnc` fails a deep equality check (_.isEqual). + * + * If used in server code, thie `fnc` will be run as-is, without any reactivity + * + * @export + * @template T + * @param {T} fnc The computation function to be memoized and calculated separately from the outer one. + * @param {string} functionName The name of this computation function + * @param {...Parameters} params Params `fnc` depends on from the outer scope. All parameters will be passed through to the function. + * @return {*} {ReturnType} + */ +export function memoizedIsolatedAutorun any>( + fnc: T, + functionName: string, + ...params: Parameters +): ReturnType { + if (Meteor.isServer) { + return fnc(...(params as any)) + } + + function hashFncAndParams(fName: string, p: any): string { + return fName + '_' + JSON.stringify(p) + } + + let result: ReturnType + const fId = hashFncAndParams(functionName, params) + // const _parentComputation = Tracker.currentComputation + if (isolatedAutorunsMem[fId] === undefined) { + const dep = new Tracker.Dependency() + dep.depend() + const computation = Tracker.nonreactive(() => { + const computation = Tracker.autorun(() => { + result = fnc(...(params as any)) + + const oldValue = isolatedAutorunsMem[fId] && isolatedAutorunsMem[fId].value + + isolatedAutorunsMem[fId] = { + dependancy: dep, + value: result, + } + + if (Tracker.currentComputation && !Tracker.currentComputation.firstRun) { + if (!_.isEqual(oldValue, result)) { + dep.changed() + } + } + }) + computation.onStop(() => { + delete isolatedAutorunsMem[fId] + }) + return computation + }) + const gc = Meteor.setInterval(() => { + if (!dep.hasDependents()) { + Meteor.clearInterval(gc) + computation.stop() + } + }, 5000) + } else { + result = isolatedAutorunsMem[fId].value + isolatedAutorunsMem[fId].dependancy.depend() + } + // @ts-expect-error it is assigned by the tracker + return result +} diff --git a/packages/webui/src/lib/mos.ts b/packages/webui/src/lib/mos.ts new file mode 100644 index 0000000000..d84c789977 --- /dev/null +++ b/packages/webui/src/lib/mos.ts @@ -0,0 +1,4 @@ +import * as MOS from '@mos-connection/helper' + +export const MOS_DATA_IS_STRICT = true +export const mosTypes = MOS.getMosTypes(MOS_DATA_IS_STRICT) diff --git a/packages/webui/src/lib/notifications/notifications.ts b/packages/webui/src/lib/notifications/notifications.ts new file mode 100644 index 0000000000..aa2776acf9 --- /dev/null +++ b/packages/webui/src/lib/notifications/notifications.ts @@ -0,0 +1,590 @@ +import { ReactiveVar } from 'meteor/reactive-var' +import * as _ from 'underscore' +import { Tracker } from 'meteor/tracker' +import { Meteor } from 'meteor/meteor' +import { EventEmitter } from 'events' +import { + Time, + ProtectedString, + unprotectString, + isProtectedString, + protectString, + assertNever, + getRandomString, + LocalStorageProperty, +} from '../lib' +import { isTranslatableMessage, ITranslatableMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' +import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' +import { MeteorCall } from '../api/methods' +import { RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' + +let reportNotificationsId: string | null = null + +export function getReportNotifications(): string | null { + return reportNotificationsId +} + +export function setReportNotifications(id: string | null): void { + reportNotificationsId = id +} + +Meteor.startup(() => { + if (!Meteor.isClient) return + + reportNotificationsId = localStorage.getItem(LocalStorageProperty.LOG_NOTIFICATIONS) +}) + +/** + * Priority level for Notifications. + * + * @export + * @enum {number} + */ +export enum NoticeLevel { + /** Highest priority notification. Subject matter will affect operations. */ + CRITICAL = 0b0001, // 1 + /** High priority notification. Operations will not be affected, but non-critical functions may be affected or the result may be undesirable. */ + WARNING = 0b0010, // 2 + /** Confirmation of a successful operation and general informations. */ + NOTIFICATION = 0b0100, // 4 + /** Tips to the user */ + TIP = 0b1000, // 8 +} + +/** + * An action object interface defining actions that the user can take on an action + * + * @export + * @interface NotificationAction + */ +export interface NotificationAction { + /** User-presented string label on the action button */ + label: string + /** Action type. If set to 'default', will attach this action to a click on the notification. */ + type: string // for a default, use 'default' + /** Icon shown on the action button. */ + icon?: any + /** The method that will be called when the user takes the aciton. */ + action?: (e: any) => void + /** If true, will disable the action (ie the button will show, but not clickable). */ + disabled?: boolean +} + +/** A source of notifications */ +export type Notifier = () => NotificationList + +const notifiers: { [index: string]: NotifierHandle } = {} + +const notifications: { [index: string]: Notification } = {} +const notificationsDep: Tracker.Dependency = new Tracker.Dependency() + +interface NotificationHandle { + id: string + stop: () => void +} + +/** + * A reactive list of notifications, produced by a Notifier. + * + * @export + * @class NotificationList + * @extends {ReactiveVar} + */ +export class NotificationList extends ReactiveVar {} + +/** + * A handle object to a registered notifier. + * + * @export + * @class NotifierObject + */ +export class NotifierHandle { + id: string + source: Notifier + handle: Tracker.Computation + result: Array = [] + + /** + * Creates an instance of NotifierHandle. Used internally by the Notification Center singleton. + * @param {string} notifierId + * @param {Notifier} source + * @memberof NotifierHandle + */ + constructor(notifierId: string, source: Notifier) { + this.id = notifierId + this.source = source + this.handle = Tracker.nonreactive(() => { + return Tracker.autorun(() => { + this.result = source().get() + notificationsDep.changed() + }) + }) as any as Tracker.Computation + + notifiers[notifierId] = this + } + + /** + * Stop notifications from this notifier. + * + * @memberof NotifierHandle + */ + stop(): void { + this.handle.stop() + + delete notifiers[this.id] + + notificationsDep.changed() + } +} + +type NotificationsSource = RundownId | SegmentId | string | undefined +/** + * Singleton handling all the notifications. + * + * @class NotificationCenter0 + */ +class NotificationCenter0 { + /** Default notification timeout for non-persistent notifications */ + private readonly NOTIFICATION_TIMEOUT = 5000 + /** The highlighted source of notifications */ + private highlightedSource: ReactiveVar + /** The highlighted level of highlighted level */ + private highlightedLevel: ReactiveVar + + private _isOpen = false + + /** In concentration mode, non-Critical notifications will be snoozed automatically */ + private _isConcentrationMode = false + + constructor() { + this.highlightedSource = new ReactiveVar(undefined) + this.highlightedLevel = new ReactiveVar(NoticeLevel.TIP) + + const notifLogUserId = getReportNotifications() + if (notifLogUserId) { + let oldNotificationIds: string[] = [] + Tracker.autorun(() => { + const newNotifIds = this.getNotificationIDs() + const oldNotifIds = new Set(oldNotificationIds) + + newNotifIds + .filter((id) => !oldNotifIds.has(id)) + .forEach((id) => { + const notification = notifications[id] + + if (notification && !notification.snoozed) { + const message = isTranslatableMessage(notification.message) + ? notification.message.key + : typeof notification.message === 'string' + ? notification.message + : '[React Element]' + + MeteorCall.client + .clientLogNotification( + notification.created, + notifLogUserId, + notification.status, + message, + notification.source + ) + .catch((e) => { + console.log(e) + }) + } + }) + + oldNotificationIds = newNotifIds + }) + } + } + + get isConcentrationMode(): boolean { + return this._isConcentrationMode + } + + set isConcentrationMode(value: boolean) { + this._isConcentrationMode = value + + if (value) + NotificationCenter.snoozeAll( + { + status: NoticeLevel.TIP, + }, + { + status: NoticeLevel.NOTIFICATION, + }, + { + status: NoticeLevel.WARNING, + } + ) + } + + get isOpen(): boolean { + return this._isOpen + } + + set isOpen(value: boolean) { + this._isOpen = value + + if (value) NotificationCenter.snoozeAll() + } + + /** + * Register a notifier in the Notification center. + * + * @param {Notifier} source The notifier to be registered. + * @returns {NotifierHandle} The handler than can be used to unregister a notifier. + * @memberof NotificationCenter0 + */ + registerNotifier(source: Notifier): NotifierHandle { + const notifierId = getRandomString() + + return new NotifierHandle(notifierId, source) + } + + /** + * Push a single-use notification into the Notification Center. + * + * @param {Notification} notice The notification to be added. + * @returns {NotificationHandle} The handler that can be used to drop the notification. + * @memberof NotificationCenter0 + */ + push(notice: Notification): NotificationHandle { + const id = notice.id || getRandomString() + notifications[id] = notice + notice.id = id + notificationsDep.changed() + + if (!notice.persistent) { + this.timeout(notice) + } + + if (!notice.snoozed && this._isOpen) { + notice.snooze() + } + if (!notice.snoozed && this._isConcentrationMode) { + if (notice.status !== NoticeLevel.CRITICAL && notice.timeout === undefined && notice.persistent === true) { + notice.snooze() + } + } + + return { + id, + stop: () => { + this.drop(id) + }, + } + } + + /** + * Remove a notification from the Notification Center + * + * @param {string} id The ID of a notification + * @memberof NotificationCenter0 + */ + drop(id: string): void { + if (notifications[id]) { + notifications[id].emit('dropped') + delete notifications[id] + notificationsDep.changed() + } else { + throw new Meteor.Error(404, `Notification "${id}" could not be found in the Notification Center`) + } + } + + /** + * Get a reactive array of notificaitons in the Notification Center + * + * @returns {Array} + * @memberof NotificationCenter0 + */ + getNotifications(): Array { + notificationsDep.depend() + + return _.flatten( + Object.values(notifiers) + .map((item) => { + item.result.forEach((i) => { + if (this._isOpen && !i.snoozed) i.snooze() + if ( + this._isConcentrationMode && + !i.snoozed && + i.status !== NoticeLevel.CRITICAL && + i.timeout === undefined && + i.persistent === true + ) { + i.snooze() + } + }) + return item.result + }) + .concat(Object.values(notifications)) + ) + } + + /** + * Get a reactive array of notificaiton id's in the Notification Center + * + * @returns {Array} + * @memberof NotificationCenter0 + */ + getNotificationIDs(): Array { + notificationsDep.depend() + + return Object.keys(notifications) + } + + /** + * Get a reactive number of notifications in the Notification Center + * + * @returns {number} + * @memberof NotificationCenter0 + */ + count(filter?: NoticeLevel): number { + notificationsDep.depend() + + // return ( + // Object.values(notifiers) + // .map((item) => (item.result || []).length) + // .reduce((a, b) => a + b, 0) + Object.values(notifications).length + // ) + if (filter === undefined) { + return ( + Object.values(notifiers).reduce((a, b) => a + (b.result || []).length, 0) + + Object.values(notifications).length + ) + } else { + return ( + Object.values(notifiers).reduce( + (a, b) => a + (b.result || []).filter((item) => (item.status & filter) !== 0).length, + 0 + ) + Object.values(notifications).filter((item) => (item.status & filter) !== 0).length + ) + } + } + + /** + * Dismiss all notifications in the Notification Center + * + * @memberof NotificationCenter0 + */ + snoozeAll(...filters: Partial[]) { + let n = this.getNotifications() + if (filters && filters.length) { + const matchers = filters.map((filter) => _.matches(filter)) + n = n.filter((v, _index, _array) => matchers.map((m) => m(v)).reduce((value, memo) => value || memo, false)) + } + n.forEach((item) => item.snooze()) + } + + /** + * Highlight all notifications from a given source at a given notification level + * + * @param {(string | undefined)} source + * @param {NoticeLevel} level + * @memberof NotificationCenter0 + */ + highlightSource(source: SegmentId | undefined, level: NoticeLevel) { + this.highlightedSource.set(source) + this.highlightedLevel.set(level) + } + + /** + * Get the highlighted source ID + * + * @returns + * @memberof NotificationCenter0 + */ + getHighlightedSource() { + return this.highlightedSource.get() + } + + /** + * Get the highlighted level + * + * @returns + * @memberof NotificationCenter0 + */ + getHighlightedLevel() { + return this.highlightedLevel.get() + } + + /** + * Timeout the notification once the notification timeout elapses. + * + * @param {Notification} notice + * @memberof NotificationCenter0 + */ + private timeout(notice: Notification): void { + Meteor.setTimeout(() => { + if (notice) { + const id = notice.id + if (id && notifications[id]) { + this.drop(id) + } + } + }, notice.timeout || this.NOTIFICATION_TIMEOUT) + } +} + +export const NotificationCenter = new NotificationCenter0() + +/** + * A Notification that can be presented to the user + * + * @export + * @class Notification + * @extends {EventEmitter} + */ +export class Notification extends EventEmitter { + id: string | undefined + status: NoticeLevel + message: string | React.ReactElement | ITranslatableMessage | null + source: NotificationsSource + persistent?: boolean + timeout?: number + snoozed?: boolean + actions?: Array + created: Time + rank: number + + constructor( + id: string | ProtectedString | undefined, + status: NoticeLevel, + message: string | React.ReactElement | ITranslatableMessage | null, + source: NotificationsSource, + created?: Time, + persistent?: boolean, + actions?: Array, + rank?: number, + timeout?: number + ) { + super() + + this.id = isProtectedString(id) ? unprotectString(id) : id + this.status = status + this.message = message + this.source = source + this.persistent = persistent || false + this.actions = actions || undefined + this.created = created || Date.now() + this.rank = rank || 0 + this.timeout = timeout + } + + /** + * Check if two notifications are equal + * + * @static + * @param {(Notification | undefined)} a + * @param {(Notification | undefined)} b + * @returns {boolean} + * @memberof Notification + */ + static isEqual(a: Notification | undefined, b: Notification | undefined): boolean { + if (typeof a !== typeof b) return false + return _.isEqual( + _.omit(a, ['created', 'snoozed', 'actions', '_events']), + _.omit(b, ['created', 'snoozed', 'actions', '_events']) + ) + } + + /** + * Compare two notifications, for use in sorting + * + * @static + * @param {Notification} a + * @param {Notification} b + * @returns {number} + * @memberof Notification + */ + static compare(a: Notification, b: Notification): number { + return ( + (!!a.persistent === !!b.persistent ? 0 : a.persistent && !b.persistent ? 1 : -1) || + a.status - b.status || + a.rank - b.rank || + a.created - b.created + ) + } + + /** + * Dismiss a notification (snooze it, but not remove it) + * + * @memberof Notification + */ + snooze(): void { + this.snoozed = true + notificationsDep.changed() + this.emit('snoozed', this) + } + + /** + * Remove notification from the Notification Center + * + * @memberof Notification + */ + drop(): void { + if (this.id) { + NotificationCenter.drop(this.id) + } + } + + /** + * Callback called by the Notifcation Center when a user takes an action + * + * @param {string} type + * @param {*} event + * @memberof Notification + */ + action(type: string, event: React.SyntheticEvent): void { + this.emit('action', this, type, event) + } +} + +export function getNoticeLevelForPieceStatus(statusCode: PieceStatusCode | undefined): NoticeLevel | null { + switch (statusCode) { + case PieceStatusCode.OK: + case PieceStatusCode.UNKNOWN: + case undefined: + return null + case PieceStatusCode.SOURCE_NOT_SET: + return NoticeLevel.CRITICAL + case PieceStatusCode.SOURCE_MISSING: + case PieceStatusCode.SOURCE_BROKEN: + case PieceStatusCode.SOURCE_UNKNOWN_STATE: + return NoticeLevel.WARNING + case PieceStatusCode.SOURCE_HAS_ISSUES: + case PieceStatusCode.SOURCE_NOT_READY: + return NoticeLevel.NOTIFICATION + default: + assertNever(statusCode) + return NoticeLevel.WARNING + } +} + +Meteor.startup(() => { + if (!Meteor.isClient) return + + const windowAny: any = window + + windowAny['testNotification'] = function ( + delay: number, + level: NoticeLevel = NoticeLevel.CRITICAL, + fakePersistent = false + ) { + NotificationCenter.push( + new Notification( + undefined, + level, + 'Notification test', + protectString('test'), + undefined, + fakePersistent, + undefined, + 100000, + delay || 10000 + ) + ) + } + windowAny['notificationCenter'] = NotificationCenter +}) diff --git a/packages/webui/src/lib/reactiveMap.ts b/packages/webui/src/lib/reactiveMap.ts new file mode 100644 index 0000000000..67ec848e42 --- /dev/null +++ b/packages/webui/src/lib/reactiveMap.ts @@ -0,0 +1,38 @@ +import { Tracker } from 'meteor/tracker' + +export class ReactiveMap { + private baseMap = new Map() + private dependencyMap = new Map() + private globalDependency = new Tracker.Dependency() + + set(key: string, value: T): void { + const prevVal = this.baseMap.get(key) + this.baseMap.set(key, value) + if (this.dependencyMap.has(key) && prevVal !== value) { + this.dependencyMap.get(key)?.changed() + } else { + this.dependencyMap.set(key, new Tracker.Dependency()) + } + if (prevVal !== value) this.globalDependency.changed() + } + + get(key: string): T | undefined { + if (this.dependencyMap.has(key)) { + this.dependencyMap.get(key)?.depend() + } else { + const dependency = new Tracker.Dependency() + dependency?.depend() + this.dependencyMap.set(key, dependency) + } + return this.baseMap.get(key) + } + + getAll(): { [key: string]: T } { + const result: { [key: string]: T } = {} + for (const [key, value] of this.baseMap.entries()) { + result[key] = value + } + this.globalDependency.depend() + return result + } +} diff --git a/packages/webui/src/lib/systemTime.ts b/packages/webui/src/lib/systemTime.ts new file mode 100644 index 0000000000..6039e8195e --- /dev/null +++ b/packages/webui/src/lib/systemTime.ts @@ -0,0 +1,139 @@ +import { Meteor } from 'meteor/meteor' +import { logger } from './logging' +import { getCurrentTime, systemTime } from './lib' +import { MeteorCall } from './api/methods' + +/** How often the client should sync its time to the server [ms] */ +const SYNC_TIME = 5 * 60 * 1000 // 5 minutes + +/** How much we assume a clock to drift over time [time unit per time unit] */ +const ASSUMED_CLOCK_DRIFT = 5 / (3600 * 24) // We assume 5 seconds drift in a day for the system clock + +/** How good time sync quality we should strive for [ms] */ +const TARGET_TIME_SYNC_QUALITY = 50 // 50 milliseconds + +/** How often we should check if its time has jumped or been skewed [ms] */ +const JUMP_CHECK_INTERVAL = 10 * 1000 // 10 seconds + +export class TimeJumpDetector { + private wallTime: number = TimeJumpDetector.getWallTime() + private monotonicTime: number = TimeJumpDetector.getMonotonicTime() + + constructor(private jumpCheckInterval: number, private onJumpDetected: (syncDiff: number) => void) {} + + public start(): void { + Meteor.setInterval(() => { + if (Meteor.isServer || Meteor.status().connected) { + this.detectTimeJump() + } + }, this.jumpCheckInterval) + } + + /** Returns the actual time of the OS, which could be influenced (jump) by an NTP sync. */ + private static getWallTime() { + return Date.now() + } + + /** Returns a Monotonic Time, which is not influenced by any NTP-sync */ + private static getMonotonicTime() { + return Meteor.isServer ? Number(process.hrtime.bigint() / BigInt(1000000)) : performance.now() + } + + private detectTimeJump() { + const wallTime = TimeJumpDetector.getWallTime() + const monotonicTime = TimeJumpDetector.getMonotonicTime() + const currentDiff = wallTime - monotonicTime + const previousDiff = this.wallTime - this.monotonicTime + const syncDiff = currentDiff - previousDiff + if (Math.abs(syncDiff) > TARGET_TIME_SYNC_QUALITY) { + this.wallTime = wallTime + this.monotonicTime = monotonicTime + this.onJumpDetected(syncDiff) + } + } +} + +if (Meteor.isServer) { + // handled in systemTime, but we want to log jumps anyway + if (!Meteor.isTest) { + Meteor.startup(() => { + const timeJumpDetector = new TimeJumpDetector(JUMP_CHECK_INTERVAL * 6, (syncDiff) => { + logger.warn(`Time jump or skew of ${Math.round(syncDiff)} ms detected`) + // But we're not doing anything more + // TODO: Should we trigger peripheralDevices to resync? And clients? + }) + timeJumpDetector.start() + }) + } +} else { + // fetch time from server: + const updateDiffTime = (force?: boolean) => { + // Calculate an "adjusted standard deviation", something that increases over time. + // Using this we can decide wether a new measurement is better or worse than previous one. (the lower, the better) + const currentSyncDegradation = + systemTime.stdDev + (performance.now() - systemTime.lastSync) * ASSUMED_CLOCK_DRIFT + + // If the sync is assumed to have degraded enough, it's time to resync + if (currentSyncDegradation > TARGET_TIME_SYNC_QUALITY || force) { + const sentTime = performance.now() + MeteorCall.peripheralDevice + .getTimeDiff() + .then((stat) => { + const replyTime = performance.now() + + const diff = Math.round(Date.now() + (sentTime - replyTime) / 2 - stat.currentTime) + const stdDev = Math.abs(Math.round(sentTime - replyTime)) / 2 // Not really a standard deviation calculation, but it's what we can do with just one measuring point.. + + // Only use the result if the stdDev is better than previous sync quality: + if (stdDev <= currentSyncDegradation || force) { + // Only trace the result if the diff is different than the previous: + if (Math.abs(systemTime.diff - diff) > 10) { + logger.verbose( + `Time diff set to: ${diff} ms (server stdDev: ${ + Math.floor(stat.stdDev * 10) / 10 + } ms, client stdDev: ${stdDev} ms)` + ) + } + + // Store the result into the global variable `systemTime` (used in getCurrentTime()): + systemTime.diff = diff + systemTime.stdDev = stdDev + systemTime.lastSync = performance.now() + systemTime.hasBeenSet = true + systemTime.timeOriginDiff = getCurrentTime() - (performance.timeOrigin + performance.now()) + } + }) + .catch((err) => { + logger.error(err) + }) + } + } + + Meteor.startup(() => { + // Run it once right away: + updateDiffTime() + + // Also run it a few seconds in, to get a more accurate reading: + Meteor.setTimeout(() => { + updateDiffTime() + }, 5000) + + // Also run it after 30 seconds, to get an even more accurate reading: + Meteor.setTimeout(() => { + updateDiffTime() + }, 30 * 1000) + + // Then run it on an interval, to ensure it is kept up to date: + Meteor.setInterval(() => { + if (Meteor.status().connected) { + updateDiffTime() + } + }, SYNC_TIME) + + const timeJumpDetector = new TimeJumpDetector(JUMP_CHECK_INTERVAL, (syncDiff) => { + logger.verbose(`Time jump or skew of ${Math.round(syncDiff)} ms detected, resyncing with server.`) + updateDiffTime(true) + }) + timeJumpDetector.start() + }) +} diff --git a/packages/webui/src/lib/tv2/AHKkeyboardMap.ts b/packages/webui/src/lib/tv2/AHKkeyboardMap.ts new file mode 100644 index 0000000000..71af9f8486 --- /dev/null +++ b/packages/webui/src/lib/tv2/AHKkeyboardMap.ts @@ -0,0 +1,75 @@ +import * as _ from 'underscore' + +export const AHKKeyboardMap: Record = { + '½': ['SC029', '{vkDCsc029}'], + f1: ['F1', '{F1}'], + f2: ['F2', '{F2}'], + f3: ['F3', '{F3}'], + f4: ['F4', '{F4}'], + f5: ['F5', '{F5}'], + f6: ['F6', '{F6}'], + f7: ['F7', '{F7}'], + f8: ['F8', '{F8}'], + f9: ['F9', '{F9}'], + f10: ['F10', '{F10}'], + f11: ['F11', '{F11}'], + f12: ['F12', '{F12}'], + '!': ['!', '{!}'], + '#': ['#', '{#}'], + add: ['+', '{+}'], + comma: ['SC033', '{,}'], + period: '.', + '^': ['^', '{^}'], + '{': ['{', '{{}'], + '}': ['}', '{}}'], + enter: ['Enter', '{Enter}'], + esc: ['Escape', '{Escape}'], + space: ['Space', '{Space}'], + tab: ['Tab', '{Tab}'], + backspace: ['Backspace', '{Backspace}'], + del: ['Delete', '{Delete}'], + ins: ['Insert', '{Insert}'], + up: ['Up', '{Up}'], + down: ['Down', '{Down}'], + left: ['Left', '{Left}'], + right: ['Right', '{Right}'], + home: ['Home', '{Home}'], + end: ['End', '{End}'], + pageup: ['PgUp', '{PgUp}'], + pagedown: ['PgDn', '{PgDn}'], + capslock: ['CapsLock', '{CapsLock}'], + numlock: ['NumLock', '{NumLock}'], + scrolllock: ['ScrollLock', '{ScrollLock}'], + num0: ['Numpad0', '{Numpad0}'], + num1: ['Numpad1', '{Numpad1}'], + num2: ['Numpad0', '{Numpad0}'], + num3: ['Numpad0', '{Numpad0}'], + num4: ['Numpad0', '{Numpad0}'], + num5: ['Numpad0', '{Numpad0}'], + num6: ['Numpad0', '{Numpad0}'], + num7: ['Numpad0', '{Numpad0}'], + num8: ['Numpad0', '{Numpad0}'], + num9: ['Numpad0', '{Numpad0}'], + numadd: ['NumpadAdd', '{NumpadAdd}'], + numsub: ['NumpadSub', '{NumpadSub}'], + nummul: ['NumpadMult', '{NumpadMult}'], + numdiv: ['NumpadDiv', '{NumpadDiv}'], +} + +export const AHKModifierMap: Record = { + ctrl: '^', + shift: '+', + alt: '!', + cmd: '#', +} + +export const AHKBaseHeader = [ + '#NoEnv', + 'SendMode Input', + 'SetWorkingDir %A_ScriptDir%', + '', + '#IfWinActive, ahk_class Chrome_WidgetWin_1', + '', +] + +export const useAHKComboTemplate = _.template('<%=platformKeyCombo%> up:: send <%=browserKeyCombo%>') diff --git a/packages/webui/src/lib/typings/cubic-spline.d.ts b/packages/webui/src/lib/typings/cubic-spline.d.ts new file mode 100644 index 0000000000..ddded4fc4b --- /dev/null +++ b/packages/webui/src/lib/typings/cubic-spline.d.ts @@ -0,0 +1,43 @@ +declare module 'cubic-spline' { + /** + * Creates a cubic spline using an algorithm based on the one described by Ivan Kuckir (@ivankutskir) here: http://blog.ivank.net/interpolation-with-cubic-splines.html + * + * Use: + * ``` + * const Spline = require('cubic-spline'); + + const xs = [1, 2, 3, 4, 5]; + const ys = [9, 3, 6, 2, 4]; + + // new a Spline object + const spline = new Spline(xs, ys); + + // get Y at arbitrary X + console.log(spline.at(1.4)); + + // interpolate a line at a higher resolution + for (let i = 0; i < 50; i++) { + console.log(spline.at(i * 0.1)); + } + ``` + */ + class Spline { + /** + * Creates an instance of Spline. + * @param {number[]} domain The domain of the spline - an array of known input point of the control nodes + * @param {number[]} range The range of the spline - an array of known output values for the corresponding input points + * @memberof Spline + */ + constructor(domain: number[], range: number[]) + /** + * Calculates the value of the spline at the given input point. + * + * @param {number} input Any real number, will interpolate/extrapolate depending on if this is within the domain or not. + * @return {*} {number} The interpolated/extrapolated value + * @memberof Spline + */ + public at(input: number): number + } + + export = Spline +} diff --git a/packages/webui/src/lib/typings/react-time-hoc.d.ts b/packages/webui/src/lib/typings/react-time-hoc.d.ts new file mode 100644 index 0000000000..14c7f0cca0 --- /dev/null +++ b/packages/webui/src/lib/typings/react-time-hoc.d.ts @@ -0,0 +1,4 @@ +declare module 'react-timer-hoc' { + function timer(timer: number): (component: T) => T + export default timer +} diff --git a/packages/webui/src/lib/typings/reactivearray.d.ts b/packages/webui/src/lib/typings/reactivearray.d.ts new file mode 100644 index 0000000000..03d04fa6f2 --- /dev/null +++ b/packages/webui/src/lib/typings/reactivearray.d.ts @@ -0,0 +1,30 @@ +declare class ReactiveArray extends Array { + constructor(source?: Array) + + /** + * Return all elements as a plain Javascript array. + */ + array(): Array + + /** + * Returns a reactive source of the array. + */ + list(): Array + + /** + * An alias of list(). + */ + depend(): Array + + /** + * Remove all elements of the array. + */ + clear(): void +} + +declare class ReactiveDict { + constructor(id?: string) + set(key: string, value: T): void + get(key: string): T + equals(key: string, compareValue: T): boolean +} diff --git a/packages/webui/src/lib/typings/webmanifest.d.ts b/packages/webui/src/lib/typings/webmanifest.d.ts new file mode 100644 index 0000000000..023ae26097 --- /dev/null +++ b/packages/webui/src/lib/typings/webmanifest.d.ts @@ -0,0 +1,206 @@ +/* tslint:disable */ +/** + * This file was automatically generated by json-schema-to-typescript and then manually extended to support new + * WebAPIs. See Fugu project: https://fugu-tracker.web.app/ + * Source: https://json.schemastore.org/web-manifest.json + */ + +type BasicDisplayMode = 'fullscreen' | 'standalone' | 'minimal-ui' | 'browser' +type ExtendedDisplayMode = BasicDisplayMode | 'window-controls-overlay' | 'tabbed' + +export interface JSONSchemaForWebApplicationManifestFiles { + /** + * The background_color member describes the expected background color of the web application. + */ + background_color?: string + /** + * The base direction of the manifest. + */ + dir?: 'ltr' | 'rtl' | 'auto' + /** + * The item represents the developer's preferred display mode for the web application. + */ + display?: BasicDisplayMode + /** + * This item represents additional developer's preferred display modes, in order of preference. If none of them are available, display will be used + * See: https://developer.mozilla.org/en-US/docs/Web/Manifest/display_override + */ + display_override?: ExtendedDisplayMode[] + /** + * The icons member is an array of icon objects that can serve as iconic representations of the web application in various contexts. + */ + icons?: ManifestImageResource[] + /** + * The primary language for the values of the manifest. + */ + lang?: string + /** + * The name of the web application. + */ + name?: string + /** + * The orientation member is a string that serves as the default orientation for all top-level browsing contexts of the web application. + */ + orientation?: + | 'any' + | 'natural' + | 'landscape' + | 'portrait' + | 'portrait-primary' + | 'portrait-secondary' + | 'landscape-primary' + | 'landscape-secondary' + /** + * Boolean value that is used as a hint for the user agent to say that related applications should be preferred over the web application. + */ + prefer_related_applications?: boolean + /** + * Array of application accessible to the underlying application platform that has a relationship with the web application. + */ + related_applications?: ExternalApplicationResource[] + /** + * A string that represents the navigation scope of this web application's application context. + */ + scope?: string + /** + * A string that represents a short version of the name of the web application. + */ + short_name?: string + /** + * Array of shortcut items that provide access to key tasks within a web application. + */ + shortcuts?: ShortcutItem[] + /** + * Represents the URL that the developer would prefer the user agent load when the user launches the web application. + */ + start_url?: string + /** + * The theme_color member serves as the default theme color for an application context. + */ + theme_color?: string + /** + * A string that represents the id of the web application. + */ + id?: string + /** + * A list of protocol Web Handlers to be registered when installing this app. + * See: https://web.dev/url-protocol-handler/#how-to-use-url-protocol-handler-registration-for-pwas + */ + protocol_handlers?: ProtocolHandler[] + [k: string]: unknown +} +export interface ManifestImageResource { + /** + * The sizes member is a string consisting of an unordered set of unique space-separated tokens which are ASCII case-insensitive that represents the dimensions of an image for visual media. + */ + sizes?: string | 'any' + /** + * The src member of an image is a URL from which a user agent can fetch the icon's data. + */ + src: string + /** + * The type member of an image is a hint as to the media type of the image. + */ + type?: string + purpose?: + | 'monochrome' + | 'maskable' + | 'any' + | 'monochrome maskable' + | 'monochrome any' + | 'maskable monochrome' + | 'maskable any' + | 'any monochrome' + | 'any maskable' + | 'monochrome maskable any' + | 'monochrome any maskable' + | 'maskable monochrome any' + | 'maskable any monochrome' + | 'any monochrome maskable' + | 'any maskable monochrome' + [k: string]: unknown +} +export interface ExternalApplicationResource { + /** + * The platform it is associated to. + */ + platform: 'chrome_web_store' | 'play' | 'itunes' | 'windows' + /** + * The URL where the application can be found. + */ + url?: string + /** + * Information additional to the URL or instead of the URL, depending on the platform. + */ + id?: string + /** + * Information about the minimum version of an application related to this web app. + */ + min_version?: string + /** + * An array of fingerprint objects used for verifying the application. + */ + fingerprints?: { + type?: string + value?: string + [k: string]: unknown + }[] + [k: string]: unknown +} +/** + * A shortcut item represents a link to a key task or page within a web app. A user agent can use these values to assemble a context menu to be displayed by the operating system when a user engages with the web app's icon. + */ +export interface ShortcutItem { + /** + * A string that represents the id of the shortcut item + */ + id?: string + /** + * The name member of a shortcut item is a string that represents the name of the shortcut as it is usually displayed to the user in a context menu. + */ + name: string + /** + * The short_name member of a shortcut item is a string that represents a short version of the name of the shortcut. It is intended to be used where there is insufficient space to display the full name of the shortcut. + */ + short_name?: string + /** + * The description member of a shortcut item is a string that allows the developer to describe the purpose of the shortcut. + */ + description?: string + /** + * The url member of a shortcut item is a URL within scope of a processed manifest that opens when the associated shortcut is activated. + */ + url: string + /** + * The icons member of a shortcut item serves as iconic representations of the shortcut in various contexts. + */ + icons?: ManifestImageResource[] + [k: string]: unknown +} + +export interface ProtocolHandler { + protocol: + | 'bitcoin' + | 'geo' + | 'im' + | 'irc' + | 'ircs' + | 'magnet' + | 'mailto' + | 'matrix' + | 'mms' + | 'news' + | 'nntp' + | 'openpgp4fpr' + | 'sip' + | 'sms' + | 'smsto' + | 'ssh' + | 'tel' + | 'urn' + | 'webcal' + | 'wtai' + | 'xmpp' + | `web+${string}` + url: string +} diff --git a/packages/webui/src/lib/userAction.ts b/packages/webui/src/lib/userAction.ts new file mode 100644 index 0000000000..7b24ec3df9 --- /dev/null +++ b/packages/webui/src/lib/userAction.ts @@ -0,0 +1,54 @@ +export enum UserAction { + SAVE_EVALUATION, + ACTIVATE_RUNDOWN_PLAYLIST, + DEACTIVATE_RUNDOWN_PLAYLIST, + CREATE_SNAPSHOT_FOR_DEBUG, + REMOVE_RUNDOWN_PLAYLIST, + REMOVE_RUNDOWN, + RESYNC_RUNDOWN, + RESYNC_RUNDOWN_PLAYLIST, + DISABLE_NEXT_PIECE, + TAKE, + MOVE_NEXT, + ACTIVATE_HOLD, + DEACTIVATE_OTHER_RUNDOWN_PLAYLIST, + RESET_AND_ACTIVATE_RUNDOWN_PLAYLIST, + PREPARE_FOR_BROADCAST, + RESET_RUNDOWN_PLAYLIST, + RELOAD_RUNDOWN_PLAYLIST_DATA, + SET_NEXT, + SET_NEXT_SEGMENT, + TAKE_PIECE, + UNSYNC_RUNDOWN, + SET_IN_OUT_POINTS, + START_ADLIB, + START_GLOBAL_ADLIB, + START_STICKY_PIECE, + START_BUCKET_ADLIB, + CLEAR_SOURCELAYER, + RESTART_MEDIA_WORKFLOW, + ABORT_MEDIA_WORKFLOW, + PRIORITIZE_MEDIA_WORKFLOW, + ABORT_ALL_MEDIA_WORKFLOWS, + PACKAGE_MANAGER_RESTART_WORK, + PACKAGE_MANAGER_RESTART_PACKAGE_CONTAINER, + GENERATE_RESTART_TOKEN, + RESTART_CORE, + USER_LOG_PLAYER_METHOD, + UNKNOWN_ACTION, + CREATE_BUCKET, + REMOVE_BUCKET, + MODIFY_BUCKET, + EMPTY_BUCKET, + INGEST_BUCKET_ADLIB, + REMOVE_BUCKET_ADLIB, + MODIFY_BUCKET_ADLIB, + SWITCH_ROUTE_SET, + SAVE_TO_BUCKET, + RUNDOWN_ORDER_MOVE, + RUNDOWN_ORDER_RESET, + PERIPHERAL_DEVICE_REFRESH_DEBUG_STATES, + ACTIVATE_ADLIB_TESTING, + QUEUE_NEXT_SEGMENT, + CREATE_ADLIB_TESTING_RUNDOWN, +} diff --git a/meteor/client/main.tsx b/packages/webui/src/main.tsx similarity index 74% rename from meteor/client/main.tsx rename to packages/webui/src/main.tsx index e96564ad97..ef2067e763 100644 --- a/meteor/client/main.tsx +++ b/packages/webui/src/main.tsx @@ -1,26 +1,25 @@ -import * as React from 'react' import { Meteor } from 'meteor/meteor' import { createRoot } from 'react-dom/client' import { DndProvider } from 'react-dnd' import { HTML5Backend } from 'react-dnd-html5-backend' -import { SorensenContextProvider } from './lib/SorensenContext' +import { SorensenContextProvider } from './client/lib/SorensenContext' // Import some browser polyfills to handle rare features -import './lib/polyfill/polyfills' +import './client/lib/polyfill/polyfills' -import './ui/i18n' +import './client/ui/i18n' -import '../lib/main' +import './lib/main' // Import files that call Meteor.startup: -import './lib/currentTimeReactive' -import './lib/uncaughtErrorHandler' -import './lib/dev' +import './client/lib/currentTimeReactive' +import './client/lib/uncaughtErrorHandler' +import './client/lib/dev' -import App from './ui/App' -import { logger } from '../lib/logging' -import './lib/logStatus' +import App from './client/ui/App' +import { logger } from './lib/logging' +import './client/lib/logStatus' if ('serviceWorker' in navigator) { // Use the window load event to keep the page load performant diff --git a/packages/webui/src/meteor/allow-deny.js b/packages/webui/src/meteor/allow-deny.js new file mode 100644 index 0000000000..ecf7ab396f --- /dev/null +++ b/packages/webui/src/meteor/allow-deny.js @@ -0,0 +1,534 @@ +import { LocalCollection } from "./minimongo"; +import { Meteor } from "./meteor"; +import EJSON from 'ejson' +import { check, Match } from "./check"; +import { DDP } from './ddp' + +/// +/// Remote methods and access control. +/// + +const hasOwn = Object.prototype.hasOwnProperty; + +// Restrict default mutators on collection. allow() and deny() take the +// same options: +// +// options.insert {Function(userId, doc)} +// return true to allow/deny adding this document +// +// options.update {Function(userId, docs, fields, modifier)} +// return true to allow/deny updating these documents. +// `fields` is passed as an array of fields that are to be modified +// +// options.remove {Function(userId, docs)} +// return true to allow/deny removing these documents +// +// options.fetch {Array} +// Fields to fetch for these validators. If any call to allow or deny +// does not have this option then all fields are loaded. +// +// allow and deny can be called multiple times. The validators are +// evaluated as follows: +// - If neither deny() nor allow() has been called on the collection, +// then the request is allowed if and only if the "insecure" smart +// package is in use. +// - Otherwise, if any deny() function returns true, the request is denied. +// - Otherwise, if any allow() function returns true, the request is allowed. +// - Otherwise, the request is denied. +// +// Meteor may call your deny() and allow() functions in any order, and may not +// call all of them if it is able to make a decision without calling them all +// (so don't include side effects). + +export const AllowDeny = { + CollectionPrototype: {} +}; + +// In the `mongo` package, we will extend Mongo.Collection.prototype with these +// methods +const CollectionPrototype = AllowDeny.CollectionPrototype; + +/** + * @summary Allow users to write directly to this collection from client code, subject to limitations you define. + * @locus Server + * @method allow + * @memberOf Mongo.Collection + * @instance + * @param {Object} options + * @param {Function} options.insert,update,remove Functions that look at a proposed modification to the database and return true if it should be allowed. + * @param {String[]} options.fetch Optional performance enhancement. Limits the fields that will be fetched from the database for inspection by your `update` and `remove` functions. + * @param {Function} options.transform Overrides `transform` on the [`Collection`](#collections). Pass `null` to disable transformation. + */ +CollectionPrototype.allow = function(options) { + addValidator(this, 'allow', options); +}; + +/** + * @summary Override `allow` rules. + * @locus Server + * @method deny + * @memberOf Mongo.Collection + * @instance + * @param {Object} options + * @param {Function} options.insert,update,remove Functions that look at a proposed modification to the database and return true if it should be denied, even if an [allow](#allow) rule says otherwise. + * @param {String[]} options.fetch Optional performance enhancement. Limits the fields that will be fetched from the database for inspection by your `update` and `remove` functions. + * @param {Function} options.transform Overrides `transform` on the [`Collection`](#collections). Pass `null` to disable transformation. + */ +CollectionPrototype.deny = function(options) { + addValidator(this, 'deny', options); +}; + +CollectionPrototype._defineMutationMethods = function(options) { + const self = this; + options = options || {}; + + // set to true once we call any allow or deny methods. If true, use + // allow/deny semantics. If false, use insecure mode semantics. + self._restricted = false; + + // Insecure mode (default to allowing writes). Defaults to 'undefined' which + // means insecure iff the insecure package is loaded. This property can be + // overriden by tests or packages wishing to change insecure mode behavior of + // their collections. + self._insecure = undefined; + + self._validators = { + insert: {allow: [], deny: []}, + update: {allow: [], deny: []}, + remove: {allow: [], deny: []}, + upsert: {allow: [], deny: []}, // dummy arrays; can't set these! + fetch: [], + fetchAllFields: false + }; + + if (!self._name) + return; // anonymous collection + + // XXX Think about method namespacing. Maybe methods should be + // "Meteor:Mongo:insert/NAME"? + self._prefix = '/' + self._name + '/'; + + // Mutation Methods + // Minimongo on the server gets no stubs; instead, by default + // it wait()s until its result is ready, yielding. + // This matches the behavior of macromongo on the server better. + // XXX see #MeteorServerNull + if (self._connection && (self._connection === Meteor.server || Meteor.isClient)) { + const m = {}; + + ['insert', 'update', 'remove'].forEach((method) => { + const methodName = self._prefix + method; + + if (options.useExisting) { + const handlerPropName = Meteor.isClient ? '_methodHandlers' : 'method_handlers'; + // Do not try to create additional methods if this has already been called. + // (Otherwise the .methods() call below will throw an error.) + if (self._connection[handlerPropName] && + typeof self._connection[handlerPropName][methodName] === 'function') return; + } + + m[methodName] = function (/* ... */) { + // All the methods do their own validation, instead of using check(). + check(arguments, [Match.Any]); + const args = Array.from(arguments); + try { + // For an insert, if the client didn't specify an _id, generate one + // now; because this uses DDP.randomStream, it will be consistent with + // what the client generated. We generate it now rather than later so + // that if (eg) an allow/deny rule does an insert to the same + // collection (not that it really should), the generated _id will + // still be the first use of the stream and will be consistent. + // + // However, we don't actually stick the _id onto the document yet, + // because we want allow/deny rules to be able to differentiate + // between arbitrary client-specified _id fields and merely + // client-controlled-via-randomSeed fields. + let generatedId = null; + if (method === "insert" && !hasOwn.call(args[0], '_id')) { + generatedId = self._makeNewID(); + } + + if (this.isSimulation) { + // In a client simulation, you can do any mutation (even with a + // complex selector). + if (generatedId !== null) + args[0]._id = generatedId; + return self._collection[method].apply( + self._collection, args); + } + + // This is the server receiving a method call from the client. + + // We don't allow arbitrary selectors in mutations from the client: only + // single-ID selectors. + if (method !== 'insert') + throwIfSelectorIsNotId(args[0], method); + + if (self._restricted) { + // short circuit if there is no way it will pass. + if (self._validators[method].allow.length === 0) { + throw new Meteor.Error( + 403, "Access denied. No allow validators set on restricted " + + "collection for method '" + method + "'."); + } + + const validatedMethodName = + '_validated' + method.charAt(0).toUpperCase() + method.slice(1); + args.unshift(this.userId); + method === 'insert' && args.push(generatedId); + return self[validatedMethodName].apply(self, args); + } else if (self._isInsecure()) { + if (generatedId !== null) + args[0]._id = generatedId; + // In insecure mode, allow any mutation (with a simple selector). + // XXX This is kind of bogus. Instead of blindly passing whatever + // we get from the network to this function, we should actually + // know the correct arguments for the function and pass just + // them. For example, if you have an extraneous extra null + // argument and this is Mongo on the server, the .wrapAsync'd + // functions like update will get confused and pass the + // "fut.resolver()" in the wrong slot, where _update will never + // invoke it. Bam, broken DDP connection. Probably should just + // take this whole method and write it three times, invoking + // helpers for the common code. + return self._collection[method].apply(self._collection, args); + } else { + // In secure mode, if we haven't called allow or deny, then nothing + // is permitted. + throw new Meteor.Error(403, "Access denied"); + } + } catch (e) { + if ( + e.name === 'MongoError' || + // for old versions of MongoDB (probably not necessary but it's here just in case) + e.name === 'BulkWriteError' || + // for newer versions of MongoDB (https://docs.mongodb.com/drivers/node/current/whats-new/#bulkwriteerror---mongobulkwriteerror) + e.name === 'MongoBulkWriteError' || + e.name === 'MinimongoError' + ) { + throw new Meteor.Error(409, e.toString()); + } else { + throw e; + } + } + }; + }); + + self._connection.methods(m); + } +}; + +CollectionPrototype._updateFetch = function (fields) { + const self = this; + + if (!self._validators.fetchAllFields) { + if (fields) { + const union = Object.create(null); + const add = names => names && names.forEach(name => union[name] = 1); + add(self._validators.fetch); + add(fields); + self._validators.fetch = Object.keys(union); + } else { + self._validators.fetchAllFields = true; + // clear fetch just to make sure we don't accidentally read it + self._validators.fetch = null; + } + } +}; + +CollectionPrototype._isInsecure = function () { + const self = this; + if (self._insecure === undefined) + return false // !!Package.insecure; HACK + return self._insecure; +}; + +CollectionPrototype._validatedInsert = function (userId, doc, + generatedId) { + const self = this; + + // call user validators. + // Any deny returns true means denied. + if (self._validators.insert.deny.some((validator) => { + return validator(userId, docToValidate(validator, doc, generatedId)); + })) { + throw new Meteor.Error(403, "Access denied"); + } + // Any allow returns true means proceed. Throw error if they all fail. + if (self._validators.insert.allow.every((validator) => { + return !validator(userId, docToValidate(validator, doc, generatedId)); + })) { + throw new Meteor.Error(403, "Access denied"); + } + + // If we generated an ID above, insert it now: after the validation, but + // before actually inserting. + if (generatedId !== null) + doc._id = generatedId; + + self._collection.insert.call(self._collection, doc); +}; + +// Simulate a mongo `update` operation while validating that the access +// control rules set by calls to `allow/deny` are satisfied. If all +// pass, rewrite the mongo operation to use $in to set the list of +// document ids to change ##ValidatedChange +CollectionPrototype._validatedUpdate = function( + userId, selector, mutator, options) { + const self = this; + + check(mutator, Object); + + options = Object.assign(Object.create(null), options); + + if (!LocalCollection._selectorIsIdPerhapsAsObject(selector)) + throw new Error("validated update should be of a single ID"); + + // We don't support upserts because they don't fit nicely into allow/deny + // rules. + if (options.upsert) + throw new Meteor.Error(403, "Access denied. Upserts not " + + "allowed in a restricted collection."); + + const noReplaceError = "Access denied. In a restricted collection you can only" + + " update documents, not replace them. Use a Mongo update operator, such " + + "as '$set'."; + + const mutatorKeys = Object.keys(mutator); + + // compute modified fields + const modifiedFields = {}; + + if (mutatorKeys.length === 0) { + throw new Meteor.Error(403, noReplaceError); + } + mutatorKeys.forEach((op) => { + const params = mutator[op]; + if (op.charAt(0) !== '$') { + throw new Meteor.Error(403, noReplaceError); + } else if (!hasOwn.call(ALLOWED_UPDATE_OPERATIONS, op)) { + throw new Meteor.Error( + 403, "Access denied. Operator " + op + " not allowed in a restricted collection."); + } else { + Object.keys(params).forEach((field) => { + // treat dotted fields as if they are replacing their + // top-level part + if (field.indexOf('.') !== -1) + field = field.substring(0, field.indexOf('.')); + + // record the field we are trying to change + modifiedFields[field] = true; + }); + } + }); + + const fields = Object.keys(modifiedFields); + + const findOptions = {transform: null}; + if (!self._validators.fetchAllFields) { + findOptions.fields = {}; + self._validators.fetch.forEach((fieldName) => { + findOptions.fields[fieldName] = 1; + }); + } + + const doc = self._collection.findOne(selector, findOptions); + if (!doc) // none satisfied! + return 0; + + // call user validators. + // Any deny returns true means denied. + if (self._validators.update.deny.some((validator) => { + const factoriedDoc = transformDoc(validator, doc); + return validator(userId, + factoriedDoc, + fields, + mutator); + })) { + throw new Meteor.Error(403, "Access denied"); + } + // Any allow returns true means proceed. Throw error if they all fail. + if (self._validators.update.allow.every((validator) => { + const factoriedDoc = transformDoc(validator, doc); + return !validator(userId, + factoriedDoc, + fields, + mutator); + })) { + throw new Meteor.Error(403, "Access denied"); + } + + options._forbidReplace = true; + + // Back when we supported arbitrary client-provided selectors, we actually + // rewrote the selector to include an _id clause before passing to Mongo to + // avoid races, but since selector is guaranteed to already just be an ID, we + // don't have to any more. + + return self._collection.update.call( + self._collection, selector, mutator, options); +}; + +// Only allow these operations in validated updates. Specifically +// whitelist operations, rather than blacklist, so new complex +// operations that are added aren't automatically allowed. A complex +// operation is one that does more than just modify its target +// field. For now this contains all update operations except '$rename'. +// http://docs.mongodb.org/manual/reference/operators/#update +const ALLOWED_UPDATE_OPERATIONS = { + $inc:1, $set:1, $unset:1, $addToSet:1, $pop:1, $pullAll:1, $pull:1, + $pushAll:1, $push:1, $bit:1 +}; + +// Simulate a mongo `remove` operation while validating access control +// rules. See #ValidatedChange +CollectionPrototype._validatedRemove = function(userId, selector) { + const self = this; + + const findOptions = {transform: null}; + if (!self._validators.fetchAllFields) { + findOptions.fields = {}; + self._validators.fetch.forEach((fieldName) => { + findOptions.fields[fieldName] = 1; + }); + } + + const doc = self._collection.findOne(selector, findOptions); + if (!doc) + return 0; + + // call user validators. + // Any deny returns true means denied. + if (self._validators.remove.deny.some((validator) => { + return validator(userId, transformDoc(validator, doc)); + })) { + throw new Meteor.Error(403, "Access denied"); + } + // Any allow returns true means proceed. Throw error if they all fail. + if (self._validators.remove.allow.every((validator) => { + return !validator(userId, transformDoc(validator, doc)); + })) { + throw new Meteor.Error(403, "Access denied"); + } + + // Back when we supported arbitrary client-provided selectors, we actually + // rewrote the selector to {_id: {$in: [ids that we found]}} before passing to + // Mongo to avoid races, but since selector is guaranteed to already just be + // an ID, we don't have to any more. + + return self._collection.remove.call(self._collection, selector); +}; + +CollectionPrototype._callMutatorMethod = function _callMutatorMethod(name, args, callback) { + if (Meteor.isClient && !callback && !alreadyInSimulation()) { + // Client can't block, so it can't report errors by exception, + // only by callback. If they forget the callback, give them a + // default one that logs the error, so they aren't totally + // baffled if their writes don't work because their database is + // down. + // Don't give a default callback in simulation, because inside stubs we + // want to return the results from the local collection immediately and + // not force a callback. + callback = function (err) { + if (err) + Meteor._debug(name + " failed", err); + }; + } + + // For two out of three mutator methods, the first argument is a selector + const firstArgIsSelector = name === "update" || name === "remove"; + if (firstArgIsSelector && !alreadyInSimulation()) { + // If we're about to actually send an RPC, we should throw an error if + // this is a non-ID selector, because the mutation methods only allow + // single-ID selectors. (If we don't throw here, we'll see flicker.) + throwIfSelectorIsNotId(args[0], name); + } + + const mutatorMethodName = this._prefix + name; + return this._connection.apply( + mutatorMethodName, args, { returnStubValue: true }, callback); +} + +function transformDoc(validator, doc) { + if (validator.transform) + return validator.transform(doc); + return doc; +} + +function docToValidate(validator, doc, generatedId) { + let ret = doc; + if (validator.transform) { + ret = EJSON.clone(doc); + // If you set a server-side transform on your collection, then you don't get + // to tell the difference between "client specified the ID" and "server + // generated the ID", because transforms expect to get _id. If you want to + // do that check, you can do it with a specific + // `C.allow({insert: f, transform: null})` validator. + if (generatedId !== null) { + ret._id = generatedId; + } + ret = validator.transform(ret); + } + return ret; +} + +function addValidator(collection, allowOrDeny, options) { + // validate keys + const validKeysRegEx = /^(?:insert|update|remove|fetch|transform)$/; + Object.keys(options).forEach((key) => { + if (!validKeysRegEx.test(key)) + throw new Error(allowOrDeny + ": Invalid key: " + key); + }); + + collection._restricted = true; + + ['insert', 'update', 'remove'].forEach((name) => { + if (hasOwn.call(options, name)) { + if (!(options[name] instanceof Function)) { + throw new Error(allowOrDeny + ": Value for `" + name + "` must be a function"); + } + + // If the transform is specified at all (including as 'null') in this + // call, then take that; otherwise, take the transform from the + // collection. + if (options.transform === undefined) { + options[name].transform = collection._transform; // already wrapped + } else { + options[name].transform = LocalCollection.wrapTransform( + options.transform); + } + + collection._validators[name][allowOrDeny].push(options[name]); + } + }); + + // Only update the fetch fields if we're passed things that affect + // fetching. This way allow({}) and allow({insert: f}) don't result in + // setting fetchAllFields + if (options.update || options.remove || options.fetch) { + if (options.fetch && !(options.fetch instanceof Array)) { + throw new Error(allowOrDeny + ": Value for `fetch` must be an array"); + } + collection._updateFetch(options.fetch); + } +} + +function throwIfSelectorIsNotId(selector, methodName) { + if (!LocalCollection._selectorIsIdPerhapsAsObject(selector)) { + throw new Meteor.Error( + 403, "Not permitted. Untrusted code may only " + methodName + + " documents by ID."); + } +}; + +// Determine if we are in a DDP method simulation +function alreadyInSimulation() { + var CurrentInvocation = + DDP._CurrentMethodInvocation || + // For backwards compatibility, as explained in this issue: + // https://github.com/meteor/meteor/issues/8947 + DDP._CurrentInvocation; + + const enclosing = CurrentInvocation.get(); + return enclosing && enclosing.isSimulation; +} \ No newline at end of file diff --git a/packages/webui/src/meteor/callback-hook.js b/packages/webui/src/meteor/callback-hook.js new file mode 100644 index 0000000000..6832e8a4aa --- /dev/null +++ b/packages/webui/src/meteor/callback-hook.js @@ -0,0 +1,149 @@ +// XXX This pattern is under development. Do not add more callsites +// using this package for now. See: +// https://meteor.hackpad.com/Design-proposal-Hooks-YxvgEW06q6f +// +// Encapsulates the pattern of registering callbacks on a hook. +// +// The `each` method of the hook calls its iterator function argument +// with each registered callback. This allows the hook to +// conditionally decide not to call the callback (if, for example, the +// observed object has been closed or terminated). +// +// By default, callbacks are bound with `Meteor.bindEnvironment`, so they will be +// called with the Meteor environment of the calling code that +// registered the callback. Override by passing { bindEnvironment: false } +// to the constructor. +// +// Registering a callback returns an object with a single `stop` +// method which unregisters the callback. +// +// The code is careful to allow a callback to be safely unregistered +// while the callbacks are being iterated over. +// +// If the hook is configured with the `exceptionHandler` option, the +// handler will be called if a called callback throws an exception. +// By default (if the exception handler doesn't itself throw an +// exception, or if the iterator function doesn't return a falsy value +// to terminate the calling of callbacks), the remaining callbacks +// will still be called. +// +// Alternatively, the `debugPrintExceptions` option can be specified +// as string describing the callback. On an exception the string and +// the exception will be printed to the console log with +// `Meteor._debug`, and the exception otherwise ignored. +// +// If an exception handler isn't specified, exceptions thrown in the +// callback will propagate up to the iterator function, and will +// terminate calling the remaining callbacks if not caught. + +import { Meteor } from "./meteor"; + +const hasOwn = Object.prototype.hasOwnProperty; + +export class Hook { + constructor(options) { + options = options || {}; + this.nextCallbackId = 0; + this.callbacks = Object.create(null); + // Whether to wrap callbacks with Meteor.bindEnvironment + this.bindEnvironment = true; + if (options.bindEnvironment === false) { + this.bindEnvironment = false; + } + + if (options.exceptionHandler) { + this.exceptionHandler = options.exceptionHandler; + } else if (options.debugPrintExceptions) { + if (typeof options.debugPrintExceptions !== "string") { + throw new Error("Hook option debugPrintExceptions should be a string"); + } + this.exceptionHandler = options.debugPrintExceptions; + } + } + + register(callback) { + const exceptionHandler = this.exceptionHandler || function (exception) { + // Note: this relies on the undocumented fact that if bindEnvironment's + // onException throws, and you are invoking the callback either in the + // browser or from within a Fiber in Node, the exception is propagated. + throw exception; + }; + + if (this.bindEnvironment) { + callback = Meteor.bindEnvironment(callback, exceptionHandler); + } else { + callback = dontBindEnvironment(callback, exceptionHandler); + } + + const id = this.nextCallbackId++; + this.callbacks[id] = callback; + + return { + callback, + stop: () => { + delete this.callbacks[id]; + } + }; + } + + /** + * For each registered callback, call the passed iterator function with the callback. + * + * The iterator function can choose whether or not to call the + * callback. (For example, it might not call the callback if the + * observed object has been closed or terminated). + * The iteration is stopped if the iterator function returns a falsy + * value or throws an exception. + * + * @param iterator + */ + forEach(iterator) { + // Invoking bindEnvironment'd callbacks outside of a Fiber in Node doesn't + // run them to completion (and exceptions thrown from onException are not + // propagated), so we need to be in a Fiber. + Meteor._nodeCodeMustBeInFiber(); + + const ids = Object.keys(this.callbacks); + for (let i = 0; i < ids.length; ++i) { + const id = ids[i]; + // check to see if the callback was removed during iteration + if (hasOwn.call(this.callbacks, id)) { + const callback = this.callbacks[id]; + if (! iterator(callback)) { + break; + } + } + } + } + + /** + * @deprecated use forEach + * @param iterator + */ + each(iterator) { + return this.forEach(iterator); + } +} + +// Copied from Meteor.bindEnvironment and removed all the env stuff. +function dontBindEnvironment(func, onException, _this) { + if (!onException || typeof(onException) === 'string') { + const description = onException || "callback of async function"; + onException = function (error) { + Meteor._debug( + "Exception in " + description, + error + ); + }; + } + + return function (...args) { + let ret; + try { + ret = func.apply(_this, args); + } catch (e) { + onException(e); + } + return ret; + }; +} \ No newline at end of file diff --git a/packages/webui/src/meteor/check/index.d.ts b/packages/webui/src/meteor/check/index.d.ts new file mode 100644 index 0000000000..dc6825ea7d --- /dev/null +++ b/packages/webui/src/meteor/check/index.d.ts @@ -0,0 +1,78 @@ + /** + * The namespace for all Match types and methods. + */ + namespace Match { + interface Matcher { + _meteorCheckMatcherBrand: void; + } + // prettier-ignore + export type Pattern = + typeof String | + typeof Number | + typeof Boolean | + typeof Object | + typeof Function | + (new (...args: any[]) => any) | + undefined | null | string | number | boolean | + [Pattern] | + {[key: string]: Pattern} | + Matcher; + // prettier-ignore + export type PatternMatch = + T extends Matcher ? U : + T extends typeof String ? string : + T extends typeof Number ? number : + T extends typeof Boolean ? boolean : + T extends typeof Object ? object : + T extends typeof Function ? Function : + T extends undefined | null | string | number | boolean ? T : + T extends new (...args: any[]) => infer U ? U : + T extends [Pattern] ? PatternMatch[] : + T extends {[key: string]: Pattern} ? {[K in keyof T]: PatternMatch} : + unknown; + + /** Matches any value. */ + var Any: Matcher; + /** Matches a signed 32-bit integer. Doesn’t match `Infinity`, `-Infinity`, or `NaN`. */ + var Integer: Matcher; + + /** + * Matches either `undefined`, `null`, or pattern. If used in an object, matches only if the key is not set as opposed to the value being set to `undefined` or `null`. This set of conditions + * was chosen because `undefined` arguments to Meteor Methods are converted to `null` when sent over the wire. + */ + function Maybe(pattern: T): Matcher | undefined | null>; + + /** Behaves like `Match.Maybe` except it doesn’t accept `null`. If used in an object, the behavior is identical to `Match.Maybe`. */ + function Optional(pattern: T): Matcher | undefined>; + + /** Matches an Object with the given keys; the value may also have other keys with arbitrary values. */ + function ObjectIncluding(dico: T): Matcher>; + + /** Matches any value that matches at least one of the provided patterns. */ + function OneOf(...patterns: T): Matcher>; + + /** + * Calls the function condition with the value as the argument. If condition returns true, this matches. If condition throws a `Match.Error` or returns false, this fails. If condition throws + * any other error, that error is thrown from the call to `check` or `Match.test`. + */ + function Where(condition: (val: any) => val is T): Matcher; + function Where(condition: (val: any) => boolean): Matcher; + + /** + * Returns true if the value matches the pattern. + * @param value The value to check + * @param pattern The pattern to match `value` against + */ + function test(value: any, pattern: T): value is PatternMatch; + } + + /** + * Check that a value matches a pattern. + * If the value does not match the pattern, throw a `Match.Error`. + * + * Particularly useful to assert that arguments to a function have the right + * types and structure. + * @param value The value to check + * @param pattern The pattern to match `value` against + */ + export function check(value: any, pattern: T): asserts value is Match.PatternMatch; \ No newline at end of file diff --git a/packages/webui/src/meteor/check/index.js b/packages/webui/src/meteor/check/index.js new file mode 100644 index 0000000000..b0f91a0e06 --- /dev/null +++ b/packages/webui/src/meteor/check/index.js @@ -0,0 +1,554 @@ +// XXX docs +import { isPlainObject } from './isPlainObject'; +import EJSON from 'ejson' +import { Meteor } from '../meteor' + +// Things we explicitly do NOT support: +// - heterogenous arrays + +const currentArgumentChecker = new Meteor.EnvironmentVariable; +const hasOwn = Object.prototype.hasOwnProperty; + +/** + * @summary Check that a value matches a [pattern](#matchpatterns). + * If the value does not match the pattern, throw a `Match.Error`. + * + * Particularly useful to assert that arguments to a function have the right + * types and structure. + * @locus Anywhere + * @param {Any} value The value to check + * @param {MatchPattern} pattern The pattern to match `value` against + */ +export function check(value, pattern) { + // Record that check got called, if somebody cared. + // + // We use getOrNullIfOutsideFiber so that it's OK to call check() + // from non-Fiber server contexts; the downside is that if you forget to + // bindEnvironment on some random callback in your method/publisher, + // it might not find the argumentChecker and you'll get an error about + // not checking an argument that it looks like you're checking (instead + // of just getting a "Node code must run in a Fiber" error). + const argChecker = currentArgumentChecker.getOrNullIfOutsideFiber(); + if (argChecker) { + argChecker.checking(value); + } + + const result = testSubtree(value, pattern); + if (result) { + const err = new Match.Error(result.message); + if (result.path) { + err.message += ` in field ${result.path}`; + err.path = result.path; + } + + throw err; + } +}; + +/** + * @namespace Match + * @summary The namespace for all Match types and methods. + */ +export const Match = { + Optional: function(pattern) { + return new Optional(pattern); + }, + + Maybe: function(pattern) { + return new Maybe(pattern); + }, + + OneOf: function(...args) { + return new OneOf(args); + }, + + Any: ['__any__'], + Where: function(condition) { + return new Where(condition); + }, + + ObjectIncluding: function(pattern) { + return new ObjectIncluding(pattern) + }, + + ObjectWithValues: function(pattern) { + return new ObjectWithValues(pattern); + }, + + // Matches only signed 32-bit integers + Integer: ['__integer__'], + + // XXX matchers should know how to describe themselves for errors + Error: Meteor.makeErrorType('Match.Error', function (msg) { + this.message = `Match error: ${msg}`; + + // The path of the value that failed to match. Initially empty, this gets + // populated by catching and rethrowing the exception as it goes back up the + // stack. + // E.g.: "vals[3].entity.created" + this.path = ''; + + // If this gets sent over DDP, don't give full internal details but at least + // provide something better than 500 Internal server error. + this.sanitizedError = new Meteor.Error(400, 'Match failed'); + }), + + // Tests to see if value matches pattern. Unlike check, it merely returns true + // or false (unless an error other than Match.Error was thrown). It does not + // interact with _failIfArgumentsAreNotAllChecked. + // XXX maybe also implement a Match.match which returns more information about + // failures but without using exception handling or doing what check() + // does with _failIfArgumentsAreNotAllChecked and Meteor.Error conversion + + /** + * @summary Returns true if the value matches the pattern. + * @locus Anywhere + * @param {Any} value The value to check + * @param {MatchPattern} pattern The pattern to match `value` against + */ + test(value, pattern) { + return !testSubtree(value, pattern); + }, + + // Runs `f.apply(context, args)`. If check() is not called on every element of + // `args` (either directly or in the first level of an array), throws an error + // (using `description` in the message). + _failIfArgumentsAreNotAllChecked(f, context, args, description) { + const argChecker = new ArgumentChecker(args, description); + const result = currentArgumentChecker.withValue( + argChecker, + () => f.apply(context, args) + ); + + // If f didn't itself throw, make sure it checked all of its arguments. + argChecker.throwUnlessAllArgumentsHaveBeenChecked(); + return result; + } +}; + +class Optional { + constructor(pattern) { + this.pattern = pattern; + } +} + +class Maybe { + constructor(pattern) { + this.pattern = pattern; + } +} + +class OneOf { + constructor(choices) { + if (!choices || choices.length === 0) { + throw new Error('Must provide at least one choice to Match.OneOf'); + } + + this.choices = choices; + } +} + +class Where { + constructor(condition) { + this.condition = condition; + } +} + +class ObjectIncluding { + constructor(pattern) { + this.pattern = pattern; + } +} + +class ObjectWithValues { + constructor(pattern) { + this.pattern = pattern; + } +} + +const stringForErrorMessage = (value, options = {}) => { + if ( value === null ) { + return 'null'; + } + + if ( options.onlyShowType ) { + return typeof value; + } + + // Your average non-object things. Saves from doing the try/catch below for. + if ( typeof value !== 'object' ) { + return EJSON.stringify(value) + } + + try { + + // Find objects with circular references since EJSON doesn't support them yet (Issue #4778 + Unaccepted PR) + // If the native stringify is going to choke, EJSON.stringify is going to choke too. + JSON.stringify(value); + } catch (stringifyError) { + if ( stringifyError.name === 'TypeError' ) { + return typeof value; + } + } + + return EJSON.stringify(value); +}; + +const typeofChecks = [ + [String, 'string'], + [Number, 'number'], + [Boolean, 'boolean'], + + // While we don't allow undefined/function in EJSON, this is good for optional + // arguments with OneOf. + [Function, 'function'], + [undefined, 'undefined'], +]; + +// Return `false` if it matches. Otherwise, return an object with a `message` and a `path` field. +const testSubtree = (value, pattern) => { + + // Match anything! + if (pattern === Match.Any) { + return false; + } + + // Basic atomic types. + // Do not match boxed objects (e.g. String, Boolean) + for (let i = 0; i < typeofChecks.length; ++i) { + if (pattern === typeofChecks[i][0]) { + if (typeof value === typeofChecks[i][1]) { + return false; + } + + return { + message: `Expected ${typeofChecks[i][1]}, got ${stringForErrorMessage(value, { onlyShowType: true })}`, + path: '', + }; + } + } + + if (pattern === null) { + if (value === null) { + return false; + } + + return { + message: `Expected null, got ${stringForErrorMessage(value)}`, + path: '', + }; + } + + // Strings, numbers, and booleans match literally. Goes well with Match.OneOf. + if (typeof pattern === 'string' || typeof pattern === 'number' || typeof pattern === 'boolean') { + if (value === pattern) { + return false; + } + + return { + message: `Expected ${pattern}, got ${stringForErrorMessage(value)}`, + path: '', + }; + } + + // Match.Integer is special type encoded with array + if (pattern === Match.Integer) { + + // There is no consistent and reliable way to check if variable is a 64-bit + // integer. One of the popular solutions is to get reminder of division by 1 + // but this method fails on really large floats with big precision. + // E.g.: 1.348192308491824e+23 % 1 === 0 in V8 + // Bitwise operators work consistantly but always cast variable to 32-bit + // signed integer according to JavaScript specs. + if (typeof value === 'number' && (value | 0) === value) { + return false; + } + + return { + message: `Expected Integer, got ${stringForErrorMessage(value)}`, + path: '', + }; + } + + // 'Object' is shorthand for Match.ObjectIncluding({}); + if (pattern === Object) { + pattern = Match.ObjectIncluding({}); + } + + // Array (checked AFTER Any, which is implemented as an Array). + if (pattern instanceof Array) { + if (pattern.length !== 1) { + return { + message: `Bad pattern: arrays must have one type element ${stringForErrorMessage(pattern)}`, + path: '', + }; + } + + if (!Array.isArray(value) && !isArguments(value)) { + return { + message: `Expected array, got ${stringForErrorMessage(value)}`, + path: '', + }; + } + + for (let i = 0, length = value.length; i < length; i++) { + const result = testSubtree(value[i], pattern[0]); + if (result) { + result.path = _prependPath(i, result.path); + return result; + } + } + + return false; + } + + // Arbitrary validation checks. The condition can return false or throw a + // Match.Error (ie, it can internally use check()) to fail. + if (pattern instanceof Where) { + let result; + try { + result = pattern.condition(value); + } catch (err) { + if (!(err instanceof Match.Error)) { + throw err; + } + + return { + message: err.message, + path: err.path + }; + } + + if (result) { + return false; + } + + // XXX this error is terrible + return { + message: 'Failed Match.Where validation', + path: '', + }; + } + + if (pattern instanceof Maybe) { + pattern = Match.OneOf(undefined, null, pattern.pattern); + } else if (pattern instanceof Optional) { + pattern = Match.OneOf(undefined, pattern.pattern); + } + + if (pattern instanceof OneOf) { + for (let i = 0; i < pattern.choices.length; ++i) { + const result = testSubtree(value, pattern.choices[i]); + if (!result) { + + // No error? Yay, return. + return false; + } + + // Match errors just mean try another choice. + } + + // XXX this error is terrible + return { + message: 'Failed Match.OneOf, Match.Maybe or Match.Optional validation', + path: '', + }; + } + + // A function that isn't something we special-case is assumed to be a + // constructor. + if (pattern instanceof Function) { + if (value instanceof pattern) { + return false; + } + + return { + message: `Expected ${pattern.name || 'particular constructor'}`, + path: '', + }; + } + + let unknownKeysAllowed = false; + let unknownKeyPattern; + if (pattern instanceof ObjectIncluding) { + unknownKeysAllowed = true; + pattern = pattern.pattern; + } + + if (pattern instanceof ObjectWithValues) { + unknownKeysAllowed = true; + unknownKeyPattern = [pattern.pattern]; + pattern = {}; // no required keys + } + + if (typeof pattern !== 'object') { + return { + message: 'Bad pattern: unknown pattern type', + path: '', + }; + } + + // An object, with required and optional keys. Note that this does NOT do + // structural matches against objects of special types that happen to match + // the pattern: this really needs to be a plain old {Object}! + if (typeof value !== 'object') { + return { + message: `Expected object, got ${typeof value}`, + path: '', + }; + } + + if (value === null) { + return { + message: `Expected object, got null`, + path: '', + }; + } + + if (! isPlainObject(value)) { + return { + message: `Expected plain object`, + path: '', + }; + } + + const requiredPatterns = Object.create(null); + const optionalPatterns = Object.create(null); + + Object.keys(pattern).forEach(key => { + const subPattern = pattern[key]; + if (subPattern instanceof Optional || + subPattern instanceof Maybe) { + optionalPatterns[key] = subPattern.pattern; + } else { + requiredPatterns[key] = subPattern; + } + }); + + for (let key in Object(value)) { + const subValue = value[key]; + if (hasOwn.call(requiredPatterns, key)) { + const result = testSubtree(subValue, requiredPatterns[key]); + if (result) { + result.path = _prependPath(key, result.path); + return result; + } + + delete requiredPatterns[key]; + } else if (hasOwn.call(optionalPatterns, key)) { + const result = testSubtree(subValue, optionalPatterns[key]); + if (result) { + result.path = _prependPath(key, result.path); + return result; + } + + } else { + if (!unknownKeysAllowed) { + return { + message: 'Unknown key', + path: key, + }; + } + + if (unknownKeyPattern) { + const result = testSubtree(subValue, unknownKeyPattern[0]); + if (result) { + result.path = _prependPath(key, result.path); + return result; + } + } + } + } + + const keys = Object.keys(requiredPatterns); + if (keys.length) { + return { + message: `Missing key '${keys[0]}'`, + path: '', + }; + } +}; + +class ArgumentChecker { + constructor (args, description) { + + // Make a SHALLOW copy of the arguments. (We'll be doing identity checks + // against its contents.) + this.args = [...args]; + + // Since the common case will be to check arguments in order, and we splice + // out arguments when we check them, make it so we splice out from the end + // rather than the beginning. + this.args.reverse(); + this.description = description; + } + + checking(value) { + if (this._checkingOneValue(value)) { + return; + } + + // Allow check(arguments, [String]) or check(arguments.slice(1), [String]) + // or check([foo, bar], [String]) to count... but only if value wasn't + // itself an argument. + if (Array.isArray(value) || isArguments(value)) { + Array.prototype.forEach.call(value, this._checkingOneValue.bind(this)); + } + } + + _checkingOneValue(value) { + for (let i = 0; i < this.args.length; ++i) { + + // Is this value one of the arguments? (This can have a false positive if + // the argument is an interned primitive, but it's still a good enough + // check.) + // (NaN is not === to itself, so we have to check specially.) + if (value === this.args[i] || + (Number.isNaN(value) && Number.isNaN(this.args[i]))) { + this.args.splice(i, 1); + return true; + } + } + return false; + } + + throwUnlessAllArgumentsHaveBeenChecked() { + if (this.args.length > 0) + throw new Error(`Did not check() all arguments during ${this.description}`); + } +} + +const _jsKeywords = ['do', 'if', 'in', 'for', 'let', 'new', 'try', 'var', 'case', + 'else', 'enum', 'eval', 'false', 'null', 'this', 'true', 'void', 'with', + 'break', 'catch', 'class', 'const', 'super', 'throw', 'while', 'yield', + 'delete', 'export', 'import', 'public', 'return', 'static', 'switch', + 'typeof', 'default', 'extends', 'finally', 'package', 'private', 'continue', + 'debugger', 'function', 'arguments', 'interface', 'protected', 'implements', + 'instanceof']; + +// Assumes the base of path is already escaped properly +// returns key + base +const _prependPath = (key, base) => { + if ((typeof key) === 'number' || key.match(/^[0-9]+$/)) { + key = `[${key}]`; + } else if (!key.match(/^[a-z_$][0-9a-z_$]*$/i) || + _jsKeywords.indexOf(key) >= 0) { + key = JSON.stringify([key]); + } + + if (base && base[0] !== '[') { + return `${key}.${base}`; + } + + return key + base; +} + +const isObject = value => typeof value === 'object' && value !== null; + +const baseIsArguments = item => + isObject(item) && + Object.prototype.toString.call(item) === '[object Arguments]'; + +const isArguments = baseIsArguments(function() { return arguments; }()) ? + baseIsArguments : + value => isObject(value) && typeof value.callee === 'function'; \ No newline at end of file diff --git a/packages/webui/src/meteor/check/isPlainObject.js b/packages/webui/src/meteor/check/isPlainObject.js new file mode 100644 index 0000000000..0e329ed6e9 --- /dev/null +++ b/packages/webui/src/meteor/check/isPlainObject.js @@ -0,0 +1,36 @@ +// Copy of jQuery.isPlainObject for the server side from jQuery v3.1.1. + +const class2type = {}; + +const toString = class2type.toString; + +const hasOwn = Object.prototype.hasOwnProperty; + +const fnToString = hasOwn.toString; + +const ObjectFunctionString = fnToString.call(Object); + +const getProto = Object.getPrototypeOf; + +export const isPlainObject = obj => { + let proto; + let Ctor; + + // Detect obvious negatives + // Use toString instead of jQuery.type to catch host objects + if (!obj || toString.call(obj) !== '[object Object]') { + return false; + } + + proto = getProto(obj); + + // Objects with no prototype (e.g., `Object.create( null )`) are plain + if (!proto) { + return true; + } + + // Objects with prototype are plain iff they were constructed by a global Object function + Ctor = hasOwn.call(proto, 'constructor') && proto.constructor; + return typeof Ctor === 'function' && + fnToString.call(Ctor) === ObjectFunctionString; +}; \ No newline at end of file diff --git a/packages/webui/src/meteor/ddp-common/heartbeat.js b/packages/webui/src/meteor/ddp-common/heartbeat.js new file mode 100644 index 0000000000..36c40221a0 --- /dev/null +++ b/packages/webui/src/meteor/ddp-common/heartbeat.js @@ -0,0 +1,92 @@ +import { DDPCommon } from './namespace' +import { Meteor } from '../meteor' + +// Heartbeat options: +// heartbeatInterval: interval to send pings, in milliseconds. +// heartbeatTimeout: timeout to close the connection if a reply isn't +// received, in milliseconds. +// sendPing: function to call to send a ping on the connection. +// onTimeout: function to call to close the connection. + +DDPCommon.Heartbeat = class Heartbeat { + constructor(options) { + this.heartbeatInterval = options.heartbeatInterval; + this.heartbeatTimeout = options.heartbeatTimeout; + this._sendPing = options.sendPing; + this._onTimeout = options.onTimeout; + this._seenPacket = false; + + this._heartbeatIntervalHandle = null; + this._heartbeatTimeoutHandle = null; + } + + stop() { + this._clearHeartbeatIntervalTimer(); + this._clearHeartbeatTimeoutTimer(); + } + + start() { + this.stop(); + this._startHeartbeatIntervalTimer(); + } + + _startHeartbeatIntervalTimer() { + this._heartbeatIntervalHandle = Meteor.setInterval( + () => this._heartbeatIntervalFired(), + this.heartbeatInterval + ); + } + + _startHeartbeatTimeoutTimer() { + this._heartbeatTimeoutHandle = Meteor.setTimeout( + () => this._heartbeatTimeoutFired(), + this.heartbeatTimeout + ); + } + + _clearHeartbeatIntervalTimer() { + if (this._heartbeatIntervalHandle) { + Meteor.clearInterval(this._heartbeatIntervalHandle); + this._heartbeatIntervalHandle = null; + } + } + + _clearHeartbeatTimeoutTimer() { + if (this._heartbeatTimeoutHandle) { + Meteor.clearTimeout(this._heartbeatTimeoutHandle); + this._heartbeatTimeoutHandle = null; + } + } + + // The heartbeat interval timer is fired when we should send a ping. + _heartbeatIntervalFired() { + // don't send ping if we've seen a packet since we last checked, + // *or* if we have already sent a ping and are awaiting a timeout. + // That shouldn't happen, but it's possible if + // `this.heartbeatInterval` is smaller than + // `this.heartbeatTimeout`. + if (! this._seenPacket && ! this._heartbeatTimeoutHandle) { + this._sendPing(); + // Set up timeout, in case a pong doesn't arrive in time. + this._startHeartbeatTimeoutTimer(); + } + this._seenPacket = false; + } + + // The heartbeat timeout timer is fired when we sent a ping, but we + // timed out waiting for the pong. + _heartbeatTimeoutFired() { + this._heartbeatTimeoutHandle = null; + this._onTimeout(); + } + + messageReceived() { + // Tell periodic checkin that we have seen a packet, and thus it + // does not need to send a ping this cycle. + this._seenPacket = true; + // If we were waiting for a pong, we got it. + if (this._heartbeatTimeoutHandle) { + this._clearHeartbeatTimeoutTimer(); + } + } + }; \ No newline at end of file diff --git a/packages/webui/src/meteor/ddp-common/index.js b/packages/webui/src/meteor/ddp-common/index.js new file mode 100644 index 0000000000..02cf2ae810 --- /dev/null +++ b/packages/webui/src/meteor/ddp-common/index.js @@ -0,0 +1,6 @@ +import './heartbeat' +import './utils' +import './method_invocation' +import './random_stream' + +export * from './namespace' \ No newline at end of file diff --git a/packages/webui/src/meteor/ddp-common/method_invocation.js b/packages/webui/src/meteor/ddp-common/method_invocation.js new file mode 100644 index 0000000000..5dcabe37e3 --- /dev/null +++ b/packages/webui/src/meteor/ddp-common/method_invocation.js @@ -0,0 +1,96 @@ +import { DDPCommon } from './namespace' + +// Instance name is this because it is usually referred to as this inside a +// method definition +/** + * @summary The state for a single invocation of a method, referenced by this + * inside a method definition. + * @param {Object} options + * @instanceName this + * @showInstanceName true + */ + DDPCommon.MethodInvocation = class MethodInvocation { + constructor(options) { + // true if we're running not the actual method, but a stub (that is, + // if we're on a client (which may be a browser, or in the future a + // server connecting to another server) and presently running a + // simulation of a server-side method for latency compensation + // purposes). not currently true except in a client such as a browser, + // since there's usually no point in running stubs unless you have a + // zero-latency connection to the user. + + /** + * @summary Access inside a method invocation. Boolean value, true if this invocation is a stub. + * @locus Anywhere + * @name isSimulation + * @memberOf DDPCommon.MethodInvocation + * @instance + * @type {Boolean} + */ + this.isSimulation = options.isSimulation; + + // call this function to allow other method invocations (from the + // same client) to continue running without waiting for this one to + // complete. + this._unblock = options.unblock || function () {}; + this._calledUnblock = false; + + // current user id + + /** + * @summary The id of the user that made this method call, or `null` if no user was logged in. + * @locus Anywhere + * @name userId + * @memberOf DDPCommon.MethodInvocation + * @instance + */ + this.userId = options.userId; + + // sets current user id in all appropriate server contexts and + // reruns subscriptions + this._setUserId = options.setUserId || function () {}; + + // On the server, the connection this method call came in on. + + /** + * @summary Access inside a method invocation. The [connection](#meteor_onconnection) that this method was received on. `null` if the method is not associated with a connection, eg. a server initiated method call. Calls to methods made from a server method which was in turn initiated from the client share the same `connection`. + * @locus Server + * @name connection + * @memberOf DDPCommon.MethodInvocation + * @instance + */ + this.connection = options.connection; + + // The seed for randomStream value generation + this.randomSeed = options.randomSeed; + + // This is set by RandomStream.get; and holds the random stream state + this.randomStream = null; + } + + /** + * @summary Call inside a method invocation. Allow subsequent method from this client to begin running in a new fiber. + * @locus Server + * @memberOf DDPCommon.MethodInvocation + * @instance + */ + unblock() { + this._calledUnblock = true; + this._unblock(); + } + + /** + * @summary Set the logged in user. + * @locus Server + * @memberOf DDPCommon.MethodInvocation + * @instance + * @param {String | null} userId The value that should be returned by `userId` on this connection. + */ + setUserId(userId) { + if (this._calledUnblock) { + throw new Error("Can't call setUserId in a method after calling unblock"); + } + this.userId = userId; + this._setUserId(userId); + } + }; \ No newline at end of file diff --git a/packages/webui/src/meteor/ddp-common/namespace.js b/packages/webui/src/meteor/ddp-common/namespace.js new file mode 100644 index 0000000000..8c4702a8a1 --- /dev/null +++ b/packages/webui/src/meteor/ddp-common/namespace.js @@ -0,0 +1,9 @@ +/** + * @namespace DDPCommon + * @summary Namespace for DDPCommon-related methods/classes. Shared between + * `ddp-client` and `ddp-server`, where the ddp-client is the implementation + * of a ddp client for both client AND server; and the ddp server is the + * implementation of the livedata server and stream server. Common + * functionality shared between both can be shared under this namespace + */ +export const DDPCommon = {}; \ No newline at end of file diff --git a/packages/webui/src/meteor/ddp-common/random_stream.js b/packages/webui/src/meteor/ddp-common/random_stream.js new file mode 100644 index 0000000000..cae915fffb --- /dev/null +++ b/packages/webui/src/meteor/ddp-common/random_stream.js @@ -0,0 +1,98 @@ +import { DDPCommon } from './namespace' +import { Random } from '../random' + +// RandomStream allows for generation of pseudo-random values, from a seed. +// +// We use this for consistent 'random' numbers across the client and server. +// We want to generate probably-unique IDs on the client, and we ideally want +// the server to generate the same IDs when it executes the method. +// +// For generated values to be the same, we must seed ourselves the same way, +// and we must keep track of the current state of our pseudo-random generators. +// We call this state the scope. By default, we use the current DDP method +// invocation as our scope. DDP now allows the client to specify a randomSeed. +// If a randomSeed is provided it will be used to seed our random sequences. +// In this way, client and server method calls will generate the same values. +// +// We expose multiple named streams; each stream is independent +// and is seeded differently (but predictably from the name). +// By using multiple streams, we support reordering of requests, +// as long as they occur on different streams. +// +// @param options {Optional Object} +// seed: Array or value - Seed value(s) for the generator. +// If an array, will be used as-is +// If a value, will be converted to a single-value array +// If omitted, a random array will be used as the seed. +DDPCommon.RandomStream = class RandomStream { + constructor(options) { + this.seed = [].concat(options.seed || randomToken()); + this.sequences = Object.create(null); + } + + // Get a random sequence with the specified name, creating it if does not exist. + // New sequences are seeded with the seed concatenated with the name. + // By passing a seed into Random.create, we use the Alea generator. + _sequence(name) { + var self = this; + + var sequence = self.sequences[name] || null; + if (sequence === null) { + var sequenceSeed = self.seed.concat(name); + for (var i = 0; i < sequenceSeed.length; i++) { + if (typeof sequenceSeed[i] === "function") { + sequenceSeed[i] = sequenceSeed[i](); + } + } + self.sequences[name] = sequence = Random.createWithSeeds.apply(null, sequenceSeed); + } + return sequence; + } + }; + + // Returns a random string of sufficient length for a random seed. + // This is a placeholder function; a similar function is planned + // for Random itself; when that is added we should remove this function, + // and call Random's randomToken instead. + function randomToken() { + return Random.hexString(20); + }; + + // Returns the random stream with the specified name, in the specified + // scope. If a scope is passed, then we use that to seed a (not + // cryptographically secure) PRNG using the fast Alea algorithm. If + // scope is null (or otherwise falsey) then we use a generated seed. + // + // However, scope will normally be the current DDP method invocation, + // so we'll use the stream with the specified name, and we should get + // consistent values on the client and server sides of a method call. + DDPCommon.RandomStream.get = function (scope, name) { + if (!name) { + name = "default"; + } + if (!scope) { + // There was no scope passed in; the sequence won't actually be + // reproducible. but make it fast (and not cryptographically + // secure) anyways, so that the behavior is similar to what you'd + // get by passing in a scope. + return Random.insecure; + } + var randomStream = scope.randomStream; + if (!randomStream) { + scope.randomStream = randomStream = new DDPCommon.RandomStream({ + seed: scope.randomSeed + }); + } + return randomStream._sequence(name); + }; + + // Creates a randomSeed for passing to a method call. + // Note that we take enclosing as an argument, + // though we expect it to be DDP._CurrentMethodInvocation.get() + // However, we often evaluate makeRpcSeed lazily, and thus the relevant + // invocation may not be the one currently in scope. + // If enclosing is null, we'll use Random and values won't be repeatable. + DDPCommon.makeRpcSeed = function (enclosing, methodName) { + var stream = DDPCommon.RandomStream.get(enclosing, '/rpc/' + methodName); + return stream.hexString(20); + }; \ No newline at end of file diff --git a/packages/webui/src/meteor/ddp-common/utils.js b/packages/webui/src/meteor/ddp-common/utils.js new file mode 100644 index 0000000000..ff8f628b35 --- /dev/null +++ b/packages/webui/src/meteor/ddp-common/utils.js @@ -0,0 +1,121 @@ +"use strict"; + +import { DDPCommon } from './namespace' +import { Meteor } from '../meteor' +import EJSON from 'ejson' + +export const hasOwn = Object.prototype.hasOwnProperty; +export const slice = Array.prototype.slice; + +export function keys(obj) { + return Object.keys(Object(obj)); +} + +export function isEmpty(obj) { + if (obj == null) { + return true; + } + + if (Array.isArray(obj) || + typeof obj === "string") { + return obj.length === 0; + } + + for (const key in obj) { + if (hasOwn.call(obj, key)) { + return false; + } + } + + return true; +} + +export function last(array, n, guard) { + if (array == null) { + return; + } + + if ((n == null) || guard) { + return array[array.length - 1]; + } + + return slice.call(array, Math.max(array.length - n, 0)); +} + +DDPCommon.SUPPORTED_DDP_VERSIONS = [ '1', 'pre2', 'pre1' ]; + +DDPCommon.parseDDP = function (stringMessage) { + try { + var msg = JSON.parse(stringMessage); + } catch (e) { + Meteor._debug("Discarding message with invalid JSON", stringMessage); + return null; + } + // DDP messages must be objects. + if (msg === null || typeof msg !== 'object') { + Meteor._debug("Discarding non-object DDP message", stringMessage); + return null; + } + + // massage msg to get it into "abstract ddp" rather than "wire ddp" format. + + // switch between "cleared" rep of unsetting fields and "undefined" + // rep of same + if (hasOwn.call(msg, 'cleared')) { + if (! hasOwn.call(msg, 'fields')) { + msg.fields = {}; + } + msg.cleared.forEach(clearKey => { + msg.fields[clearKey] = undefined; + }); + delete msg.cleared; + } + + ['fields', 'params', 'result'].forEach(field => { + if (hasOwn.call(msg, field)) { + msg[field] = EJSON._adjustTypesFromJSONValue(msg[field]); + } + }); + + return msg; +}; + +DDPCommon.stringifyDDP = function (msg) { + const copy = EJSON.clone(msg); + + // swizzle 'changed' messages from 'fields undefined' rep to 'fields + // and cleared' rep + if (hasOwn.call(msg, 'fields')) { + const cleared = []; + + Object.keys(msg.fields).forEach(key => { + const value = msg.fields[key]; + + if (typeof value === "undefined") { + cleared.push(key); + delete copy.fields[key]; + } + }); + + if (! isEmpty(cleared)) { + copy.cleared = cleared; + } + + if (isEmpty(copy.fields)) { + delete copy.fields; + } + } + + // adjust types to basic + ['fields', 'params', 'result'].forEach(field => { + if (hasOwn.call(copy, field)) { + copy[field] = EJSON._adjustTypesToJSONValue(copy[field]); + } + }); + + if (msg.id && typeof msg.id !== 'string') { + throw new Error("Message id is not a string"); + } + + return JSON.stringify(copy); +}; \ No newline at end of file diff --git a/packages/webui/src/meteor/ddp/client_convenience.js b/packages/webui/src/meteor/ddp/client_convenience.js new file mode 100644 index 0000000000..ace0d3bfd0 --- /dev/null +++ b/packages/webui/src/meteor/ddp/client_convenience.js @@ -0,0 +1,59 @@ +import { DDP } from './common/namespace.js'; +import { Meteor } from '../meteor'; +import { Retry } from '../retry' +import { Reload } from '../reload' + +// Meteor.refresh can be called on the client (if you're in common code) but it +// only has an effect on the server. +Meteor.refresh = () => {}; + +// By default, try to connect back to the same endpoint as the page +// was served from. +// +// XXX We should be doing this a different way. Right now we don't +// include ROOT_URL_PATH_PREFIX when computing ddpUrl. (We don't +// include it on the server when computing +// DDP_DEFAULT_CONNECTION_URL, and we don't include it in our +// default, '/'.) We get by with this because DDP.connect then +// forces the URL passed to it to be interpreted relative to the +// app's deploy path, even if it is absolute. Instead, we should +// make DDP_DEFAULT_CONNECTION_URL, if set, include the path prefix; +// make the default ddpUrl be '' rather that '/'; and make +// _translateUrl in stream_client_common.js not force absolute paths +// to be treated like relative paths. See also +// stream_client_common.js #RationalizingRelativeDDPURLs +const runtimeConfig = typeof window.__meteor_runtime_config__ !== 'undefined' ? window.__meteor_runtime_config__ : Object.create(null); +const ddpUrl = runtimeConfig.DDP_DEFAULT_CONNECTION_URL || '/'; + +const retry = new Retry(); + +function onDDPVersionNegotiationFailure(description) { + Meteor._debug(description); +// if (Package.reload) { + const migrationData = Reload._migrationData('livedata') || Object.create(null); + let failures = migrationData.DDPVersionNegotiationFailures || 0; + ++failures; + Reload._onMigrate('livedata', () => [true, { DDPVersionNegotiationFailures: failures }]); + retry.retryLater(failures, () => { + Reload._reload({ immediateMigration: true }); + }); +// } +} + +Meteor.connection = DDP.connect(ddpUrl, { + onDDPVersionNegotiationFailure: onDDPVersionNegotiationFailure +}); + +// Proxy the public methods of Meteor.connection so they can +// be called directly on Meteor. +[ + 'subscribe', + 'methods', + 'call', + 'apply', + 'status', + 'reconnect', + 'disconnect' +].forEach(name => { + Meteor[name] = Meteor.connection[name].bind(Meteor.connection); +}); \ No newline at end of file diff --git a/packages/webui/src/meteor/ddp/common/MethodInvoker.js b/packages/webui/src/meteor/ddp/common/MethodInvoker.js new file mode 100644 index 0000000000..ee00cefc48 --- /dev/null +++ b/packages/webui/src/meteor/ddp/common/MethodInvoker.js @@ -0,0 +1,85 @@ +// A MethodInvoker manages sending a method to the server and calling the user's +// callbacks. On construction, it registers itself in the connection's +// _methodInvokers map; it removes itself once the method is fully finished and +// the callback is invoked. This occurs when it has both received a result, +// and the data written by it is fully visible. +export default class MethodInvoker { + constructor(options) { + // Public (within this file) fields. + this.methodId = options.methodId; + this.sentMessage = false; + + this._callback = options.callback; + this._connection = options.connection; + this._message = options.message; + this._onResultReceived = options.onResultReceived || (() => {}); + this._wait = options.wait; + this.noRetry = options.noRetry; + this._methodResult = null; + this._dataVisible = false; + + // Register with the connection. + this._connection._methodInvokers[this.methodId] = this; + } + // Sends the method message to the server. May be called additional times if + // we lose the connection and reconnect before receiving a result. + sendMessage() { + // This function is called before sending a method (including resending on + // reconnect). We should only (re)send methods where we don't already have a + // result! + if (this.gotResult()) + throw new Error('sendingMethod is called on method with result'); + + // If we're re-sending it, it doesn't matter if data was written the first + // time. + this._dataVisible = false; + this.sentMessage = true; + + // If this is a wait method, make all data messages be buffered until it is + // done. + if (this._wait) + this._connection._methodsBlockingQuiescence[this.methodId] = true; + + // Actually send the message. + this._connection._send(this._message); + } + // Invoke the callback, if we have both a result and know that all data has + // been written to the local cache. + _maybeInvokeCallback() { + if (this._methodResult && this._dataVisible) { + // Call the callback. (This won't throw: the callback was wrapped with + // bindEnvironment.) + this._callback(this._methodResult[0], this._methodResult[1]); + + // Forget about this method. + delete this._connection._methodInvokers[this.methodId]; + + // Let the connection know that this method is finished, so it can try to + // move on to the next block of methods. + this._connection._outstandingMethodFinished(); + } + } + // Call with the result of the method from the server. Only may be called + // once; once it is called, you should not call sendMessage again. + // If the user provided an onResultReceived callback, call it immediately. + // Then invoke the main callback if data is also visible. + receiveResult(err, result) { + if (this.gotResult()) + throw new Error('Methods should only receive results once'); + this._methodResult = [err, result]; + this._onResultReceived(err, result); + this._maybeInvokeCallback(); + } + // Call this when all data written by the method is visible. This means that + // the method has returns its "data is done" message *AND* all server + // documents that are buffered at that time have been written to the local + // cache. Invokes the main callback if the result has been received. + dataVisible() { + this._dataVisible = true; + this._maybeInvokeCallback(); + } + // True if receiveResult has been called. + gotResult() { + return !!this._methodResult; + } + } \ No newline at end of file diff --git a/packages/webui/src/meteor/ddp/common/livedata_connection.js b/packages/webui/src/meteor/ddp/common/livedata_connection.js new file mode 100644 index 0000000000..4c8ebb1e26 --- /dev/null +++ b/packages/webui/src/meteor/ddp/common/livedata_connection.js @@ -0,0 +1,1755 @@ +import { Meteor } from '../../meteor'; +import { DDPCommon } from '../../ddp-common'; +import { Tracker } from '../../tracker'; +import EJSON from 'ejson'; +import { Random } from '../../random'; +import { Hook } from '../../callback-hook'; +import { MongoID } from '../../mongo-id'; +import { IdMap } from '../../id-map'; +import { Reload } from '../../reload'; +import { DDP } from './namespace.js'; +import { DiffSequence } from '../../diff-sequence'; +import MethodInvoker from './MethodInvoker.js'; +import { + hasOwn, + slice, + keys, + isEmpty, + last, +} from "../../ddp-common/utils.js"; +import { ClientStream } from "../../socket-stream-client" + +let Fiber; +let Future; +// if (Meteor.isServer) { +// Fiber = Npm.require('fibers'); +// Future = Npm.require('fibers/future'); +// } + +class MongoIDMap extends IdMap { + constructor() { + super(MongoID.idStringify, MongoID.idParse); + } +} + +// @param url {String|Object} URL to Meteor app, +// or an object as a test hook (see code) +// Options: +// reloadWithOutstanding: is it OK to reload if there are outstanding methods? +// headers: extra headers to send on the websockets connection, for +// server-to-server DDP only +// _sockjsOptions: Specifies options to pass through to the sockjs client +// onDDPNegotiationVersionFailure: callback when version negotiation fails. +// +// XXX There should be a way to destroy a DDP connection, causing all +// outstanding method calls to fail. +// +// XXX Our current way of handling failure and reconnection is great +// for an app (where we want to tolerate being disconnected as an +// expect state, and keep trying forever to reconnect) but cumbersome +// for something like a command line tool that wants to make a +// connection, call a method, and print an error if connection +// fails. We should have better usability in the latter case (while +// still transparently reconnecting if it's just a transient failure +// or the server migrating us). +export class Connection { + constructor(url, options) { + const self = this; + + this.options = options = { + onConnected() {}, + onDDPVersionNegotiationFailure(description) { + Meteor._debug(description); + }, + heartbeatInterval: 17500, + heartbeatTimeout: 15000, + npmFayeOptions: Object.create(null), + // These options are only for testing. + reloadWithOutstanding: false, + supportedDDPVersions: DDPCommon.SUPPORTED_DDP_VERSIONS, + retry: true, + respondToPings: true, + // When updates are coming within this ms interval, batch them together. + bufferedWritesInterval: 5, + // Flush buffers immediately if writes are happening continuously for more than this many ms. + bufferedWritesMaxAge: 500, + + ...options + }; + + // If set, called when we reconnect, queuing method calls _before_ the + // existing outstanding ones. + // NOTE: This feature has been preserved for backwards compatibility. The + // preferred method of setting a callback on reconnect is to use + // DDP.onReconnect. + self.onReconnect = null; + + // as a test hook, allow passing a stream instead of a url. + if (typeof url === 'object') { + self._stream = url; + } else { + self._stream = new ClientStream(url, { + retry: options.retry, + ConnectionError: DDP.ConnectionError, + headers: options.headers, + _sockjsOptions: options._sockjsOptions, + // Used to keep some tests quiet, or for other cases in which + // the right thing to do with connection errors is to silently + // fail (e.g. sending package usage stats). At some point we + // should have a real API for handling client-stream-level + // errors. + _dontPrintErrors: options._dontPrintErrors, + connectTimeoutMs: options.connectTimeoutMs, + npmFayeOptions: options.npmFayeOptions + }); + } + + self._lastSessionId = null; + self._versionSuggestion = null; // The last proposed DDP version. + self._version = null; // The DDP version agreed on by client and server. + self._stores = Object.create(null); // name -> object with methods + self._methodHandlers = Object.create(null); // name -> func + self._nextMethodId = 1; + self._supportedDDPVersions = options.supportedDDPVersions; + + self._heartbeatInterval = options.heartbeatInterval; + self._heartbeatTimeout = options.heartbeatTimeout; + + // Tracks methods which the user has tried to call but which have not yet + // called their user callback (ie, they are waiting on their result or for all + // of their writes to be written to the local cache). Map from method ID to + // MethodInvoker object. + self._methodInvokers = Object.create(null); + + // Tracks methods which the user has called but whose result messages have not + // arrived yet. + // + // _outstandingMethodBlocks is an array of blocks of methods. Each block + // represents a set of methods that can run at the same time. The first block + // represents the methods which are currently in flight; subsequent blocks + // must wait for previous blocks to be fully finished before they can be sent + // to the server. + // + // Each block is an object with the following fields: + // - methods: a list of MethodInvoker objects + // - wait: a boolean; if true, this block had a single method invoked with + // the "wait" option + // + // There will never be adjacent blocks with wait=false, because the only thing + // that makes methods need to be serialized is a wait method. + // + // Methods are removed from the first block when their "result" is + // received. The entire first block is only removed when all of the in-flight + // methods have received their results (so the "methods" list is empty) *AND* + // all of the data written by those methods are visible in the local cache. So + // it is possible for the first block's methods list to be empty, if we are + // still waiting for some objects to quiesce. + // + // Example: + // _outstandingMethodBlocks = [ + // {wait: false, methods: []}, + // {wait: true, methods: []}, + // {wait: false, methods: [, + // ]}] + // This means that there were some methods which were sent to the server and + // which have returned their results, but some of the data written by + // the methods may not be visible in the local cache. Once all that data is + // visible, we will send a 'login' method. Once the login method has returned + // and all the data is visible (including re-running subs if userId changes), + // we will send the 'foo' and 'bar' methods in parallel. + self._outstandingMethodBlocks = []; + + // method ID -> array of objects with keys 'collection' and 'id', listing + // documents written by a given method's stub. keys are associated with + // methods whose stub wrote at least one document, and whose data-done message + // has not yet been received. + self._documentsWrittenByStub = {}; + // collection -> IdMap of "server document" object. A "server document" has: + // - "document": the version of the document according the + // server (ie, the snapshot before a stub wrote it, amended by any changes + // received from the server) + // It is undefined if we think the document does not exist + // - "writtenByStubs": a set of method IDs whose stubs wrote to the document + // whose "data done" messages have not yet been processed + self._serverDocuments = {}; + + // Array of callbacks to be called after the next update of the local + // cache. Used for: + // - Calling methodInvoker.dataVisible and sub ready callbacks after + // the relevant data is flushed. + // - Invoking the callbacks of "half-finished" methods after reconnect + // quiescence. Specifically, methods whose result was received over the old + // connection (so we don't re-send it) but whose data had not been made + // visible. + self._afterUpdateCallbacks = []; + + // In two contexts, we buffer all incoming data messages and then process them + // all at once in a single update: + // - During reconnect, we buffer all data messages until all subs that had + // been ready before reconnect are ready again, and all methods that are + // active have returned their "data done message"; then + // - During the execution of a "wait" method, we buffer all data messages + // until the wait method gets its "data done" message. (If the wait method + // occurs during reconnect, it doesn't get any special handling.) + // all data messages are processed in one update. + // + // The following fields are used for this "quiescence" process. + + // This buffers the messages that aren't being processed yet. + self._messagesBufferedUntilQuiescence = []; + // Map from method ID -> true. Methods are removed from this when their + // "data done" message is received, and we will not quiesce until it is + // empty. + self._methodsBlockingQuiescence = {}; + // map from sub ID -> true for subs that were ready (ie, called the sub + // ready callback) before reconnect but haven't become ready again yet + self._subsBeingRevived = {}; // map from sub._id -> true + // if true, the next data update should reset all stores. (set during + // reconnect.) + self._resetStores = false; + + // name -> array of updates for (yet to be created) collections + self._updatesForUnknownStores = {}; + // if we're blocking a migration, the retry func + self._retryMigrate = null; + + self.__flushBufferedWrites = Meteor.bindEnvironment( + self._flushBufferedWrites, + 'flushing DDP buffered writes', + self + ); + // Collection name -> array of messages. + self._bufferedWrites = {}; + // When current buffer of updates must be flushed at, in ms timestamp. + self._bufferedWritesFlushAt = null; + // Timeout handle for the next processing of all pending writes + self._bufferedWritesFlushHandle = null; + + self._bufferedWritesInterval = options.bufferedWritesInterval; + self._bufferedWritesMaxAge = options.bufferedWritesMaxAge; + + // metadata for subscriptions. Map from sub ID to object with keys: + // - id + // - name + // - params + // - inactive (if true, will be cleaned up if not reused in re-run) + // - ready (has the 'ready' message been received?) + // - readyCallback (an optional callback to call when ready) + // - errorCallback (an optional callback to call if the sub terminates with + // an error, XXX COMPAT WITH 1.0.3.1) + // - stopCallback (an optional callback to call when the sub terminates + // for any reason, with an error argument if an error triggered the stop) + self._subscriptions = {}; + + // Reactive userId. + self._userId = null; + self._userIdDeps = new Tracker.Dependency(); + + // Block auto-reload while we're waiting for method responses. + if (Meteor.isClient && + //Package.reload && + ! options.reloadWithOutstanding) { + Reload._onMigrate(retry => { + if (! self._readyToMigrate()) { + self._retryMigrate = retry; + return [false]; + } else { + return [true]; + } + }); + } + + const onDisconnect = () => { + if (self._heartbeat) { + self._heartbeat.stop(); + self._heartbeat = null; + } + }; + + if (Meteor.isServer) { + self._stream.on( + 'message', + Meteor.bindEnvironment( + this.onMessage.bind(this), + 'handling DDP message' + ) + ); + self._stream.on( + 'reset', + Meteor.bindEnvironment(this.onReset.bind(this), 'handling DDP reset') + ); + self._stream.on( + 'disconnect', + Meteor.bindEnvironment(onDisconnect, 'handling DDP disconnect') + ); + } else { + self._stream.on('message', this.onMessage.bind(this)); + self._stream.on('reset', this.onReset.bind(this)); + self._stream.on('disconnect', onDisconnect); + } + } + + // 'name' is the name of the data on the wire that should go in the + // store. 'wrappedStore' should be an object with methods beginUpdate, update, + // endUpdate, saveOriginals, retrieveOriginals. see Collection for an example. + registerStore(name, wrappedStore) { + const self = this; + + if (name in self._stores) return false; + + // Wrap the input object in an object which makes any store method not + // implemented by 'store' into a no-op. + const store = Object.create(null); + const keysOfStore = [ + 'update', + 'beginUpdate', + 'endUpdate', + 'saveOriginals', + 'retrieveOriginals', + 'getDoc', + '_getCollection' + ]; + keysOfStore.forEach((method) => { + store[method] = (...args) => { + if (wrappedStore[method]) { + return wrappedStore[method](...args); + } + }; + }); + self._stores[name] = store; + + const queued = self._updatesForUnknownStores[name]; + if (Array.isArray(queued)) { + store.beginUpdate(queued.length, false); + queued.forEach(msg => { + store.update(msg); + }); + store.endUpdate(); + delete self._updatesForUnknownStores[name]; + } + + return true; + } + + /** + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.subscribe + * @summary Subscribe to a record set. Returns a handle that provides + * `stop()` and `ready()` methods. + * @locus Client + * @param {String} name Name of the subscription. Matches the name of the + * server's `publish()` call. + * @param {EJSONable} [arg1,arg2...] Optional arguments passed to publisher + * function on server. + * @param {Function|Object} [callbacks] Optional. May include `onStop` + * and `onReady` callbacks. If there is an error, it is passed as an + * argument to `onStop`. If a function is passed instead of an object, it + * is interpreted as an `onReady` callback. + */ + subscribe(name /* .. [arguments] .. (callback|callbacks) */) { + const self = this; + + const params = slice.call(arguments, 1); + let callbacks = Object.create(null); + if (params.length) { + const lastParam = params[params.length - 1]; + if (typeof lastParam === 'function') { + callbacks.onReady = params.pop(); + } else if (lastParam && [ + lastParam.onReady, + // XXX COMPAT WITH 1.0.3.1 onError used to exist, but now we use + // onStop with an error callback instead. + lastParam.onError, + lastParam.onStop + ].some(f => typeof f === "function")) { + callbacks = params.pop(); + } + } + + // Is there an existing sub with the same name and param, run in an + // invalidated Computation? This will happen if we are rerunning an + // existing computation. + // + // For example, consider a rerun of: + // + // Tracker.autorun(function () { + // Meteor.subscribe("foo", Session.get("foo")); + // Meteor.subscribe("bar", Session.get("bar")); + // }); + // + // If "foo" has changed but "bar" has not, we will match the "bar" + // subcribe to an existing inactive subscription in order to not + // unsub and resub the subscription unnecessarily. + // + // We only look for one such sub; if there are N apparently-identical subs + // being invalidated, we will require N matching subscribe calls to keep + // them all active. + const existing = Object.values(self._subscriptions).find( + sub => (sub.inactive && sub.name === name && EJSON.equals(sub.params, params)) + ); + + let id; + if (existing) { + id = existing.id; + existing.inactive = false; // reactivate + + if (callbacks.onReady) { + // If the sub is not already ready, replace any ready callback with the + // one provided now. (It's not really clear what users would expect for + // an onReady callback inside an autorun; the semantics we provide is + // that at the time the sub first becomes ready, we call the last + // onReady callback provided, if any.) + // If the sub is already ready, run the ready callback right away. + // It seems that users would expect an onReady callback inside an + // autorun to trigger once the the sub first becomes ready and also + // when re-subs happens. + if (existing.ready) { + callbacks.onReady(); + } else { + existing.readyCallback = callbacks.onReady; + } + } + + // XXX COMPAT WITH 1.0.3.1 we used to have onError but now we call + // onStop with an optional error argument + if (callbacks.onError) { + // Replace existing callback if any, so that errors aren't + // double-reported. + existing.errorCallback = callbacks.onError; + } + + if (callbacks.onStop) { + existing.stopCallback = callbacks.onStop; + } + } else { + // New sub! Generate an id, save it locally, and send message. + id = Random.id(); + self._subscriptions[id] = { + id: id, + name: name, + params: EJSON.clone(params), + inactive: false, + ready: false, + readyDeps: new Tracker.Dependency(), + readyCallback: callbacks.onReady, + // XXX COMPAT WITH 1.0.3.1 #errorCallback + errorCallback: callbacks.onError, + stopCallback: callbacks.onStop, + connection: self, + remove() { + delete this.connection._subscriptions[this.id]; + this.ready && this.readyDeps.changed(); + }, + stop() { + this.connection._send({ msg: 'unsub', id: id }); + this.remove(); + + if (callbacks.onStop) { + callbacks.onStop(); + } + } + }; + self._send({ msg: 'sub', id: id, name: name, params: params }); + } + + // return a handle to the application. + const handle = { + stop() { + if (! hasOwn.call(self._subscriptions, id)) { + return; + } + self._subscriptions[id].stop(); + }, + ready() { + // return false if we've unsubscribed. + if (!hasOwn.call(self._subscriptions, id)) { + return false; + } + const record = self._subscriptions[id]; + record.readyDeps.depend(); + return record.ready; + }, + subscriptionId: id + }; + + if (Tracker.active) { + // We're in a reactive computation, so we'd like to unsubscribe when the + // computation is invalidated... but not if the rerun just re-subscribes + // to the same subscription! When a rerun happens, we use onInvalidate + // as a change to mark the subscription "inactive" so that it can + // be reused from the rerun. If it isn't reused, it's killed from + // an afterFlush. + Tracker.onInvalidate((c) => { + if (hasOwn.call(self._subscriptions, id)) { + self._subscriptions[id].inactive = true; + } + + Tracker.afterFlush(() => { + if (hasOwn.call(self._subscriptions, id) && + self._subscriptions[id].inactive) { + handle.stop(); + } + }); + }); + } + + return handle; + } + + // options: + // - onLateError {Function(error)} called if an error was received after the ready event. + // (errors received before ready cause an error to be thrown) + _subscribeAndWait(name, args, options) { + const self = this; + const f = new Future(); + let ready = false; + args = args || []; + args.push({ + onReady() { + ready = true; + f['return'](); + }, + onError(e) { + if (!ready) f['throw'](e); + else options && options.onLateError && options.onLateError(e); + } + }); + + const handle = self.subscribe.apply(self, [name].concat(args)); + f.wait(); + return handle; + } + + methods(methods) { + Object.entries(methods).forEach(([name, func]) => { + if (typeof func !== 'function') { + throw new Error("Method '" + name + "' must be a function"); + } + if (this._methodHandlers[name]) { + throw new Error("A method named '" + name + "' is already defined"); + } + this._methodHandlers[name] = func; + }); + } + + /** + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.call + * @summary Invokes a method passing any number of arguments. + * @locus Anywhere + * @param {String} name Name of method to invoke + * @param {EJSONable} [arg1,arg2...] Optional method arguments + * @param {Function} [asyncCallback] Optional callback, which is called asynchronously with the error or result after the method is complete. If not provided, the method runs synchronously if possible (see below). + */ + call(name /* .. [arguments] .. callback */) { + // if it's a function, the last argument is the result callback, + // not a parameter to the remote method. + const args = slice.call(arguments, 1); + let callback; + if (args.length && typeof args[args.length - 1] === 'function') { + callback = args.pop(); + } + return this.apply(name, args, callback); + } + + /** + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.apply + * @summary Invoke a method passing an array of arguments. + * @locus Anywhere + * @param {String} name Name of method to invoke + * @param {EJSONable[]} args Method arguments + * @param {Object} [options] + * @param {Boolean} options.wait (Client only) If true, don't send this method until all previous method calls have completed, and don't send any subsequent method calls until this one is completed. + * @param {Function} options.onResultReceived (Client only) This callback is invoked with the error or result of the method (just like `asyncCallback`) as soon as the error or result is available. The local cache may not yet reflect the writes performed by the method. + * @param {Boolean} options.noRetry (Client only) if true, don't send this method again on reload, simply call the callback an error with the error code 'invocation-failed'. + * @param {Boolean} options.throwStubExceptions (Client only) If true, exceptions thrown by method stubs will be thrown instead of logged, and the method will not be invoked on the server. + * @param {Boolean} options.returnStubValue (Client only) If true then in cases where we would have otherwise discarded the stub's return value and returned undefined, instead we go ahead and return it. Specifically, this is any time other than when (a) we are already inside a stub or (b) we are in Node and no callback was provided. Currently we require this flag to be explicitly passed to reduce the likelihood that stub return values will be confused with server return values; we may improve this in future. + * @param {Function} [asyncCallback] Optional callback; same semantics as in [`Meteor.call`](#meteor_call). + */ + apply(name, args, options, callback) { + const self = this; + + // We were passed 3 arguments. They may be either (name, args, options) + // or (name, args, callback) + if (!callback && typeof options === 'function') { + callback = options; + options = Object.create(null); + } + options = options || Object.create(null); + + if (callback) { + // XXX would it be better form to do the binding in stream.on, + // or caller, instead of here? + // XXX improve error message (and how we report it) + callback = Meteor.bindEnvironment( + callback, + "delivering result of invoking '" + name + "'" + ); + } + + // Keep our args safe from mutation (eg if we don't send the message for a + // while because of a wait method). + args = EJSON.clone(args); + + const enclosing = DDP._CurrentMethodInvocation.get(); + const alreadyInSimulation = enclosing && enclosing.isSimulation; + + // Lazily generate a randomSeed, only if it is requested by the stub. + // The random streams only have utility if they're used on both the client + // and the server; if the client doesn't generate any 'random' values + // then we don't expect the server to generate any either. + // Less commonly, the server may perform different actions from the client, + // and may in fact generate values where the client did not, but we don't + // have any client-side values to match, so even here we may as well just + // use a random seed on the server. In that case, we don't pass the + // randomSeed to save bandwidth, and we don't even generate it to save a + // bit of CPU and to avoid consuming entropy. + let randomSeed = null; + const randomSeedGenerator = () => { + if (randomSeed === null) { + randomSeed = DDPCommon.makeRpcSeed(enclosing, name); + } + return randomSeed; + }; + + // Run the stub, if we have one. The stub is supposed to make some + // temporary writes to the database to give the user a smooth experience + // until the actual result of executing the method comes back from the + // server (whereupon the temporary writes to the database will be reversed + // during the beginUpdate/endUpdate process.) + // + // Normally, we ignore the return value of the stub (even if it is an + // exception), in favor of the real return value from the server. The + // exception is if the *caller* is a stub. In that case, we're not going + // to do a RPC, so we use the return value of the stub as our return + // value. + + let stubReturnValue; + let exception; + const stub = self._methodHandlers[name]; + if (stub) { + const setUserId = userId => { + self.setUserId(userId); + }; + + const invocation = new DDPCommon.MethodInvocation({ + isSimulation: true, + userId: self.userId(), + setUserId: setUserId, + randomSeed() { + return randomSeedGenerator(); + } + }); + + if (!alreadyInSimulation) self._saveOriginals(); + + try { + // Note that unlike in the corresponding server code, we never audit + // that stubs check() their arguments. + stubReturnValue = DDP._CurrentMethodInvocation.withValue( + invocation, + () => { + if (Meteor.isServer) { + // Because saveOriginals and retrieveOriginals aren't reentrant, + // don't allow stubs to yield. + return Meteor._noYieldsAllowed(() => { + // re-clone, so that the stub can't affect our caller's values + return stub.apply(invocation, EJSON.clone(args)); + }); + } else { + return stub.apply(invocation, EJSON.clone(args)); + } + } + ); + } catch (e) { + exception = e; + } + } + + // If we're in a simulation, stop and return the result we have, + // rather than going on to do an RPC. If there was no stub, + // we'll end up returning undefined. + if (alreadyInSimulation) { + if (callback) { + callback(exception, stubReturnValue); + return undefined; + } + if (exception) throw exception; + return stubReturnValue; + } + + // We only create the methodId here because we don't actually need one if + // we're already in a simulation + const methodId = '' + self._nextMethodId++; + if (stub) { + self._retrieveAndStoreOriginals(methodId); + } + + // Generate the DDP message for the method call. Note that on the client, + // it is important that the stub have finished before we send the RPC, so + // that we know we have a complete list of which local documents the stub + // wrote. + const message = { + msg: 'method', + id: methodId, + method: name, + params: args + }; + + // If an exception occurred in a stub, and we're ignoring it + // because we're doing an RPC and want to use what the server + // returns instead, log it so the developer knows + // (unless they explicitly ask to see the error). + // + // Tests can set the '_expectedByTest' flag on an exception so it won't + // go to log. + if (exception) { + if (options.throwStubExceptions) { + throw exception; + } else if (!exception._expectedByTest) { + Meteor._debug( + "Exception while simulating the effect of invoking '" + name + "'", + exception + ); + } + } + + // At this point we're definitely doing an RPC, and we're going to + // return the value of the RPC to the caller. + + // If the caller didn't give a callback, decide what to do. + let future; + if (!callback) { + if (Meteor.isClient) { + // On the client, we don't have fibers, so we can't block. The + // only thing we can do is to return undefined and discard the + // result of the RPC. If an error occurred then print the error + // to the console. + callback = err => { + err && Meteor._debug("Error invoking Method '" + name + "'", err); + }; + } else { + // On the server, make the function synchronous. Throw on + // errors, return on success. + future = new Future(); + callback = future.resolver(); + } + } + + // Send the randomSeed only if we used it + if (randomSeed !== null) { + message.randomSeed = randomSeed; + } + + const methodInvoker = new MethodInvoker({ + methodId, + callback: callback, + connection: self, + onResultReceived: options.onResultReceived, + wait: !!options.wait, + message: message, + noRetry: !!options.noRetry + }); + + if (options.wait) { + // It's a wait method! Wait methods go in their own block. + self._outstandingMethodBlocks.push({ + wait: true, + methods: [methodInvoker] + }); + } else { + // Not a wait method. Start a new block if the previous block was a wait + // block, and add it to the last block of methods. + if (isEmpty(self._outstandingMethodBlocks) || + last(self._outstandingMethodBlocks).wait) { + self._outstandingMethodBlocks.push({ + wait: false, + methods: [], + }); + } + + last(self._outstandingMethodBlocks).methods.push(methodInvoker); + } + + // If we added it to the first block, send it out now. + if (self._outstandingMethodBlocks.length === 1) methodInvoker.sendMessage(); + + // If we're using the default callback on the server, + // block waiting for the result. + if (future) { + return future.wait(); + } + return options.returnStubValue ? stubReturnValue : undefined; + } + + // Before calling a method stub, prepare all stores to track changes and allow + // _retrieveAndStoreOriginals to get the original versions of changed + // documents. + _saveOriginals() { + if (! this._waitingForQuiescence()) { + this._flushBufferedWrites(); + } + + Object.values(this._stores).forEach((store) => { + store.saveOriginals(); + }); + } + + // Retrieves the original versions of all documents modified by the stub for + // method 'methodId' from all stores and saves them to _serverDocuments (keyed + // by document) and _documentsWrittenByStub (keyed by method ID). + _retrieveAndStoreOriginals(methodId) { + const self = this; + if (self._documentsWrittenByStub[methodId]) + throw new Error('Duplicate methodId in _retrieveAndStoreOriginals'); + + const docsWritten = []; + + Object.entries(self._stores).forEach(([collection, store]) => { + const originals = store.retrieveOriginals(); + // not all stores define retrieveOriginals + if (! originals) return; + originals.forEach((doc, id) => { + docsWritten.push({ collection, id }); + if (! hasOwn.call(self._serverDocuments, collection)) { + self._serverDocuments[collection] = new MongoIDMap(); + } + const serverDoc = self._serverDocuments[collection].setDefault( + id, + Object.create(null) + ); + if (serverDoc.writtenByStubs) { + // We're not the first stub to write this doc. Just add our method ID + // to the record. + serverDoc.writtenByStubs[methodId] = true; + } else { + // First stub! Save the original value and our method ID. + serverDoc.document = doc; + serverDoc.flushCallbacks = []; + serverDoc.writtenByStubs = Object.create(null); + serverDoc.writtenByStubs[methodId] = true; + } + }); + }); + if (! isEmpty(docsWritten)) { + self._documentsWrittenByStub[methodId] = docsWritten; + } + } + + // This is very much a private function we use to make the tests + // take up fewer server resources after they complete. + _unsubscribeAll() { + Object.values(this._subscriptions).forEach((sub) => { + // Avoid killing the autoupdate subscription so that developers + // still get hot code pushes when writing tests. + // + // XXX it's a hack to encode knowledge about autoupdate here, + // but it doesn't seem worth it yet to have a special API for + // subscriptions to preserve after unit tests. + if (sub.name !== 'meteor_autoupdate_clientVersions') { + sub.stop(); + } + }); + } + + // Sends the DDP stringification of the given message object + _send(obj) { + this._stream.send(DDPCommon.stringifyDDP(obj)); + } + + // We detected via DDP-level heartbeats that we've lost the + // connection. Unlike `disconnect` or `close`, a lost connection + // will be automatically retried. + _lostConnection(error) { + this._stream._lostConnection(error); + } + + /** + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.status + * @summary Get the current connection status. A reactive data source. + * @locus Client + */ + status(...args) { + return this._stream.status(...args); + } + + /** + * @summary Force an immediate reconnection attempt if the client is not connected to the server. + + This method does nothing if the client is already connected. + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.reconnect + * @locus Client + */ + reconnect(...args) { + return this._stream.reconnect(...args); + } + + /** + * @memberOf Meteor + * @importFromPackage meteor + * @alias Meteor.disconnect + * @summary Disconnect the client from the server. + * @locus Client + */ + disconnect(...args) { + return this._stream.disconnect(...args); + } + + close() { + return this._stream.disconnect({ _permanent: true }); + } + + /// + /// Reactive user system + /// + userId() { + if (this._userIdDeps) this._userIdDeps.depend(); + return this._userId; + } + + setUserId(userId) { + // Avoid invalidating dependents if setUserId is called with current value. + if (this._userId === userId) return; + this._userId = userId; + if (this._userIdDeps) this._userIdDeps.changed(); + } + + // Returns true if we are in a state after reconnect of waiting for subs to be + // revived or early methods to finish their data, or we are waiting for a + // "wait" method to finish. + _waitingForQuiescence() { + return ( + ! isEmpty(this._subsBeingRevived) || + ! isEmpty(this._methodsBlockingQuiescence) + ); + } + + // Returns true if any method whose message has been sent to the server has + // not yet invoked its user callback. + _anyMethodsAreOutstanding() { + const invokers = this._methodInvokers; + return Object.values(invokers).some((invoker) => !!invoker.sentMessage); + } + + _livedata_connected(msg) { + const self = this; + + if (self._version !== 'pre1' && self._heartbeatInterval !== 0) { + self._heartbeat = new DDPCommon.Heartbeat({ + heartbeatInterval: self._heartbeatInterval, + heartbeatTimeout: self._heartbeatTimeout, + onTimeout() { + self._lostConnection( + new DDP.ConnectionError('DDP heartbeat timed out') + ); + }, + sendPing() { + self._send({ msg: 'ping' }); + } + }); + self._heartbeat.start(); + } + + // If this is a reconnect, we'll have to reset all stores. + if (self._lastSessionId) self._resetStores = true; + + let reconnectedToPreviousSession; + if (typeof msg.session === 'string') { + reconnectedToPreviousSession = self._lastSessionId === msg.session; + self._lastSessionId = msg.session; + } + + if (reconnectedToPreviousSession) { + // Successful reconnection -- pick up where we left off. Note that right + // now, this never happens: the server never connects us to a previous + // session, because DDP doesn't provide enough data for the server to know + // what messages the client has processed. We need to improve DDP to make + // this possible, at which point we'll probably need more code here. + return; + } + + // Server doesn't have our data any more. Re-sync a new session. + + // Forget about messages we were buffering for unknown collections. They'll + // be resent if still relevant. + self._updatesForUnknownStores = Object.create(null); + + if (self._resetStores) { + // Forget about the effects of stubs. We'll be resetting all collections + // anyway. + self._documentsWrittenByStub = Object.create(null); + self._serverDocuments = Object.create(null); + } + + // Clear _afterUpdateCallbacks. + self._afterUpdateCallbacks = []; + + // Mark all named subscriptions which are ready (ie, we already called the + // ready callback) as needing to be revived. + // XXX We should also block reconnect quiescence until unnamed subscriptions + // (eg, autopublish) are done re-publishing to avoid flicker! + self._subsBeingRevived = Object.create(null); + Object.entries(self._subscriptions).forEach(([id, sub]) => { + if (sub.ready) { + self._subsBeingRevived[id] = true; + } + }); + + // Arrange for "half-finished" methods to have their callbacks run, and + // track methods that were sent on this connection so that we don't + // quiesce until they are all done. + // + // Start by clearing _methodsBlockingQuiescence: methods sent before + // reconnect don't matter, and any "wait" methods sent on the new connection + // that we drop here will be restored by the loop below. + self._methodsBlockingQuiescence = Object.create(null); + if (self._resetStores) { + const invokers = self._methodInvokers; + keys(invokers).forEach(id => { + const invoker = invokers[id]; + if (invoker.gotResult()) { + // This method already got its result, but it didn't call its callback + // because its data didn't become visible. We did not resend the + // method RPC. We'll call its callback when we get a full quiesce, + // since that's as close as we'll get to "data must be visible". + self._afterUpdateCallbacks.push( + (...args) => invoker.dataVisible(...args) + ); + } else if (invoker.sentMessage) { + // This method has been sent on this connection (maybe as a resend + // from the last connection, maybe from onReconnect, maybe just very + // quickly before processing the connected message). + // + // We don't need to do anything special to ensure its callbacks get + // called, but we'll count it as a method which is preventing + // reconnect quiescence. (eg, it might be a login method that was run + // from onReconnect, and we don't want to see flicker by seeing a + // logged-out state.) + self._methodsBlockingQuiescence[invoker.methodId] = true; + } + }); + } + + self._messagesBufferedUntilQuiescence = []; + + // If we're not waiting on any methods or subs, we can reset the stores and + // call the callbacks immediately. + if (! self._waitingForQuiescence()) { + if (self._resetStores) { + Object.values(self._stores).forEach((store) => { + store.beginUpdate(0, true); + store.endUpdate(); + }); + self._resetStores = false; + } + self._runAfterUpdateCallbacks(); + } + } + + _processOneDataMessage(msg, updates) { + const messageType = msg.msg; + + // msg is one of ['added', 'changed', 'removed', 'ready', 'updated'] + if (messageType === 'added') { + this._process_added(msg, updates); + } else if (messageType === 'changed') { + this._process_changed(msg, updates); + } else if (messageType === 'removed') { + this._process_removed(msg, updates); + } else if (messageType === 'ready') { + this._process_ready(msg, updates); + } else if (messageType === 'updated') { + this._process_updated(msg, updates); + } else if (messageType === 'nosub') { + // ignore this + } else { + Meteor._debug('discarding unknown livedata data message type', msg); + } + } + + _livedata_data(msg) { + const self = this; + + if (self._waitingForQuiescence()) { + self._messagesBufferedUntilQuiescence.push(msg); + + if (msg.msg === 'nosub') { + delete self._subsBeingRevived[msg.id]; + } + + if (msg.subs) { + msg.subs.forEach(subId => { + delete self._subsBeingRevived[subId]; + }); + } + + if (msg.methods) { + msg.methods.forEach(methodId => { + delete self._methodsBlockingQuiescence[methodId]; + }); + } + + if (self._waitingForQuiescence()) { + return; + } + + // No methods or subs are blocking quiescence! + // We'll now process and all of our buffered messages, reset all stores, + // and apply them all at once. + + const bufferedMessages = self._messagesBufferedUntilQuiescence; + Object.values(bufferedMessages).forEach(bufferedMessage => { + self._processOneDataMessage( + bufferedMessage, + self._bufferedWrites + ); + }); + + self._messagesBufferedUntilQuiescence = []; + + } else { + self._processOneDataMessage(msg, self._bufferedWrites); + } + + // Immediately flush writes when: + // 1. Buffering is disabled. Or; + // 2. any non-(added/changed/removed) message arrives. + const standardWrite = + msg.msg === "added" || + msg.msg === "changed" || + msg.msg === "removed"; + + if (self._bufferedWritesInterval === 0 || ! standardWrite) { + self._flushBufferedWrites(); + return; + } + + if (self._bufferedWritesFlushAt === null) { + self._bufferedWritesFlushAt = + new Date().valueOf() + self._bufferedWritesMaxAge; + } else if (self._bufferedWritesFlushAt < new Date().valueOf()) { + self._flushBufferedWrites(); + return; + } + + if (self._bufferedWritesFlushHandle) { + clearTimeout(self._bufferedWritesFlushHandle); + } + self._bufferedWritesFlushHandle = setTimeout( + self.__flushBufferedWrites, + self._bufferedWritesInterval + ); + } + + _flushBufferedWrites() { + const self = this; + if (self._bufferedWritesFlushHandle) { + clearTimeout(self._bufferedWritesFlushHandle); + self._bufferedWritesFlushHandle = null; + } + + self._bufferedWritesFlushAt = null; + // We need to clear the buffer before passing it to + // performWrites. As there's no guarantee that it + // will exit cleanly. + const writes = self._bufferedWrites; + self._bufferedWrites = Object.create(null); + self._performWrites(writes); + } + + _performWrites(updates) { + const self = this; + + if (self._resetStores || ! isEmpty(updates)) { + // Begin a transactional update of each store. + + Object.entries(self._stores).forEach(([storeName, store]) => { + store.beginUpdate( + hasOwn.call(updates, storeName) + ? updates[storeName].length + : 0, + self._resetStores + ); + }); + + self._resetStores = false; + + Object.entries(updates).forEach(([storeName, updateMessages]) => { + const store = self._stores[storeName]; + if (store) { + updateMessages.forEach(updateMessage => { + store.update(updateMessage); + }); + } else { + // Nobody's listening for this data. Queue it up until + // someone wants it. + // XXX memory use will grow without bound if you forget to + // create a collection or just don't care about it... going + // to have to do something about that. + const updates = self._updatesForUnknownStores; + + if (! hasOwn.call(updates, storeName)) { + updates[storeName] = []; + } + + updates[storeName].push(...updateMessages); + } + }); + + // End update transaction. + Object.values(self._stores).forEach((store) => { + store.endUpdate(); + }); + } + + self._runAfterUpdateCallbacks(); + } + + // Call any callbacks deferred with _runWhenAllServerDocsAreFlushed whose + // relevant docs have been flushed, as well as dataVisible callbacks at + // reconnect-quiescence time. + _runAfterUpdateCallbacks() { + const self = this; + const callbacks = self._afterUpdateCallbacks; + self._afterUpdateCallbacks = []; + callbacks.forEach((c) => { + c(); + }); + } + + _pushUpdate(updates, collection, msg) { + if (! hasOwn.call(updates, collection)) { + updates[collection] = []; + } + updates[collection].push(msg); + } + + _getServerDoc(collection, id) { + const self = this; + if (! hasOwn.call(self._serverDocuments, collection)) { + return null; + } + const serverDocsForCollection = self._serverDocuments[collection]; + return serverDocsForCollection.get(id) || null; + } + + _process_added(msg, updates) { + const self = this; + const id = MongoID.idParse(msg.id); + const serverDoc = self._getServerDoc(msg.collection, id); + if (serverDoc) { + // Some outstanding stub wrote here. + const isExisting = serverDoc.document !== undefined; + + serverDoc.document = msg.fields || Object.create(null); + serverDoc.document._id = id; + + if (self._resetStores) { + // During reconnect the server is sending adds for existing ids. + // Always push an update so that document stays in the store after + // reset. Use current version of the document for this update, so + // that stub-written values are preserved. + const currentDoc = self._stores[msg.collection].getDoc(msg.id); + if (currentDoc !== undefined) msg.fields = currentDoc; + + self._pushUpdate(updates, msg.collection, msg); + } else if (isExisting) { + throw new Error('Server sent add for existing id: ' + msg.id); + } + } else { + self._pushUpdate(updates, msg.collection, msg); + } + } + + _process_changed(msg, updates) { + const self = this; + const serverDoc = self._getServerDoc(msg.collection, MongoID.idParse(msg.id)); + if (serverDoc) { + if (serverDoc.document === undefined) + throw new Error('Server sent changed for nonexisting id: ' + msg.id); + DiffSequence.applyChanges(serverDoc.document, msg.fields); + } else { + self._pushUpdate(updates, msg.collection, msg); + } + } + + _process_removed(msg, updates) { + const self = this; + const serverDoc = self._getServerDoc(msg.collection, MongoID.idParse(msg.id)); + if (serverDoc) { + // Some outstanding stub wrote here. + if (serverDoc.document === undefined) + throw new Error('Server sent removed for nonexisting id:' + msg.id); + serverDoc.document = undefined; + } else { + self._pushUpdate(updates, msg.collection, { + msg: 'removed', + collection: msg.collection, + id: msg.id + }); + } + } + + _process_updated(msg, updates) { + const self = this; + // Process "method done" messages. + + msg.methods.forEach((methodId) => { + const docs = self._documentsWrittenByStub[methodId] || {}; + Object.values(docs).forEach((written) => { + const serverDoc = self._getServerDoc(written.collection, written.id); + if (! serverDoc) { + throw new Error('Lost serverDoc for ' + JSON.stringify(written)); + } + if (! serverDoc.writtenByStubs[methodId]) { + throw new Error( + 'Doc ' + + JSON.stringify(written) + + ' not written by method ' + + methodId + ); + } + delete serverDoc.writtenByStubs[methodId]; + if (isEmpty(serverDoc.writtenByStubs)) { + // All methods whose stubs wrote this method have completed! We can + // now copy the saved document to the database (reverting the stub's + // change if the server did not write to this object, or applying the + // server's writes if it did). + + // This is a fake ddp 'replace' message. It's just for talking + // between livedata connections and minimongo. (We have to stringify + // the ID because it's supposed to look like a wire message.) + self._pushUpdate(updates, written.collection, { + msg: 'replace', + id: MongoID.idStringify(written.id), + replace: serverDoc.document + }); + // Call all flush callbacks. + + serverDoc.flushCallbacks.forEach((c) => { + c(); + }); + + // Delete this completed serverDocument. Don't bother to GC empty + // IdMaps inside self._serverDocuments, since there probably aren't + // many collections and they'll be written repeatedly. + self._serverDocuments[written.collection].remove(written.id); + } + }); + delete self._documentsWrittenByStub[methodId]; + + // We want to call the data-written callback, but we can't do so until all + // currently buffered messages are flushed. + const callbackInvoker = self._methodInvokers[methodId]; + if (! callbackInvoker) { + throw new Error('No callback invoker for method ' + methodId); + } + + self._runWhenAllServerDocsAreFlushed( + (...args) => callbackInvoker.dataVisible(...args) + ); + }); + } + + _process_ready(msg, updates) { + const self = this; + // Process "sub ready" messages. "sub ready" messages don't take effect + // until all current server documents have been flushed to the local + // database. We can use a write fence to implement this. + + msg.subs.forEach((subId) => { + self._runWhenAllServerDocsAreFlushed(() => { + const subRecord = self._subscriptions[subId]; + // Did we already unsubscribe? + if (!subRecord) return; + // Did we already receive a ready message? (Oops!) + if (subRecord.ready) return; + subRecord.ready = true; + subRecord.readyCallback && subRecord.readyCallback(); + subRecord.readyDeps.changed(); + }); + }); + } + + // Ensures that "f" will be called after all documents currently in + // _serverDocuments have been written to the local cache. f will not be called + // if the connection is lost before then! + _runWhenAllServerDocsAreFlushed(f) { + const self = this; + const runFAfterUpdates = () => { + self._afterUpdateCallbacks.push(f); + }; + let unflushedServerDocCount = 0; + const onServerDocFlush = () => { + --unflushedServerDocCount; + if (unflushedServerDocCount === 0) { + // This was the last doc to flush! Arrange to run f after the updates + // have been applied. + runFAfterUpdates(); + } + }; + + Object.values(self._serverDocuments).forEach((serverDocuments) => { + serverDocuments.forEach((serverDoc) => { + const writtenByStubForAMethodWithSentMessage = + keys(serverDoc.writtenByStubs).some(methodId => { + const invoker = self._methodInvokers[methodId]; + return invoker && invoker.sentMessage; + }); + + if (writtenByStubForAMethodWithSentMessage) { + ++unflushedServerDocCount; + serverDoc.flushCallbacks.push(onServerDocFlush); + } + }); + }); + if (unflushedServerDocCount === 0) { + // There aren't any buffered docs --- we can call f as soon as the current + // round of updates is applied! + runFAfterUpdates(); + } + } + + _livedata_nosub(msg) { + const self = this; + + // First pass it through _livedata_data, which only uses it to help get + // towards quiescence. + self._livedata_data(msg); + + // Do the rest of our processing immediately, with no + // buffering-until-quiescence. + + // we weren't subbed anyway, or we initiated the unsub. + if (! hasOwn.call(self._subscriptions, msg.id)) { + return; + } + + // XXX COMPAT WITH 1.0.3.1 #errorCallback + const errorCallback = self._subscriptions[msg.id].errorCallback; + const stopCallback = self._subscriptions[msg.id].stopCallback; + + self._subscriptions[msg.id].remove(); + + const meteorErrorFromMsg = msgArg => { + return ( + msgArg && + msgArg.error && + new Meteor.Error( + msgArg.error.error, + msgArg.error.reason, + msgArg.error.details + ) + ); + }; + + // XXX COMPAT WITH 1.0.3.1 #errorCallback + if (errorCallback && msg.error) { + errorCallback(meteorErrorFromMsg(msg)); + } + + if (stopCallback) { + stopCallback(meteorErrorFromMsg(msg)); + } + } + + _livedata_result(msg) { + // id, result or error. error has error (code), reason, details + + const self = this; + + // Lets make sure there are no buffered writes before returning result. + if (! isEmpty(self._bufferedWrites)) { + self._flushBufferedWrites(); + } + + // find the outstanding request + // should be O(1) in nearly all realistic use cases + if (isEmpty(self._outstandingMethodBlocks)) { + Meteor._debug('Received method result but no methods outstanding'); + return; + } + const currentMethodBlock = self._outstandingMethodBlocks[0].methods; + let i; + const m = currentMethodBlock.find((method, idx) => { + const found = method.methodId === msg.id; + if (found) i = idx; + return found; + }); + if (!m) { + Meteor._debug("Can't match method response to original method call", msg); + return; + } + + // Remove from current method block. This may leave the block empty, but we + // don't move on to the next block until the callback has been delivered, in + // _outstandingMethodFinished. + currentMethodBlock.splice(i, 1); + + if (hasOwn.call(msg, 'error')) { + m.receiveResult( + new Meteor.Error(msg.error.error, msg.error.reason, msg.error.details) + ); + } else { + // msg.result may be undefined if the method didn't return a + // value + m.receiveResult(undefined, msg.result); + } + } + + // Called by MethodInvoker after a method's callback is invoked. If this was + // the last outstanding method in the current block, runs the next block. If + // there are no more methods, consider accepting a hot code push. + _outstandingMethodFinished() { + const self = this; + if (self._anyMethodsAreOutstanding()) return; + + // No methods are outstanding. This should mean that the first block of + // methods is empty. (Or it might not exist, if this was a method that + // half-finished before disconnect/reconnect.) + if (! isEmpty(self._outstandingMethodBlocks)) { + const firstBlock = self._outstandingMethodBlocks.shift(); + if (! isEmpty(firstBlock.methods)) + throw new Error( + 'No methods outstanding but nonempty block: ' + + JSON.stringify(firstBlock) + ); + + // Send the outstanding methods now in the first block. + if (! isEmpty(self._outstandingMethodBlocks)) + self._sendOutstandingMethods(); + } + + // Maybe accept a hot code push. + self._maybeMigrate(); + } + + // Sends messages for all the methods in the first block in + // _outstandingMethodBlocks. + _sendOutstandingMethods() { + const self = this; + + if (isEmpty(self._outstandingMethodBlocks)) { + return; + } + + self._outstandingMethodBlocks[0].methods.forEach(m => { + m.sendMessage(); + }); + } + + _livedata_error(msg) { + Meteor._debug('Received error from server: ', msg.reason); + if (msg.offendingMessage) Meteor._debug('For: ', msg.offendingMessage); + } + + _callOnReconnectAndSendAppropriateOutstandingMethods() { + const self = this; + const oldOutstandingMethodBlocks = self._outstandingMethodBlocks; + self._outstandingMethodBlocks = []; + + self.onReconnect && self.onReconnect(); + DDP._reconnectHook.each(callback => { + callback(self); + return true; + }); + + if (isEmpty(oldOutstandingMethodBlocks)) return; + + // We have at least one block worth of old outstanding methods to try + // again. First: did onReconnect actually send anything? If not, we just + // restore all outstanding methods and run the first block. + if (isEmpty(self._outstandingMethodBlocks)) { + self._outstandingMethodBlocks = oldOutstandingMethodBlocks; + self._sendOutstandingMethods(); + return; + } + + // OK, there are blocks on both sides. Special case: merge the last block of + // the reconnect methods with the first block of the original methods, if + // neither of them are "wait" blocks. + if (! last(self._outstandingMethodBlocks).wait && + ! oldOutstandingMethodBlocks[0].wait) { + oldOutstandingMethodBlocks[0].methods.forEach(m => { + last(self._outstandingMethodBlocks).methods.push(m); + + // If this "last block" is also the first block, send the message. + if (self._outstandingMethodBlocks.length === 1) { + m.sendMessage(); + } + }); + + oldOutstandingMethodBlocks.shift(); + } + + // Now add the rest of the original blocks on. + self._outstandingMethodBlocks.push(...oldOutstandingMethodBlocks); + } + + // We can accept a hot code push if there are no methods in flight. + _readyToMigrate() { + return isEmpty(this._methodInvokers); + } + + // If we were blocking a migration, see if it's now possible to continue. + // Call whenever the set of outstanding/blocked methods shrinks. + _maybeMigrate() { + const self = this; + if (self._retryMigrate && self._readyToMigrate()) { + self._retryMigrate(); + self._retryMigrate = null; + } + } + + onMessage(raw_msg) { + let msg; + try { + msg = DDPCommon.parseDDP(raw_msg); + } catch (e) { + Meteor._debug('Exception while parsing DDP', e); + return; + } + + // Any message counts as receiving a pong, as it demonstrates that + // the server is still alive. + if (this._heartbeat) { + this._heartbeat.messageReceived(); + } + + if (msg === null || !msg.msg) { + if(!msg || !msg.testMessageOnConnect) { + if (Object.keys(msg).length === 1 && msg.server_id) return; + Meteor._debug('discarding invalid livedata message', msg); + } + return; + } + + if (msg.msg === 'connected') { + this._version = this._versionSuggestion; + this._livedata_connected(msg); + this.options.onConnected(); + } else if (msg.msg === 'failed') { + if (this._supportedDDPVersions.indexOf(msg.version) >= 0) { + this._versionSuggestion = msg.version; + this._stream.reconnect({ _force: true }); + } else { + const description = + 'DDP version negotiation failed; server requested version ' + + msg.version; + this._stream.disconnect({ _permanent: true, _error: description }); + this.options.onDDPVersionNegotiationFailure(description); + } + } else if (msg.msg === 'ping' && this.options.respondToPings) { + this._send({ msg: 'pong', id: msg.id }); + } else if (msg.msg === 'pong') { + // noop, as we assume everything's a pong + } else if ( + ['added', 'changed', 'removed', 'ready', 'updated'].includes(msg.msg) + ) { + this._livedata_data(msg); + } else if (msg.msg === 'nosub') { + this._livedata_nosub(msg); + } else if (msg.msg === 'result') { + this._livedata_result(msg); + } else if (msg.msg === 'error') { + this._livedata_error(msg); + } else { + Meteor._debug('discarding unknown livedata message type', msg); + } + } + + onReset() { + // Send a connect message at the beginning of the stream. + // NOTE: reset is called even on the first connection, so this is + // the only place we send this message. + const msg = { msg: 'connect' }; + if (this._lastSessionId) msg.session = this._lastSessionId; + msg.version = this._versionSuggestion || this._supportedDDPVersions[0]; + this._versionSuggestion = msg.version; + msg.support = this._supportedDDPVersions; + this._send(msg); + + // Mark non-retry calls as failed. This has to be done early as getting these methods out of the + // current block is pretty important to making sure that quiescence is properly calculated, as + // well as possibly moving on to another useful block. + + // Only bother testing if there is an outstandingMethodBlock (there might not be, especially if + // we are connecting for the first time. + if (this._outstandingMethodBlocks.length > 0) { + // If there is an outstanding method block, we only care about the first one as that is the + // one that could have already sent messages with no response, that are not allowed to retry. + const currentMethodBlock = this._outstandingMethodBlocks[0].methods; + this._outstandingMethodBlocks[0].methods = currentMethodBlock.filter( + methodInvoker => { + // Methods with 'noRetry' option set are not allowed to re-send after + // recovering dropped connection. + if (methodInvoker.sentMessage && methodInvoker.noRetry) { + // Make sure that the method is told that it failed. + methodInvoker.receiveResult( + new Meteor.Error( + 'invocation-failed', + 'Method invocation might have failed due to dropped connection. ' + + 'Failing because `noRetry` option was passed to Meteor.apply.' + ) + ); + } + + // Only keep a method if it wasn't sent or it's allowed to retry. + // This may leave the block empty, but we don't move on to the next + // block until the callback has been delivered, in _outstandingMethodFinished. + return !(methodInvoker.sentMessage && methodInvoker.noRetry); + } + ); + } + + // Now, to minimize setup latency, go ahead and blast out all of + // our pending methods ands subscriptions before we've even taken + // the necessary RTT to know if we successfully reconnected. (1) + // They're supposed to be idempotent, and where they are not, + // they can block retry in apply; (2) even if we did reconnect, + // we're not sure what messages might have gotten lost + // (in either direction) since we were disconnected (TCP being + // sloppy about that.) + + // If the current block of methods all got their results (but didn't all get + // their data visible), discard the empty block now. + if ( + this._outstandingMethodBlocks.length > 0 && + this._outstandingMethodBlocks[0].methods.length === 0 + ) { + this._outstandingMethodBlocks.shift(); + } + + // Mark all messages as unsent, they have not yet been sent on this + // connection. + keys(this._methodInvokers).forEach(id => { + this._methodInvokers[id].sentMessage = false; + }); + + // If an `onReconnect` handler is set, call it first. Go through + // some hoops to ensure that methods that are called from within + // `onReconnect` get executed _before_ ones that were originally + // outstanding (since `onReconnect` is used to re-establish auth + // certificates) + this._callOnReconnectAndSendAppropriateOutstandingMethods(); + + // add new subscriptions at the end. this way they take effect after + // the handlers and we don't see flicker. + Object.entries(this._subscriptions).forEach(([id, sub]) => { + this._send({ + msg: 'sub', + id: id, + name: sub.name, + params: sub.params + }); + }); + } +} \ No newline at end of file diff --git a/packages/webui/src/meteor/ddp/common/namespace.js b/packages/webui/src/meteor/ddp/common/namespace.js new file mode 100644 index 0000000000..c721a44efc --- /dev/null +++ b/packages/webui/src/meteor/ddp/common/namespace.js @@ -0,0 +1,94 @@ +import { DDPCommon } from '../../ddp-common'; +import { Meteor } from '../../meteor'; +import { Hook } from '../../callback-hook'; + +import { Connection } from './livedata_connection.js'; + +// This array allows the `_allSubscriptionsReady` method below, which +// is used by the `spiderable` package, to keep track of whether all +// data is ready. +const allConnections = []; + +/** + * @namespace DDP + * @summary Namespace for DDP-related methods/classes. + */ +export const DDP = {}; + +Meteor.DDP = DDP; + +// This is private but it's used in a few places. accounts-base uses +// it to get the current user. Meteor.setTimeout and friends clear +// it. We can probably find a better way to factor this. +DDP._CurrentMethodInvocation = new Meteor.EnvironmentVariable(); +DDP._CurrentPublicationInvocation = new Meteor.EnvironmentVariable(); + +// XXX: Keep DDP._CurrentInvocation for backwards-compatibility. +DDP._CurrentInvocation = DDP._CurrentMethodInvocation; + +// This is passed into a weird `makeErrorType` function that expects its thing +// to be a constructor +function connectionErrorConstructor(message) { + this.message = message; +} + +DDP.ConnectionError = Meteor.makeErrorType( + 'DDP.ConnectionError', + connectionErrorConstructor +); + +DDP.ForcedReconnectError = Meteor.makeErrorType( + 'DDP.ForcedReconnectError', + () => {} +); + +// Returns the named sequence of pseudo-random values. +// The scope will be DDP._CurrentMethodInvocation.get(), so the stream will produce +// consistent values for method calls on the client and server. +DDP.randomStream = name => { + const scope = DDP._CurrentMethodInvocation.get(); + return DDPCommon.RandomStream.get(scope, name); +}; + +// @param url {String} URL to Meteor app, +// e.g.: +// "subdomain.meteor.com", +// "http://subdomain.meteor.com", +// "/", +// "ddp+sockjs://ddp--****-foo.meteor.com/sockjs" + +/** + * @summary Connect to the server of a different Meteor application to subscribe to its document sets and invoke its remote methods. + * @locus Anywhere + * @param {String} url The URL of another Meteor application. + * @param {Object} [options] + * @param {Boolean} options.reloadWithOutstanding is it OK to reload if there are outstanding methods? + * @param {Object} options.headers extra headers to send on the websockets connection, for server-to-server DDP only + * @param {Object} options._sockjsOptions Specifies options to pass through to the sockjs client + * @param {Function} options.onDDPNegotiationVersionFailure callback when version negotiation fails. + */ +DDP.connect = (url, options) => { + const ret = new Connection(url, options); + allConnections.push(ret); // hack. see below. + return ret; +}; + +DDP._reconnectHook = new Hook({ bindEnvironment: false }); + +/** + * @summary Register a function to call as the first step of + * reconnecting. This function can call methods which will be executed before + * any other outstanding methods. For example, this can be used to re-establish + * the appropriate authentication context on the connection. + * @locus Anywhere + * @param {Function} callback The function to call. It will be called with a + * single argument, the [connection object](#ddp_connect) that is reconnecting. + */ +DDP.onReconnect = callback => DDP._reconnectHook.register(callback); + +// Hack for `spiderable` package: a way to see if the page is done +// loading all the data it needs. +// +DDP._allSubscriptionsReady = () => allConnections.every( + conn => Object.values(conn._subscriptions).every(sub => sub.ready) +); \ No newline at end of file diff --git a/packages/webui/src/meteor/ddp/index.d.ts b/packages/webui/src/meteor/ddp/index.d.ts new file mode 100644 index 0000000000..8ebc69f065 --- /dev/null +++ b/packages/webui/src/meteor/ddp/index.d.ts @@ -0,0 +1,65 @@ +import { Meteor } from 'meteor/meteor'; + +export namespace DDP { + interface DDPStatic { + subscribe(name: string, ...rest: any[]): Meteor.SubscriptionHandle; + call(method: string, ...parameters: any[]): any; + apply(method: string, ...parameters: any[]): any; + methods(IMeteorMethodsDictionary: any): any; + status(): DDPStatus; + reconnect(): void; + disconnect(): void; + onReconnect(): void; + } + + function _allSubscriptionsReady(): boolean; + + type Status = 'connected' | 'connecting' | 'failed' | 'waiting' | 'offline'; + + interface DDPStatus { + connected: boolean; + status: Status; + retryCount: number; + retryTime?: number | undefined; + reason?: string | undefined; + } + + function connect(url: string): DDPStatic; +} + +export namespace DDPCommon { + interface MethodInvocationOptions { + userId: string | null; + setUserId?: ((newUserId: string) => void) | undefined; + isSimulation: boolean; + connection: Meteor.Connection; + randomSeed: string; + } + + /** The state for a single invocation of a method, referenced by this inside a method definition. */ + interface MethodInvocation { + new (options: MethodInvocationOptions): MethodInvocation; + /** + * Call inside a method invocation. Allow subsequent method from this client to begin running in a new fiber. + */ + unblock(): void; + /** + * Set the logged in user. + * @param userId The value that should be returned by `userId` on this connection. + */ + setUserId(userId: string | null): void; + /** + * The id of the user that made this method call, or `null` if no user was logged in. + */ + userId: string | null; + /** + * Access inside a method invocation. Boolean value, true if this invocation is a stub. + */ + isSimulation: boolean; + /** + * Access inside a method invocation. The [connection](#meteor_onconnection) that this method was received on. `null` if the method is not associated with a connection, eg. a server + * initiated method call. Calls to methods made from a server method which was in turn initiated from the client share the same `connection`. + */ + connection: Meteor.Connection; + } +}1 \ No newline at end of file diff --git a/packages/webui/src/meteor/ddp/index.js b/packages/webui/src/meteor/ddp/index.js new file mode 100644 index 0000000000..1842d35079 --- /dev/null +++ b/packages/webui/src/meteor/ddp/index.js @@ -0,0 +1,7 @@ + +import './common/livedata_connection'; + +// Initialize the default server connection and put it on Meteor.connection +import './client_convenience'; + +export { DDP } from './common/namespace.js'; diff --git a/packages/webui/src/meteor/diff-sequence.js b/packages/webui/src/meteor/diff-sequence.js new file mode 100644 index 0000000000..3e3b71da2e --- /dev/null +++ b/packages/webui/src/meteor/diff-sequence.js @@ -0,0 +1,291 @@ +// https://github.com/meteor/meteor/blob/73fd519de6eef8e116d813fb457c8442db9d1cdd/packages/diff-sequence/diff.js + +import EJSON from 'ejson' +import { Meteor } from './meteor' + +export const DiffSequence = {}; + +const hasOwn = Object.prototype.hasOwnProperty; + +function isObjEmpty(obj) { + for (let key in Object(obj)) { + if (hasOwn.call(obj, key)) { + return false; + } + } + return true; +} + +// ordered: bool. +// old_results and new_results: collections of documents. +// if ordered, they are arrays. +// if unordered, they are IdMaps +DiffSequence.diffQueryChanges = function (ordered, oldResults, newResults, + observer, options) { + if (ordered) + DiffSequence.diffQueryOrderedChanges( + oldResults, newResults, observer, options); + else + DiffSequence.diffQueryUnorderedChanges( + oldResults, newResults, observer, options); +}; + +DiffSequence.diffQueryUnorderedChanges = function (oldResults, newResults, + observer, options) { + options = options || {}; + var projectionFn = options.projectionFn || EJSON.clone; + + if (observer.movedBefore) { + throw new Error("_diffQueryUnordered called with a movedBefore observer!"); + } + + newResults.forEach(function (newDoc, id) { + var oldDoc = oldResults.get(id); + if (oldDoc) { + if (observer.changed && !EJSON.equals(oldDoc, newDoc)) { + var projectedNew = projectionFn(newDoc); + var projectedOld = projectionFn(oldDoc); + var changedFields = + DiffSequence.makeChangedFields(projectedNew, projectedOld); + if (! isObjEmpty(changedFields)) { + observer.changed(id, changedFields); + } + } + } else if (observer.added) { + var fields = projectionFn(newDoc); + delete fields._id; + observer.added(newDoc._id, fields); + } + }); + + if (observer.removed) { + oldResults.forEach(function (oldDoc, id) { + if (!newResults.has(id)) + observer.removed(id); + }); + } +}; + +DiffSequence.diffQueryOrderedChanges = function (old_results, new_results, + observer, options) { + options = options || {}; + var projectionFn = options.projectionFn || EJSON.clone; + + var new_presence_of_id = {}; + new_results.forEach(function (doc) { + if (new_presence_of_id[doc._id]) + Meteor._debug("Duplicate _id in new_results"); + new_presence_of_id[doc._id] = true; + }); + + var old_index_of_id = {}; + old_results.forEach(function (doc, i) { + if (doc._id in old_index_of_id) + Meteor._debug("Duplicate _id in old_results"); + old_index_of_id[doc._id] = i; + }); + + // ALGORITHM: + // + // To determine which docs should be considered "moved" (and which + // merely change position because of other docs moving) we run + // a "longest common subsequence" (LCS) algorithm. The LCS of the + // old doc IDs and the new doc IDs gives the docs that should NOT be + // considered moved. + + // To actually call the appropriate callbacks to get from the old state to the + // new state: + + // First, we call removed() on all the items that only appear in the old + // state. + + // Then, once we have the items that should not move, we walk through the new + // results array group-by-group, where a "group" is a set of items that have + // moved, anchored on the end by an item that should not move. One by one, we + // move each of those elements into place "before" the anchoring end-of-group + // item, and fire changed events on them if necessary. Then we fire a changed + // event on the anchor, and move on to the next group. There is always at + // least one group; the last group is anchored by a virtual "null" id at the + // end. + + // Asymptotically: O(N k) where k is number of ops, or potentially + // O(N log N) if inner loop of LCS were made to be binary search. + + + //////// LCS (longest common sequence, with respect to _id) + // (see Wikipedia article on Longest Increasing Subsequence, + // where the LIS is taken of the sequence of old indices of the + // docs in new_results) + // + // unmoved: the output of the algorithm; members of the LCS, + // in the form of indices into new_results + var unmoved = []; + // max_seq_len: length of LCS found so far + var max_seq_len = 0; + // seq_ends[i]: the index into new_results of the last doc in a + // common subsequence of length of i+1 <= max_seq_len + var N = new_results.length; + var seq_ends = new Array(N); + // ptrs: the common subsequence ending with new_results[n] extends + // a common subsequence ending with new_results[ptr[n]], unless + // ptr[n] is -1. + var ptrs = new Array(N); + // virtual sequence of old indices of new results + var old_idx_seq = function(i_new) { + return old_index_of_id[new_results[i_new]._id]; + }; + // for each item in new_results, use it to extend a common subsequence + // of length j <= max_seq_len + for(var i=0; i 0) { + if (old_idx_seq(seq_ends[j-1]) < old_idx_seq(i)) + break; + j--; + } + + ptrs[i] = (j === 0 ? -1 : seq_ends[j-1]); + seq_ends[j] = i; + if (j+1 > max_seq_len) + max_seq_len = j+1; + } + } + + // pull out the LCS/LIS into unmoved + var idx = (max_seq_len === 0 ? -1 : seq_ends[max_seq_len-1]); + while (idx >= 0) { + unmoved.push(idx); + idx = ptrs[idx]; + } + // the unmoved item list is built backwards, so fix that + unmoved.reverse(); + + // the last group is always anchored by the end of the result list, which is + // an id of "null" + unmoved.push(new_results.length); + + old_results.forEach(function (doc) { + if (!new_presence_of_id[doc._id]) + observer.removed && observer.removed(doc._id); + }); + + // for each group of things in the new_results that is anchored by an unmoved + // element, iterate through the things before it. + var startOfGroup = 0; + unmoved.forEach(function (endOfGroup) { + var groupId = new_results[endOfGroup] ? new_results[endOfGroup]._id : null; + var oldDoc, newDoc, fields, projectedNew, projectedOld; + for (var i = startOfGroup; i < endOfGroup; i++) { + newDoc = new_results[i]; + if (!hasOwn.call(old_index_of_id, newDoc._id)) { + fields = projectionFn(newDoc); + delete fields._id; + observer.addedBefore && observer.addedBefore(newDoc._id, fields, groupId); + observer.added && observer.added(newDoc._id, fields); + } else { + // moved + oldDoc = old_results[old_index_of_id[newDoc._id]]; + projectedNew = projectionFn(newDoc); + projectedOld = projectionFn(oldDoc); + fields = DiffSequence.makeChangedFields(projectedNew, projectedOld); + if (!isObjEmpty(fields)) { + observer.changed && observer.changed(newDoc._id, fields); + } + observer.movedBefore && observer.movedBefore(newDoc._id, groupId); + } + } + if (groupId) { + newDoc = new_results[endOfGroup]; + oldDoc = old_results[old_index_of_id[newDoc._id]]; + projectedNew = projectionFn(newDoc); + projectedOld = projectionFn(oldDoc); + fields = DiffSequence.makeChangedFields(projectedNew, projectedOld); + if (!isObjEmpty(fields)) { + observer.changed && observer.changed(newDoc._id, fields); + } + } + startOfGroup = endOfGroup+1; + }); + + +}; + + +// General helper for diff-ing two objects. +// callbacks is an object like so: +// { leftOnly: function (key, leftValue) {...}, +// rightOnly: function (key, rightValue) {...}, +// both: function (key, leftValue, rightValue) {...}, +// } +DiffSequence.diffObjects = function (left, right, callbacks) { + Object.keys(left).forEach(key => { + const leftValue = left[key]; + if (hasOwn.call(right, key)) { + callbacks.both && callbacks.both(key, leftValue, right[key]); + } else { + callbacks.leftOnly && callbacks.leftOnly(key, leftValue); + } + }); + + if (callbacks.rightOnly) { + Object.keys(right).forEach(key => { + const rightValue = right[key]; + if (! hasOwn.call(left, key)) { + callbacks.rightOnly(key, rightValue); + } + }); + } +}; + +DiffSequence.diffMaps = function (left, right, callbacks) { + left.forEach(function (leftValue, key) { + if (right.has(key)){ + callbacks.both && callbacks.both(key, leftValue, right.get(key)); + } else { + callbacks.leftOnly && callbacks.leftOnly(key, leftValue); + } + }); + + if (callbacks.rightOnly) { + right.forEach(function (rightValue, key) { + if (!left.has(key)){ + callbacks.rightOnly(key, rightValue); + } + }); + } +}; + + +DiffSequence.makeChangedFields = function (newDoc, oldDoc) { + var fields = {}; + DiffSequence.diffObjects(oldDoc, newDoc, { + leftOnly: function (key, value) { + fields[key] = undefined; + }, + rightOnly: function (key, value) { + fields[key] = value; + }, + both: function (key, leftValue, rightValue) { + if (!EJSON.equals(leftValue, rightValue)) + fields[key] = rightValue; + } + }); + return fields; +}; + +DiffSequence.applyChanges = function (doc, changeFields) { + Object.keys(changeFields).forEach(key => { + const value = changeFields[key]; + if (typeof value === "undefined") { + delete doc[key]; + } else { + doc[key] = value; + } + }); +}; diff --git a/packages/webui/src/meteor/geojson-utils/geojson-utils.js b/packages/webui/src/meteor/geojson-utils/geojson-utils.js new file mode 100644 index 0000000000..5176d51667 --- /dev/null +++ b/packages/webui/src/meteor/geojson-utils/geojson-utils.js @@ -0,0 +1,380 @@ +(function () { + var gju = {}; + + // Export the geojson object for **CommonJS** + if (typeof module !== 'undefined' && module.exports) { + module.exports = gju; + } + + // adapted from http://www.kevlindev.com/gui/math/intersection/Intersection.js + gju.lineStringsIntersect = function (l1, l2) { + var intersects = []; + for (var i = 0; i <= l1.coordinates.length - 2; ++i) { + for (var j = 0; j <= l2.coordinates.length - 2; ++j) { + var a1 = { + x: l1.coordinates[i][1], + y: l1.coordinates[i][0] + }, + a2 = { + x: l1.coordinates[i + 1][1], + y: l1.coordinates[i + 1][0] + }, + b1 = { + x: l2.coordinates[j][1], + y: l2.coordinates[j][0] + }, + b2 = { + x: l2.coordinates[j + 1][1], + y: l2.coordinates[j + 1][0] + }, + ua_t = (b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x), + ub_t = (a2.x - a1.x) * (a1.y - b1.y) - (a2.y - a1.y) * (a1.x - b1.x), + u_b = (b2.y - b1.y) * (a2.x - a1.x) - (b2.x - b1.x) * (a2.y - a1.y); + if (u_b != 0) { + var ua = ua_t / u_b, + ub = ub_t / u_b; + if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) { + intersects.push({ + 'type': 'Point', + 'coordinates': [a1.x + ua * (a2.x - a1.x), a1.y + ua * (a2.y - a1.y)] + }); + } + } + } + } + if (intersects.length == 0) intersects = false; + return intersects; + } + + // Bounding Box + + function boundingBoxAroundPolyCoords (coords) { + var xAll = [], yAll = [] + + for (var i = 0; i < coords[0].length; i++) { + xAll.push(coords[0][i][1]) + yAll.push(coords[0][i][0]) + } + + xAll = xAll.sort(function (a,b) { return a - b }) + yAll = yAll.sort(function (a,b) { return a - b }) + + return [ [xAll[0], yAll[0]], [xAll[xAll.length - 1], yAll[yAll.length - 1]] ] + } + + gju.pointInBoundingBox = function (point, bounds) { + return !(point.coordinates[1] < bounds[0][0] || point.coordinates[1] > bounds[1][0] || point.coordinates[0] < bounds[0][1] || point.coordinates[0] > bounds[1][1]) + } + + // Point in Polygon + // http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html#Listing the Vertices + + function pnpoly (x,y,coords) { + var vert = [ [0,0] ] + + for (var i = 0; i < coords.length; i++) { + for (var j = 0; j < coords[i].length; j++) { + vert.push(coords[i][j]) + } + vert.push([0,0]) + } + + var inside = false + for (var i = 0, j = vert.length - 1; i < vert.length; j = i++) { + if (((vert[i][0] > y) != (vert[j][0] > y)) && (x < (vert[j][1] - vert[i][1]) * (y - vert[i][0]) / (vert[j][0] - vert[i][0]) + vert[i][1])) inside = !inside + } + + return inside + } + + gju.pointInPolygon = function (p, poly) { + var coords = (poly.type == "Polygon") ? [ poly.coordinates ] : poly.coordinates + + var insideBox = false + for (var i = 0; i < coords.length; i++) { + if (gju.pointInBoundingBox(p, boundingBoxAroundPolyCoords(coords[i]))) insideBox = true + } + if (!insideBox) return false + + var insidePoly = false + for (var i = 0; i < coords.length; i++) { + if (pnpoly(p.coordinates[1], p.coordinates[0], coords[i])) insidePoly = true + } + + return insidePoly + } + + gju.numberToRadius = function (number) { + return number * Math.PI / 180; + } + + gju.numberToDegree = function (number) { + return number * 180 / Math.PI; + } + + // written with help from @tautologe + gju.drawCircle = function (radiusInMeters, centerPoint, steps) { + var center = [centerPoint.coordinates[1], centerPoint.coordinates[0]], + dist = (radiusInMeters / 1000) / 6371, + // convert meters to radiant + radCenter = [gju.numberToRadius(center[0]), gju.numberToRadius(center[1])], + steps = steps || 15, + // 15 sided circle + poly = [[center[0], center[1]]]; + for (var i = 0; i < steps; i++) { + var brng = 2 * Math.PI * i / steps; + var lat = Math.asin(Math.sin(radCenter[0]) * Math.cos(dist) + + Math.cos(radCenter[0]) * Math.sin(dist) * Math.cos(brng)); + var lng = radCenter[1] + Math.atan2(Math.sin(brng) * Math.sin(dist) * Math.cos(radCenter[0]), + Math.cos(dist) - Math.sin(radCenter[0]) * Math.sin(lat)); + poly[i] = []; + poly[i][1] = gju.numberToDegree(lat); + poly[i][0] = gju.numberToDegree(lng); + } + return { + "type": "Polygon", + "coordinates": [poly] + }; + } + + // assumes rectangle starts at lower left point + gju.rectangleCentroid = function (rectangle) { + var bbox = rectangle.coordinates[0]; + var xmin = bbox[0][0], + ymin = bbox[0][1], + xmax = bbox[2][0], + ymax = bbox[2][1]; + var xwidth = xmax - xmin; + var ywidth = ymax - ymin; + return { + 'type': 'Point', + 'coordinates': [xmin + xwidth / 2, ymin + ywidth / 2] + }; + } + + // from http://www.movable-type.co.uk/scripts/latlong.html + gju.pointDistance = function (pt1, pt2) { + var lon1 = pt1.coordinates[0], + lat1 = pt1.coordinates[1], + lon2 = pt2.coordinates[0], + lat2 = pt2.coordinates[1], + dLat = gju.numberToRadius(lat2 - lat1), + dLon = gju.numberToRadius(lon2 - lon1), + a = Math.pow(Math.sin(dLat / 2), 2) + Math.cos(gju.numberToRadius(lat1)) + * Math.cos(gju.numberToRadius(lat2)) * Math.pow(Math.sin(dLon / 2), 2), + c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + // Earth radius is 6371 km + return (6371 * c) * 1000; // returns meters + } + + // checks if geometry lies entirely within a circle + // works with Point, LineString, Polygon + gju.geometryWithinRadius = function (geometry, center, radius) { + if (geometry.type == 'Point') { + return gju.pointDistance(geometry, center) <= radius; + } else if (geometry.type == 'LineString' || geometry.type == 'Polygon') { + var point = {}; + var coordinates; + if (geometry.type == 'Polygon') { + // it's enough to check the exterior ring of the Polygon + coordinates = geometry.coordinates[0]; + } else { + coordinates = geometry.coordinates; + } + for (var i in coordinates) { + point.coordinates = coordinates[i]; + if (gju.pointDistance(point, center) > radius) { + return false; + } + } + } + return true; + } + + // adapted from http://paulbourke.net/geometry/polyarea/javascript.txt + gju.area = function (polygon) { + var area = 0; + // TODO: polygon holes at coordinates[1] + var points = polygon.coordinates[0]; + var j = points.length - 1; + var p1, p2; + + for (var i = 0; i < points.length; j = i++) { + var p1 = { + x: points[i][1], + y: points[i][0] + }; + var p2 = { + x: points[j][1], + y: points[j][0] + }; + area += p1.x * p2.y; + area -= p1.y * p2.x; + } + + area /= 2; + return area; + } + + // adapted from http://paulbourke.net/geometry/polyarea/javascript.txt + gju.centroid = function (polygon) { + var f, x = 0, + y = 0; + // TODO: polygon holes at coordinates[1] + var points = polygon.coordinates[0]; + var j = points.length - 1; + var p1, p2; + + for (var i = 0; i < points.length; j = i++) { + var p1 = { + x: points[i][1], + y: points[i][0] + }; + var p2 = { + x: points[j][1], + y: points[j][0] + }; + f = p1.x * p2.y - p2.x * p1.y; + x += (p1.x + p2.x) * f; + y += (p1.y + p2.y) * f; + } + + f = gju.area(polygon) * 6; + return { + 'type': 'Point', + 'coordinates': [y / f, x / f] + }; + } + + gju.simplify = function (source, kink) { /* source[] array of geojson points */ + /* kink in metres, kinks above this depth kept */ + /* kink depth is the height of the triangle abc where a-b and b-c are two consecutive line segments */ + kink = kink || 20; + source = source.map(function (o) { + return { + lng: o.coordinates[0], + lat: o.coordinates[1] + } + }); + + var n_source, n_stack, n_dest, start, end, i, sig; + var dev_sqr, max_dev_sqr, band_sqr; + var x12, y12, d12, x13, y13, d13, x23, y23, d23; + var F = (Math.PI / 180.0) * 0.5; + var index = new Array(); /* aray of indexes of source points to include in the reduced line */ + var sig_start = new Array(); /* indices of start & end of working section */ + var sig_end = new Array(); + + /* check for simple cases */ + + if (source.length < 3) return (source); /* one or two points */ + + /* more complex case. initialize stack */ + + n_source = source.length; + band_sqr = kink * 360.0 / (2.0 * Math.PI * 6378137.0); /* Now in degrees */ + band_sqr *= band_sqr; + n_dest = 0; + sig_start[0] = 0; + sig_end[0] = n_source - 1; + n_stack = 1; + + /* while the stack is not empty ... */ + while (n_stack > 0) { + + /* ... pop the top-most entries off the stacks */ + + start = sig_start[n_stack - 1]; + end = sig_end[n_stack - 1]; + n_stack--; + + if ((end - start) > 1) { /* any intermediate points ? */ + + /* ... yes, so find most deviant intermediate point to + either side of line joining start & end points */ + + x12 = (source[end].lng() - source[start].lng()); + y12 = (source[end].lat() - source[start].lat()); + if (Math.abs(x12) > 180.0) x12 = 360.0 - Math.abs(x12); + x12 *= Math.cos(F * (source[end].lat() + source[start].lat())); /* use avg lat to reduce lng */ + d12 = (x12 * x12) + (y12 * y12); + + for (i = start + 1, sig = start, max_dev_sqr = -1.0; i < end; i++) { + + x13 = source[i].lng() - source[start].lng(); + y13 = source[i].lat() - source[start].lat(); + if (Math.abs(x13) > 180.0) x13 = 360.0 - Math.abs(x13); + x13 *= Math.cos(F * (source[i].lat() + source[start].lat())); + d13 = (x13 * x13) + (y13 * y13); + + x23 = source[i].lng() - source[end].lng(); + y23 = source[i].lat() - source[end].lat(); + if (Math.abs(x23) > 180.0) x23 = 360.0 - Math.abs(x23); + x23 *= Math.cos(F * (source[i].lat() + source[end].lat())); + d23 = (x23 * x23) + (y23 * y23); + + if (d13 >= (d12 + d23)) dev_sqr = d23; + else if (d23 >= (d12 + d13)) dev_sqr = d13; + else dev_sqr = (x13 * y12 - y13 * x12) * (x13 * y12 - y13 * x12) / d12; // solve triangle + if (dev_sqr > max_dev_sqr) { + sig = i; + max_dev_sqr = dev_sqr; + } + } + + if (max_dev_sqr < band_sqr) { /* is there a sig. intermediate point ? */ + /* ... no, so transfer current start point */ + index[n_dest] = start; + n_dest++; + } else { /* ... yes, so push two sub-sections on stack for further processing */ + n_stack++; + sig_start[n_stack - 1] = sig; + sig_end[n_stack - 1] = end; + n_stack++; + sig_start[n_stack - 1] = start; + sig_end[n_stack - 1] = sig; + } + } else { /* ... no intermediate points, so transfer current start point */ + index[n_dest] = start; + n_dest++; + } + } + + /* transfer last point */ + index[n_dest] = n_source - 1; + n_dest++; + + /* make return array */ + var r = new Array(); + for (var i = 0; i < n_dest; i++) + r.push(source[index[i]]); + + return r.map(function (o) { + return { + type: "Point", + coordinates: [o.lng, o.lat] + } + }); + } + + // http://www.movable-type.co.uk/scripts/latlong.html#destPoint + gju.destinationPoint = function (pt, brng, dist) { + dist = dist/6371; // convert dist to angular distance in radians + brng = gju.numberToRadius(brng); + + var lat1 = gju.numberToRadius(pt.coordinates[0]); + var lon1 = gju.numberToRadius(pt.coordinates[1]); + + var lat2 = Math.asin( Math.sin(lat1)*Math.cos(dist) + + Math.cos(lat1)*Math.sin(dist)*Math.cos(brng) ); + var lon2 = lon1 + Math.atan2(Math.sin(brng)*Math.sin(dist)*Math.cos(lat1), + Math.cos(dist)-Math.sin(lat1)*Math.sin(lat2)); + lon2 = (lon2+3*Math.PI) % (2*Math.PI) - Math.PI; // normalise to -180..+180º + + return { + 'type': 'Point', + 'coordinates': [gju.numberToDegree(lat2), gju.numberToDegree(lon2)] + }; + }; + + })(); \ No newline at end of file diff --git a/packages/webui/src/meteor/geojson-utils/index.js b/packages/webui/src/meteor/geojson-utils/index.js new file mode 100644 index 0000000000..8c52373b84 --- /dev/null +++ b/packages/webui/src/meteor/geojson-utils/index.js @@ -0,0 +1,5 @@ +// https://github.com/meteor/meteor/tree/73fd519de6eef8e116d813fb457c8442db9d1cdd/packages/geojson-utils + +import * as GeoJSON from './geojson-utils.js' + +export { GeoJSON } diff --git a/packages/webui/src/meteor/id-map.js b/packages/webui/src/meteor/id-map.js new file mode 100644 index 0000000000..3b771287d0 --- /dev/null +++ b/packages/webui/src/meteor/id-map.js @@ -0,0 +1,83 @@ +// https://github.com/meteor/meteor/blob/73fd519de6eef8e116d813fb457c8442db9d1cdd/packages/id-map/id-map.js + +import EJSON from 'ejson' + +export class IdMap { + constructor(idStringify, idParse) { + this._map = new Map(); + this._idStringify = idStringify || JSON.stringify; + this._idParse = idParse || JSON.parse; + } + + // Some of these methods are designed to match methods on OrderedDict, since + // (eg) ObserveMultiplex and _CachingChangeObserver use them interchangeably. + // (Conceivably, this should be replaced with "UnorderedDict" with a specific + // set of methods that overlap between the two.) + + get(id) { + const key = this._idStringify(id); + return this._map.get(key); + } + + set(id, value) { + const key = this._idStringify(id); + this._map.set(key, value); + } + + remove(id) { + const key = this._idStringify(id); + this._map.delete(key); + } + + has(id) { + const key = this._idStringify(id); + return this._map.has(key); + } + + empty() { + return this._map.size === 0; + } + + clear() { + this._map.clear(); + } + + // Iterates over the items in the map. Return `false` to break the loop. + forEach(iterator) { + // don't use _.each, because we can't break out of it. + for (let [key, value] of this._map){ + const breakIfFalse = iterator.call( + null, + value, + this._idParse(key) + ); + if (breakIfFalse === false) { + return; + } + } + } + + size() { + return this._map.size; + } + + setDefault(id, def) { + const key = this._idStringify(id); + if (this._map.has(key)) { + return this._map.get(key); + } + this._map.set(key, def); + return def; + } + + // Assumes that values are EJSON-cloneable, and that we don't need to clone + // IDs (ie, that nobody is going to mutate an ObjectId). + clone() { + const clone = new IdMap(this._idStringify, this._idParse); + // copy directly to avoid stringify/parse overhead + this._map.forEach(function(value, key){ + clone._map.set(key, EJSON.clone(value)); + }); + return clone; + } + } \ No newline at end of file diff --git a/packages/webui/src/meteor/meteor.d.ts b/packages/webui/src/meteor/meteor.d.ts new file mode 100644 index 0000000000..94a8e3b4db --- /dev/null +++ b/packages/webui/src/meteor/meteor.d.ts @@ -0,0 +1,486 @@ +import { Mongo } from './mongo'; +import { EJSONable, EJSONableProperty } from 'meteor/ejson'; +import { Blaze } from 'meteor/blaze'; +import { DDP } from 'meteor/ddp'; + +// declare module 'meteor/meteor' { + type global_Error = Error; + export namespace Meteor { + /** Global props **/ + /** True if running in client environment. */ + var isClient: boolean; + /** True if running in a Cordova mobile environment. */ + var isCordova: boolean; + /** True if running in server environment. */ + var isServer: boolean; + /** True if running in production environment. */ + var isProduction: boolean; + /** True if running in production environment. */ + var isTest: boolean; + /** True if running in production environment. */ + var isDevelopment: boolean; + + /** + * `Meteor.release` is a string containing the name of the release with which the project was built (for example, `"1.2.3"`). It is `undefined` if the project was built using a git checkout + * of Meteor. + */ + var release: string; + /** Global props **/ + + /** Settings **/ + interface Settings { + public: { [id: string]: any }; + [id: string]: any; + } + /** + * `Meteor.settings` contains deployment-specific configuration options. You can initialize settings by passing the `--settings` option (which takes the name of a file containing JSON data) + * to `meteor run` or `meteor deploy`. When running your server directly (e.g. from a bundle), you instead specify settings by putting the JSON directly into the `METEOR_SETTINGS` environment + * variable. If the settings object contains a key named `public`, then `Meteor.settings.public` will be available on the client as well as the server. All other properties of + * `Meteor.settings` are only defined on the server. You can rely on `Meteor.settings` and `Meteor.settings.public` being defined objects (not undefined) on both client and server even if + * there are no settings specified. Changes to `Meteor.settings.public` at runtime will be picked up by new client connections. + */ + var settings: Settings; + /** Settings **/ + + /** User **/ + interface UserEmail { + address: string; + verified: boolean; + } + /** + * UserProfile is left intentionally underspecified here, to allow you + * to override it in your application (but keep in mind that the default + * Meteor configuration allows users to write directly to their user + * record's profile field) + */ + interface UserProfile {} + interface User { + _id: string; + username?: string | undefined; + emails?: UserEmail[] | undefined; + createdAt?: Date | undefined; + profile?: UserProfile; + services?: any; + } + + function user(options?: { fields?: Mongo.FieldSpecifier | undefined }): User | null; + + function userId(): string | null; + var users: Mongo.Collection; + /** User **/ + + /** Error **/ + /** + * This class represents a symbolic error thrown by a method. + */ + var Error: ErrorStatic; + interface ErrorStatic { + /** + * @param error A string code uniquely identifying this kind of error. + * This string should be used by callers of the method to determine the + * appropriate action to take, instead of attempting to parse the reason + * or details fields. For example: + * + * ``` + * // on the server, pick a code unique to this error + * // the reason field should be a useful debug message + * throw new Meteor.Error("logged-out", + * "The user must be logged in to post a comment."); + * + * // on the client + * Meteor.call("methodName", function (error) { + * // identify the error + * if (error && error.error === "logged-out") { + * // show a nice error message + * Session.set("errorMessage", "Please log in to post a comment."); + * } + * }); + * ``` + * + * For legacy reasons, some built-in Meteor functions such as `check` throw + * errors with a number in this field. + * + * @param reason Optional. A short human-readable summary of the + * error, like 'Not Found'. + * @param details Optional. Additional information about the error, + * like a textual stack trace. + */ + new (error: string | number, reason?: string, details?: string): Error; + } + interface Error extends global_Error { + error: string | number; + reason?: string | undefined; + details?: string | undefined; + } + var TypedError: TypedErrorStatic; + interface TypedErrorStatic { + new (message: string, errorType: string): TypedError; + } + interface TypedError extends global_Error { + message: string; + errorType: string; + } + /** Error **/ + + /** Method **/ + interface MethodThisType { + /** Access inside a method invocation. Boolean value, true if this invocation is a stub. */ + isSimulation: boolean; + /** The id of the user that made this method call, or `null` if no user was logged in. */ + userId: string | null; + /** + * Access inside a method invocation. The connection that this method was received on. `null` if the method is not associated with a connection, eg. a server initiated method call. Calls + * to methods made from a server method which was in turn initiated from the client share the same `connection`. */ + connection: Connection | null; + /** + * Set the logged in user. + * @param userId The value that should be returned by `userId` on this connection. + */ + setUserId(userId: string | null): void; + /** Call inside a method invocation. Allow subsequent method from this client to begin running in a new fiber. */ + unblock(): void; + } + + /** + * Defines functions that can be invoked over the network by clients. + * @param methods Dictionary whose keys are method names and values are functions. + */ + function methods(methods: { [key: string]: (this: MethodThisType, ...args: any[]) => any }): void; + + /** + * Invokes a method passing any number of arguments. + * @param name Name of method to invoke + * @param args Optional method arguments + */ + function call(name: string, ...args: any[]): any; + + function apply( + name: string, + args: ReadonlyArray, + options?: { + wait?: boolean | undefined; + onResultReceived?: + | ((error: global_Error | Meteor.Error | undefined, result?: Result) => void) + | undefined; + /** + * (Client only) if true, don't send this method again on reload, simply call the callback an error with the error code 'invocation-failed'. + */ + noRetry?: boolean | undefined; + returnStubValue?: boolean | undefined; + throwStubExceptions?: boolean | undefined; + }, + asyncCallback?: (error: global_Error | Meteor.Error | undefined, result?: Result) => void, + ): any; + /** Method **/ + + /** Url **/ + /** + * Generate an absolute URL pointing to the application. The server reads from the `ROOT_URL` environment variable to determine where it is running. This is taken care of automatically for + * apps deployed to Galaxy, but must be provided when using `meteor build`. + */ + var absoluteUrl: { + /** + * @param path A path to append to the root URL. Do not include a leading "`/`". + */ + (path?: string, options?: absoluteUrlOptions): string; + defaultOptions: absoluteUrlOptions; + }; + + interface absoluteUrlOptions { + /** Create an HTTPS URL. */ + secure?: boolean | undefined; + /** Replace localhost with 127.0.0.1. Useful for services that don't recognize localhost as a domain name. */ + replaceLocalhost?: boolean | undefined; + /** Override the default ROOT_URL from the server environment. For example: "`http://foo.example.com`" */ + rootUrl?: string | undefined; + } + /** Url **/ + + /** Timeout **/ + /** + * Call a function repeatedly, with a time delay between calls. + * @param func The function to run + * @param delay Number of milliseconds to wait between each function call. + */ + function setInterval(func: Function, delay: number): number; + + /** + * Call a function in the future after waiting for a specified delay. + * @param func The function to run + * @param delay Number of milliseconds to wait before calling function + */ + function setTimeout(func: Function, delay: number): number; + /** + * Cancel a repeating function call scheduled by `Meteor.setInterval`. + * @param id The handle returned by `Meteor.setInterval` + */ + function clearInterval(id: number): void; + + /** + * Cancel a function call scheduled by `Meteor.setTimeout`. + * @param id The handle returned by `Meteor.setTimeout` + */ + function clearTimeout(id: number): void; + /** + * Defer execution of a function to run asynchronously in the background (similar to `Meteor.setTimeout(func, 0)`. + * @param func The function to run + */ + function defer(func: Function): void; + /** Timeout **/ + + /** utils **/ + /** + * Run code when a client or a server starts. + * @param func A function to run on startup. + */ + function startup(func: Function): void; + + /** + * Wrap a function that takes a callback function as its final parameter. + * The signature of the callback of the wrapped function should be `function(error, result){}`. + * On the server, the wrapped function can be used either synchronously (without passing a callback) or asynchronously + * (when a callback is passed). On the client, a callback is always required; errors will be logged if there is no callback. + * If a callback is provided, the environment captured when the original function was called will be restored in the callback. + * The parameters of the wrapped function must not contain any optional parameters or be undefined, as the callback function is expected to be the final, non-undefined parameter. + * @param func A function that takes a callback as its final parameter + * @param context Optional `this` object against which the original function will be invoked + */ + function wrapAsync(func: Function, context?: Object): any; + + function bindEnvironment(func: TFunc): TFunc; + + class EnvironmentVariable { + readonly slot: number; + constructor(); + get(): T; + getOrNullIfOutsideFiber(): T | null; + withValue(value: T, fn: () => U): U; + } + /** utils **/ + + /** Pub/Sub **/ + interface SubscriptionHandle { + /** Cancel the subscription. This will typically result in the server directing the client to remove the subscription’s data from the client’s cache. */ + stop(): void; + /** True if the server has marked the subscription as ready. A reactive data source. */ + ready(): boolean; + } + interface LiveQueryHandle { + stop(): void; + } + /** Pub/Sub **/ + + /** Login **/ + interface LoginWithExternalServiceOptions { + requestPermissions?: ReadonlyArray | undefined; + requestOfflineToken?: Boolean | undefined; + forceApprovalPrompt?: Boolean | undefined; + loginUrlParameters?: Object | undefined; + redirectUrl?: string | undefined; + loginHint?: string | undefined; + loginStyle?: string | undefined; + } + + function loginWithMeteorDeveloperAccount( + options?: Meteor.LoginWithExternalServiceOptions, + callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void, + ): void; + + function loginWithFacebook( + options?: Meteor.LoginWithExternalServiceOptions, + callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void, + ): void; + + function loginWithGithub( + options?: Meteor.LoginWithExternalServiceOptions, + callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void, + ): void; + + function loginWithGoogle( + options?: Meteor.LoginWithExternalServiceOptions, + callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void, + ): void; + + function loginWithMeetup( + options?: Meteor.LoginWithExternalServiceOptions, + callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void, + ): void; + + function loginWithTwitter( + options?: Meteor.LoginWithExternalServiceOptions, + callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void, + ): void; + + function loginWithWeibo( + options?: Meteor.LoginWithExternalServiceOptions, + callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void, + ): void; + + function loginWith( + options?: { + requestPermissions?: ReadonlyArray | undefined; + requestOfflineToken?: boolean | undefined; + loginUrlParameters?: Object | undefined; + userEmail?: string | undefined; + loginStyle?: string | undefined; + redirectUrl?: string | undefined; + }, + callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void, + ): void; + + function loginWithPassword( + user: Object | string, + password: string, + callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void, + ): void; + + function loginWithToken( + token: string, + callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void, + ): void; + + function loggingIn(): boolean; + + function loggingOut(): boolean; + + function logout(callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void): void; + + function logoutOtherClients(callback?: (error?: global_Error | Meteor.Error | Meteor.TypedError) => void): void; + /** Login **/ + + /** Event **/ + interface Event { + type: string; + target: HTMLElement; + currentTarget: HTMLElement; + which: number; + stopPropagation(): void; + stopImmediatePropagation(): void; + preventDefault(): void; + isPropagationStopped(): boolean; + isImmediatePropagationStopped(): boolean; + isDefaultPrevented(): boolean; + } + interface EventHandlerFunction extends Function { + (event?: Meteor.Event, templateInstance?: Blaze.TemplateInstance): void; + } + interface EventMap { + [id: string]: Meteor.EventHandlerFunction; + } + /** Event **/ + + /** Connection **/ + function reconnect(): void; + + function disconnect(): void; + /** Connection **/ + + /** Status **/ + function status(): DDP.DDPStatus; + /** Status **/ + + /** Pub/Sub **/ + /** + * Subscribe to a record set. Returns a handle that provides + * `stop()` and `ready()` methods. + * @param name Name of the subscription. Matches the name of the + * server's `publish()` call. + * @param args Optional arguments passed to publisher + * function on server. + * @param callbacks Optional. May include `onStop` + * and `onReady` callbacks. If there is an error, it is passed as an + * argument to `onStop`. If a function is passed instead of an object, it + * is interpreted as an `onReady` callback. + */ + function subscribe(name: string, ...args: any[]): Meteor.SubscriptionHandle; + /** Pub/Sub **/ + + /** Connection **/ + interface Connection { + id: string; + close: () => void; + onClose: (callback: () => void) => void; + clientAddress: string; + httpHeaders: Object; + } + + function onConnection(callback: (connection: Connection) => void): void; + /** Connection **/ + /** + * Publish a record set. + * @param name If String, name of the record set. If Object, publications Dictionary of publish functions by name. If `null`, the set has no name, and the record set is automatically sent to + * all connected clients. + * @param func Function called on the server each time a client subscribes. Inside the function, `this` is the publish handler object, described below. If the client passed arguments to + * `subscribe`, the function is called with the same arguments. + */ + function publish( + name: string | null, + func: (this: Subscription, ...args: any[]) => void, + options?: { is_auto: boolean }, + ): void; + + function _debug(...args: any[]): void; + } + + export interface Subscription { + /** + * Call inside the publish function. Informs the subscriber that a document has been added to the record set. + * @param collection The name of the collection that contains the new document. + * @param id The new document's ID. + * @param fields The fields in the new document. If `_id` is present it is ignored. + */ + added(collection: string, id: string, fields: Object): void; + /** + * Call inside the publish function. Informs the subscriber that a document in the record set has been modified. + * @param collection The name of the collection that contains the changed document. + * @param id The changed document's ID. + * @param fields The fields in the document that have changed, together with their new values. If a field is not present in `fields` it was left unchanged; if it is present in `fields` and + * has a value of `undefined` it was removed from the document. If `_id` is present it is ignored. + */ + changed(collection: string, id: string, fields: Object): void; + /** Access inside the publish function. The incoming connection for this subscription. */ + connection: Meteor.Connection; + /** + * Call inside the publish function. Stops this client's subscription, triggering a call on the client to the `onStop` callback passed to `Meteor.subscribe`, if any. If `error` is not a + * `Meteor.Error`, it will be sanitized. + * @param error The error to pass to the client. + */ + error(error: Error): void; + /** + * Call inside the publish function. Registers a callback function to run when the subscription is stopped. + * @param func The callback function + */ + onStop(func: Function): void; + /** + * Call inside the publish function. Informs the subscriber that an initial, complete snapshot of the record set has been sent. This will trigger a call on the client to the `onReady` + * callback passed to `Meteor.subscribe`, if any. + */ + ready(): void; + /** + * Call inside the publish function. Informs the subscriber that a document has been removed from the record set. + * @param collection The name of the collection that the document has been removed from. + * @param id The ID of the document that has been removed. + */ + removed(collection: string, id: string): void; + /** + * Access inside the publish function. The incoming connection for this subscription. + */ + stop(): void; + /** + * Call inside the publish function. Allows subsequent methods or subscriptions for the client of this subscription + * to begin running without waiting for the publishing to become ready. + */ + unblock(): void; + /** Access inside the publish function. The id of the logged-in user, or `null` if no user is logged in. */ + userId: string | null; + } + + // namespace Meteor { + // /** Global props **/ + // /** True if running in development environment. */ + // var isDevelopment: boolean; + // var isTest: boolean; + // var isAppTest: boolean; + // /** Global props **/ + // } + diff --git a/packages/webui/src/meteor/meteor.js b/packages/webui/src/meteor/meteor.js new file mode 100644 index 0000000000..9da78e1585 --- /dev/null +++ b/packages/webui/src/meteor/meteor.js @@ -0,0 +1,438 @@ +const Meteor = { + _debug: (line) => { + console.debug(line) + }, + + _suppressed_log_expected: () => { + return true + }, + _suppress_log: (i) => { + // + }, + + _setImmediate: (cb) => { + return setTimeout(cb, 0) + }, + + makeErrorType: (name, constructor) => { + var errorClass = function (/*arguments*/) { + // Ensure we get a proper stack trace in most Javascript environments + if (Error.captureStackTrace) { + // V8 environments (Chrome and Node.js) + Error.captureStackTrace(this, errorClass) + } else { + // Borrow the .stack property of a native Error object. + this.stack = new Error().stack + } + // Safari magically works. + + constructor.apply(this, arguments) + + this.errorType = name + } + + Meteor._inherits(errorClass, Error) + + return errorClass + }, + + settings: { public: window.__meteor_runtime_config__?.PUBLIC_SETTINGS }, + + isClient: true, + isServer: false, + isTest: false, +} + +function withoutInvocation(f) { + var DDP = Meteor.DDP + if (DDP) { + var CurrentInvocation = + DDP._CurrentMethodInvocation || + // For backwards compatibility, as explained in this issue: + // https://github.com/meteor/meteor/issues/8947 + DDP._CurrentInvocation + + var invocation = CurrentInvocation.get() + if (invocation && invocation.isSimulation) { + throw new Error("Can't set timers inside simulations") + } + + return function () { + CurrentInvocation.withValue(null, f) + } + } else { + return f + } +} + +function bindAndCatch(context, f) { + return Meteor.bindEnvironment(withoutInvocation(f), context) +} + +// Meteor.setTimeout and Meteor.setInterval callbacks scheduled +// inside a server method are not part of the method invocation and +// should clear out the CurrentMethodInvocation environment variable. + +/** + * @memberOf Meteor + * @summary Call a function in the future after waiting for a specified delay. + * @locus Anywhere + * @param {Function} func The function to run + * @param {Number} delay Number of milliseconds to wait before calling function + */ +Meteor.setTimeout = function (f, duration) { + return setTimeout(bindAndCatch('setTimeout callback', f), duration) +} + +/** + * @memberOf Meteor + * @summary Call a function repeatedly, with a time delay between calls. + * @locus Anywhere + * @param {Function} func The function to run + * @param {Number} delay Number of milliseconds to wait between each function call. + */ +Meteor.setInterval = function (f, duration) { + return setInterval(bindAndCatch('setInterval callback', f), duration) +} + +/** + * @memberOf Meteor + * @summary Cancel a repeating function call scheduled by `Meteor.setInterval`. + * @locus Anywhere + * @param {Object} id The handle returned by `Meteor.setInterval` + */ +Meteor.clearInterval = function (x) { + return clearInterval(x) +} + +/** + * @memberOf Meteor + * @summary Cancel a function call scheduled by `Meteor.setTimeout`. + * @locus Anywhere + * @param {Object} id The handle returned by `Meteor.setTimeout` + */ +Meteor.clearTimeout = function (x) { + return clearTimeout(x) +} + +// XXX consider making this guarantee ordering of defer'd callbacks, like +// Tracker.afterFlush or Node's nextTick (in practice). Then tests can do: +// callSomethingThatDefersSomeWork(); +// Meteor.defer(expect(somethingThatValidatesThatTheWorkHappened)); + +/** + * @memberOf Meteor + * @summary Defer execution of a function to run asynchronously in the background (similar to `Meteor.setTimeout(func, 0)`. + * @locus Anywhere + * @param {Function} func The function to run + */ +Meteor.defer = function (f) { + Meteor._setImmediate(bindAndCatch('defer callback', f)) +} + +// This file is a partial analogue to fiber_helpers.js, which allows the client +// to use a queue too, and also to call noYieldsAllowed. + +// The client has no ability to yield, so noYieldsAllowed is a noop. +// +Meteor._noYieldsAllowed = function (f) { + return f() +} + +// An even simpler queue of tasks than the fiber-enabled one. This one just +// runs all the tasks when you call runTask or flush, synchronously. +// +Meteor._SynchronousQueue = function () { + var self = this + self._tasks = [] + self._running = false + self._runTimeout = null +} + +var SQp = Meteor._SynchronousQueue.prototype + +SQp.runTask = function (task) { + var self = this + if (!self.safeToRunTask()) throw new Error('Could not synchronously run a task from a running task') + self._tasks.push(task) + var tasks = self._tasks + self._tasks = [] + self._running = true + + if (self._runTimeout) { + // Since we're going to drain the queue, we can forget about the timeout + // which tries to run it. (But if one of our tasks queues something else, + // the timeout will be correctly re-created.) + clearTimeout(self._runTimeout) + self._runTimeout = null + } + + try { + while (tasks.length > 0) { + var t = tasks.shift() + try { + t() + } catch (e) { + if (tasks.length === 0) { + // this was the last task, that is, the one we're calling runTask + // for. + throw e + } + Meteor._debug('Exception in queued task', e) + } + } + } finally { + self._running = false + } +} + +SQp.queueTask = function (task) { + var self = this + self._tasks.push(task) + // Intentionally not using Meteor.setTimeout, because it doesn't like runing + // in stubs for now. + if (!self._runTimeout) { + self._runTimeout = setTimeout(function () { + return self.flush.apply(self, arguments) + }, 0) + } +} + +SQp.flush = function () { + var self = this + self.runTask(function () {}) +} + +SQp.drain = function () { + var self = this + if (!self.safeToRunTask()) { + return + } + while (self._tasks.length > 0) { + self.flush() + } +} + +SQp.safeToRunTask = function () { + var self = this + return !self._running +} + +// Sets child's prototype to a new object whose prototype is parent's +// prototype. Used as: +// Meteor._inherits(ClassB, ClassA). +// _.extend(ClassB.prototype, { ... }) +// Inspired by CoffeeScript's `extend` and Google Closure's `goog.inherits`. +var hasOwn = Object.prototype.hasOwnProperty +Meteor._inherits = function (Child, Parent) { + // copy Parent static properties + for (var key in Parent) { + // make sure we only copy hasOwnProperty properties vs. prototype + // properties + if (hasOwn.call(Parent, key)) { + Child[key] = Parent[key] + } + } + + // a middle member of prototype chain: takes the prototype from the Parent + var Middle = function () { + this.constructor = Child + } + Middle.prototype = Parent.prototype + Child.prototype = new Middle() + Child.__super__ = Parent.prototype + return Child +} + +{ + // Simple implementation of dynamic scoping, for use in browsers + + var nextSlot = 0 + var currentValues = [] + + Meteor.EnvironmentVariable = function () { + this.slot = nextSlot++ + } + + var EVp = Meteor.EnvironmentVariable.prototype + + EVp.get = function () { + return currentValues[this.slot] + } + + EVp.getOrNullIfOutsideFiber = function () { + return this.get() + } + + EVp.withValue = function (value, func) { + var saved = currentValues[this.slot] + try { + currentValues[this.slot] = value + var ret = func() + } finally { + currentValues[this.slot] = saved + } + return ret + } + + Meteor.bindEnvironment = function (func, onException, _this) { + // needed in order to be able to create closures inside func and + // have the closed variables not change back to their original + // values + var boundValues = currentValues.slice() + + if (!onException || typeof onException === 'string') { + var description = onException || 'callback of async function' + onException = function (error) { + Meteor._debug('Exception in ' + description + ':', error) + } + } + + return function (/* arguments */) { + var savedValues = currentValues + try { + currentValues = boundValues + var ret = func.apply(_this, arguments) + } catch (e) { + // note: callback-hook currently relies on the fact that if onException + // throws in the browser, the wrapped call throws. + onException(e) + } finally { + currentValues = savedValues + } + return ret + } + } + + Meteor._nodeCodeMustBeInFiber = function () { + // no-op on browser + } +} + +Meteor.Error = Meteor.makeErrorType('Meteor.Error', function (error, reason, details) { + var self = this + + // Newer versions of DDP use this property to signify that an error + // can be sent back and reconstructed on the calling client. + self.isClientSafe = true + + // String code uniquely identifying this kind of error. + self.error = error + + // Optional: A short human-readable summary of the error. Not + // intended to be shown to end users, just developers. ("Not Found", + // "Internal Server Error") + self.reason = reason + + // Optional: Additional information about the error, say for + // debugging. It might be a (textual) stack trace if the server is + // willing to provide one. The corresponding thing in HTTP would be + // the body of a 404 or 500 response. (The difference is that we + // never expect this to be shown to end users, only developers, so + // it doesn't need to be pretty.) + self.details = details + + // This is what gets displayed at the top of a stack trace. Current + // format is "[404]" (if no reason is set) or "File not found [404]" + if (self.reason) self.message = self.reason + ' [' + self.error + ']' + else self.message = '[' + self.error + ']' +}) + +// Meteor.Error is basically data and is sent over DDP, so you should be able to +// properly EJSON-clone it. This is especially important because if a +// Meteor.Error is thrown through a Future, the error, reason, and details +// properties become non-enumerable so a standard Object clone won't preserve +// them and they will be lost from DDP. +Meteor.Error.prototype.clone = function () { + var self = this + return new Meteor.Error(self.error, self.reason, self.details) +} + +/** + * @summary Generate an absolute URL pointing to the application. The server reads from the `ROOT_URL` environment variable to determine where it is running. This is taken care of automatically for apps deployed to Galaxy, but must be provided when using `meteor build`. + * @locus Anywhere + * @param {String} [path] A path to append to the root URL. Do not include a leading "`/`". + * @param {Object} [options] + * @param {Boolean} options.secure Create an HTTPS URL. + * @param {Boolean} options.replaceLocalhost Replace localhost with 127.0.0.1. Useful for services that don't recognize localhost as a domain name. + * @param {String} options.rootUrl Override the default ROOT_URL from the server environment. For example: "`http://foo.example.com`" + */ +Meteor.absoluteUrl = function (path, options) { + // path is optional + if (!options && typeof path === 'object') { + options = path + path = undefined + } + // merge options with defaults + options = Object.assign({}, Meteor.absoluteUrl.defaultOptions, options || {}) + + var url = options.rootUrl + if (!url) throw new Error('Must pass options.rootUrl or set ROOT_URL in the server environment') + + if (!/^http[s]?:\/\//i.test(url)) + // url starts with 'http://' or 'https://' + url = 'http://' + url // we will later fix to https if options.secure is set + + if (!url.endsWith('/')) { + url += '/' + } + + if (path) { + // join url and path with a / separator + while (path.startsWith('/')) { + path = path.slice(1) + } + url += path + } + + // turn http to https if secure option is set, and we're not talking + // to localhost. + if ( + options.secure && + /^http:/.test(url) && // url starts with 'http:' + !/http:\/\/localhost[:\/]/.test(url) && // doesn't match localhost + !/http:\/\/127\.0\.0\.1[:\/]/.test(url) + ) + // or 127.0.0.1 + url = url.replace(/^http:/, 'https:') + + if (options.replaceLocalhost) url = url.replace(/^http:\/\/localhost([:\/].*)/, 'http://127.0.0.1$1') + + return url +} + +// allow later packages to override default options +var defaultOptions = (Meteor.absoluteUrl.defaultOptions = {}) + +// available only in a browser environment +var location = typeof window === 'object' && window.location + +if (typeof window.__meteor_runtime_config__ === 'object' && window.__meteor_runtime_config__.ROOT_URL) { + defaultOptions.rootUrl = window.__meteor_runtime_config__.ROOT_URL +} else if (location && location.protocol && location.host) { + defaultOptions.rootUrl = location.protocol + '//' + location.host +} + +// Make absolute URLs use HTTPS by default if the current window.location +// uses HTTPS. Since this is just a default, it can be overridden by +// passing { secure: false } if necessary. +if (location && location.protocol === 'https:') { + defaultOptions.secure = true +} + +Meteor._relativeToSiteRootUrl = function (link) { + if (typeof window.__meteor_runtime_config__ === 'object' && link.substr(0, 1) === '/') + link = (window.__meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '') + link + return link +} + +export { Meteor } +window.Meteor = Meteor + +Meteor.startup = function (cb) { + cb() +} + +Meteor.user = function () { + return null +} diff --git a/packages/webui/src/meteor/minimongo/common.js b/packages/webui/src/meteor/minimongo/common.js new file mode 100644 index 0000000000..e9f960bafd --- /dev/null +++ b/packages/webui/src/meteor/minimongo/common.js @@ -0,0 +1,1656 @@ +import EJSON from 'ejson' +import { GeoJSON } from '../geojson-utils' +import { MongoID } from '../mongo-id'; + +export const hasOwn = Object.prototype.hasOwnProperty; + +// Each element selector contains: +// - compileElementSelector, a function with args: +// - operand - the "right hand side" of the operator +// - valueSelector - the "context" for the operator (so that $regex can find +// $options) +// - matcher - the Matcher this is going into (so that $elemMatch can compile +// more things) +// returning a function mapping a single value to bool. +// - dontExpandLeafArrays, a bool which prevents expandArraysInBranches from +// being called +// - dontIncludeLeafArrays, a bool which causes an argument to be passed to +// expandArraysInBranches if it is called +export const ELEMENT_OPERATORS = { + $lt: makeInequality(cmpValue => cmpValue < 0), + $gt: makeInequality(cmpValue => cmpValue > 0), + $lte: makeInequality(cmpValue => cmpValue <= 0), + $gte: makeInequality(cmpValue => cmpValue >= 0), + $mod: { + compileElementSelector(operand) { + if (!(Array.isArray(operand) && operand.length === 2 + && typeof operand[0] === 'number' + && typeof operand[1] === 'number')) { + throw Error('argument to $mod must be an array of two numbers'); + } + + // XXX could require to be ints or round or something + const divisor = operand[0]; + const remainder = operand[1]; + return value => ( + typeof value === 'number' && value % divisor === remainder + ); + }, + }, + $in: { + compileElementSelector(operand) { + if (!Array.isArray(operand)) { + throw Error('$in needs an array'); + } + + const elementMatchers = operand.map(option => { + if (option instanceof RegExp) { + return regexpElementMatcher(option); + } + + if (isOperatorObject(option)) { + throw Error('cannot nest $ under $in'); + } + + return equalityElementMatcher(option); + }); + + return value => { + // Allow {a: {$in: [null]}} to match when 'a' does not exist. + if (value === undefined) { + value = null; + } + + return elementMatchers.some(matcher => matcher(value)); + }; + }, + }, + $size: { + // {a: [[5, 5]]} must match {a: {$size: 1}} but not {a: {$size: 2}}, so we + // don't want to consider the element [5,5] in the leaf array [[5,5]] as a + // possible value. + dontExpandLeafArrays: true, + compileElementSelector(operand) { + if (typeof operand === 'string') { + // Don't ask me why, but by experimentation, this seems to be what Mongo + // does. + operand = 0; + } else if (typeof operand !== 'number') { + throw Error('$size needs a number'); + } + + return value => Array.isArray(value) && value.length === operand; + }, + }, + $type: { + // {a: [5]} must not match {a: {$type: 4}} (4 means array), but it should + // match {a: {$type: 1}} (1 means number), and {a: [[5]]} must match {$a: + // {$type: 4}}. Thus, when we see a leaf array, we *should* expand it but + // should *not* include it itself. + dontIncludeLeafArrays: true, + compileElementSelector(operand) { + if (typeof operand === 'string') { + const operandAliasMap = { + 'double': 1, + 'string': 2, + 'object': 3, + 'array': 4, + 'binData': 5, + 'undefined': 6, + 'objectId': 7, + 'bool': 8, + 'date': 9, + 'null': 10, + 'regex': 11, + 'dbPointer': 12, + 'javascript': 13, + 'symbol': 14, + 'javascriptWithScope': 15, + 'int': 16, + 'timestamp': 17, + 'long': 18, + 'decimal': 19, + 'minKey': -1, + 'maxKey': 127, + }; + if (!hasOwn.call(operandAliasMap, operand)) { + throw Error(`unknown string alias for $type: ${operand}`); + } + operand = operandAliasMap[operand]; + } else if (typeof operand === 'number') { + if (operand === 0 || operand < -1 + || (operand > 19 && operand !== 127)) { + throw Error(`Invalid numerical $type code: ${operand}`); + } + } else { + throw Error('argument to $type is not a number or a string'); + } + + return value => ( + value !== undefined && bigBlobF._type(value) === operand + ); + }, + }, + $bitsAllSet: { + compileElementSelector(operand) { + const mask = getOperandBitmask(operand, '$bitsAllSet'); + return value => { + const bitmask = getValueBitmask(value, mask.length); + return bitmask && mask.every((byte, i) => (bitmask[i] & byte) === byte); + }; + }, + }, + $bitsAnySet: { + compileElementSelector(operand) { + const mask = getOperandBitmask(operand, '$bitsAnySet'); + return value => { + const bitmask = getValueBitmask(value, mask.length); + return bitmask && mask.some((byte, i) => (~bitmask[i] & byte) !== byte); + }; + }, + }, + $bitsAllClear: { + compileElementSelector(operand) { + const mask = getOperandBitmask(operand, '$bitsAllClear'); + return value => { + const bitmask = getValueBitmask(value, mask.length); + return bitmask && mask.every((byte, i) => !(bitmask[i] & byte)); + }; + }, + }, + $bitsAnyClear: { + compileElementSelector(operand) { + const mask = getOperandBitmask(operand, '$bitsAnyClear'); + return value => { + const bitmask = getValueBitmask(value, mask.length); + return bitmask && mask.some((byte, i) => (bitmask[i] & byte) !== byte); + }; + }, + }, + $regex: { + compileElementSelector(operand, valueSelector) { + if (!(typeof operand === 'string' || operand instanceof RegExp)) { + throw Error('$regex has to be a string or RegExp'); + } + + let regexp; + if (valueSelector.$options !== undefined) { + // Options passed in $options (even the empty string) always overrides + // options in the RegExp object itself. + + // Be clear that we only support the JS-supported options, not extended + // ones (eg, Mongo supports x and s). Ideally we would implement x and s + // by transforming the regexp, but not today... + if (/[^gim]/.test(valueSelector.$options)) { + throw new Error('Only the i, m, and g regexp options are supported'); + } + + const source = operand instanceof RegExp ? operand.source : operand; + regexp = new RegExp(source, valueSelector.$options); + } else if (operand instanceof RegExp) { + regexp = operand; + } else { + regexp = new RegExp(operand); + } + + return regexpElementMatcher(regexp); + }, + }, + $elemMatch: { + dontExpandLeafArrays: true, + compileElementSelector(operand, valueSelector, matcher) { + if (!_isPlainObject(operand)) { + throw Error('$elemMatch need an object'); + } + + const isDocMatcher = !isOperatorObject( + Object.keys(operand) + .filter(key => !hasOwn.call(LOGICAL_OPERATORS, key)) + .reduce((a, b) => Object.assign(a, {[b]: operand[b]}), {}), + true); + + let subMatcher; + if (isDocMatcher) { + // This is NOT the same as compileValueSelector(operand), and not just + // because of the slightly different calling convention. + // {$elemMatch: {x: 3}} means "an element has a field x:3", not + // "consists only of a field x:3". Also, regexps and sub-$ are allowed. + subMatcher = + compileDocumentSelector(operand, matcher, {inElemMatch: true}); + } else { + subMatcher = compileValueSelector(operand, matcher); + } + + return value => { + if (!Array.isArray(value)) { + return false; + } + + for (let i = 0; i < value.length; ++i) { + const arrayElement = value[i]; + let arg; + if (isDocMatcher) { + // We can only match {$elemMatch: {b: 3}} against objects. + // (We can also match against arrays, if there's numeric indices, + // eg {$elemMatch: {'0.b': 3}} or {$elemMatch: {0: 3}}.) + if (!isIndexable(arrayElement)) { + return false; + } + + arg = arrayElement; + } else { + // dontIterate ensures that {a: {$elemMatch: {$gt: 5}}} matches + // {a: [8]} but not {a: [[8]]} + arg = [{value: arrayElement, dontIterate: true}]; + } + // XXX support $near in $elemMatch by propagating $distance? + if (subMatcher(arg).result) { + return i; // specially understood to mean "use as arrayIndices" + } + } + + return false; + }; + }, + }, +}; + +// Operators that appear at the top level of a document selector. +const LOGICAL_OPERATORS = { + $and(subSelector, matcher, inElemMatch) { + return andDocumentMatchers( + compileArrayOfDocumentSelectors(subSelector, matcher, inElemMatch) + ); + }, + + $or(subSelector, matcher, inElemMatch) { + const matchers = compileArrayOfDocumentSelectors( + subSelector, + matcher, + inElemMatch + ); + + // Special case: if there is only one matcher, use it directly, *preserving* + // any arrayIndices it returns. + if (matchers.length === 1) { + return matchers[0]; + } + + return doc => { + const result = matchers.some(fn => fn(doc).result); + // $or does NOT set arrayIndices when it has multiple + // sub-expressions. (Tested against MongoDB.) + return {result}; + }; + }, + + $nor(subSelector, matcher, inElemMatch) { + const matchers = compileArrayOfDocumentSelectors( + subSelector, + matcher, + inElemMatch + ); + return doc => { + const result = matchers.every(fn => !fn(doc).result); + // Never set arrayIndices, because we only match if nothing in particular + // 'matched' (and because this is consistent with MongoDB). + return {result}; + }; + }, + + $where(selectorValue, matcher) { + // Record that *any* path may be used. + matcher._recordPathUsed(''); + matcher._hasWhere = true; + + if (!(selectorValue instanceof Function)) { + // XXX MongoDB seems to have more complex logic to decide where or or not + // to add 'return'; not sure exactly what it is. + selectorValue = Function('obj', `return ${selectorValue}`); + } + + // We make the document available as both `this` and `obj`. + // // XXX not sure what we should do if this throws + return doc => ({result: selectorValue.call(doc, doc)}); + }, + + // This is just used as a comment in the query (in MongoDB, it also ends up in + // query logs); it has no effect on the actual selection. + $comment() { + return () => ({result: true}); + }, +}; + +// Operators that (unlike LOGICAL_OPERATORS) pertain to individual paths in a +// document, but (unlike ELEMENT_OPERATORS) do not have a simple definition as +// "match each branched value independently and combine with +// convertElementMatcherToBranchedMatcher". +const VALUE_OPERATORS = { + $eq(operand) { + return convertElementMatcherToBranchedMatcher( + equalityElementMatcher(operand) + ); + }, + $not(operand, valueSelector, matcher) { + return invertBranchedMatcher(compileValueSelector(operand, matcher)); + }, + $ne(operand) { + return invertBranchedMatcher( + convertElementMatcherToBranchedMatcher(equalityElementMatcher(operand)) + ); + }, + $nin(operand) { + return invertBranchedMatcher( + convertElementMatcherToBranchedMatcher( + ELEMENT_OPERATORS.$in.compileElementSelector(operand) + ) + ); + }, + $exists(operand) { + const exists = convertElementMatcherToBranchedMatcher( + value => value !== undefined + ); + return operand ? exists : invertBranchedMatcher(exists); + }, + // $options just provides options for $regex; its logic is inside $regex + $options(operand, valueSelector) { + if (!hasOwn.call(valueSelector, '$regex')) { + throw Error('$options needs a $regex'); + } + + return everythingMatcher; + }, + // $maxDistance is basically an argument to $near + $maxDistance(operand, valueSelector) { + if (!valueSelector.$near) { + throw Error('$maxDistance needs a $near'); + } + + return everythingMatcher; + }, + $all(operand, valueSelector, matcher) { + if (!Array.isArray(operand)) { + throw Error('$all requires array'); + } + + // Not sure why, but this seems to be what MongoDB does. + if (operand.length === 0) { + return nothingMatcher; + } + + const branchedMatchers = operand.map(criterion => { + // XXX handle $all/$elemMatch combination + if (isOperatorObject(criterion)) { + throw Error('no $ expressions in $all'); + } + + // This is always a regexp or equality selector. + return compileValueSelector(criterion, matcher); + }); + + // andBranchedMatchers does NOT require all selectors to return true on the + // SAME branch. + return andBranchedMatchers(branchedMatchers); + }, + $near(operand, valueSelector, matcher, isRoot) { + if (!isRoot) { + throw Error('$near can\'t be inside another $ operator'); + } + + matcher._hasGeoQuery = true; + + // There are two kinds of geodata in MongoDB: legacy coordinate pairs and + // GeoJSON. They use different distance metrics, too. GeoJSON queries are + // marked with a $geometry property, though legacy coordinates can be + // matched using $geometry. + let maxDistance, point, distance; + if (_isPlainObject(operand) && hasOwn.call(operand, '$geometry')) { + // GeoJSON "2dsphere" mode. + maxDistance = operand.$maxDistance; + point = operand.$geometry; + distance = value => { + // XXX: for now, we don't calculate the actual distance between, say, + // polygon and circle. If people care about this use-case it will get + // a priority. + if (!value) { + return null; + } + + if (!value.type) { + return GeoJSON.pointDistance( + point, + {type: 'Point', coordinates: pointToArray(value)} + ); + } + + if (value.type === 'Point') { + return GeoJSON.pointDistance(point, value); + } + + return GeoJSON.geometryWithinRadius(value, point, maxDistance) + ? 0 + : maxDistance + 1; + }; + } else { + maxDistance = valueSelector.$maxDistance; + + if (!isIndexable(operand)) { + throw Error('$near argument must be coordinate pair or GeoJSON'); + } + + point = pointToArray(operand); + + distance = value => { + if (!isIndexable(value)) { + return null; + } + + return distanceCoordinatePairs(point, value); + }; + } + + return branchedValues => { + // There might be multiple points in the document that match the given + // field. Only one of them needs to be within $maxDistance, but we need to + // evaluate all of them and use the nearest one for the implicit sort + // specifier. (That's why we can't just use ELEMENT_OPERATORS here.) + // + // Note: This differs from MongoDB's implementation, where a document will + // actually show up *multiple times* in the result set, with one entry for + // each within-$maxDistance branching point. + const result = {result: false}; + expandArraysInBranches(branchedValues).every(branch => { + // if operation is an update, don't skip branches, just return the first + // one (#3599) + let curDistance; + if (!matcher._isUpdate) { + if (!(typeof branch.value === 'object')) { + return true; + } + + curDistance = distance(branch.value); + + // Skip branches that aren't real points or are too far away. + if (curDistance === null || curDistance > maxDistance) { + return true; + } + + // Skip anything that's a tie. + if (result.distance !== undefined && result.distance <= curDistance) { + return true; + } + } + + result.result = true; + result.distance = curDistance; + + if (branch.arrayIndices) { + result.arrayIndices = branch.arrayIndices; + } else { + delete result.arrayIndices; + } + + return !matcher._isUpdate; + }); + + return result; + }; + }, +}; + +// NB: We are cheating and using this function to implement 'AND' for both +// 'document matchers' and 'branched matchers'. They both return result objects +// but the argument is different: for the former it's a whole doc, whereas for +// the latter it's an array of 'branched values'. +function andSomeMatchers(subMatchers) { + if (subMatchers.length === 0) { + return everythingMatcher; + } + + if (subMatchers.length === 1) { + return subMatchers[0]; + } + + return docOrBranches => { + const match = {}; + match.result = subMatchers.every(fn => { + const subResult = fn(docOrBranches); + + // Copy a 'distance' number out of the first sub-matcher that has + // one. Yes, this means that if there are multiple $near fields in a + // query, something arbitrary happens; this appears to be consistent with + // Mongo. + if (subResult.result && + subResult.distance !== undefined && + match.distance === undefined) { + match.distance = subResult.distance; + } + + // Similarly, propagate arrayIndices from sub-matchers... but to match + // MongoDB behavior, this time the *last* sub-matcher with arrayIndices + // wins. + if (subResult.result && subResult.arrayIndices) { + match.arrayIndices = subResult.arrayIndices; + } + + return subResult.result; + }); + + // If we didn't actually match, forget any extra metadata we came up with. + if (!match.result) { + delete match.distance; + delete match.arrayIndices; + } + + return match; + }; +} + +const andDocumentMatchers = andSomeMatchers; +const andBranchedMatchers = andSomeMatchers; + +function compileArrayOfDocumentSelectors(selectors, matcher, inElemMatch) { + if (!Array.isArray(selectors) || selectors.length === 0) { + throw Error('$and/$or/$nor must be nonempty array'); + } + + return selectors.map(subSelector => { + if (!_isPlainObject(subSelector)) { + throw Error('$or/$and/$nor entries need to be full objects'); + } + + return compileDocumentSelector(subSelector, matcher, {inElemMatch}); + }); +} + +// Takes in a selector that could match a full document (eg, the original +// selector). Returns a function mapping document->result object. +// +// matcher is the Matcher object we are compiling. +// +// If this is the root document selector (ie, not wrapped in $and or the like), +// then isRoot is true. (This is used by $near.) +export function compileDocumentSelector(docSelector, matcher, options = {}) { + const docMatchers = Object.keys(docSelector).map(key => { + const subSelector = docSelector[key]; + + if (key.substr(0, 1) === '$') { + // Outer operators are either logical operators (they recurse back into + // this function), or $where. + if (!hasOwn.call(LOGICAL_OPERATORS, key)) { + throw new Error(`Unrecognized logical operator: ${key}`); + } + + matcher._isSimple = false; + return LOGICAL_OPERATORS[key](subSelector, matcher, options.inElemMatch); + } + + // Record this path, but only if we aren't in an elemMatcher, since in an + // elemMatch this is a path inside an object in an array, not in the doc + // root. + if (!options.inElemMatch) { + matcher._recordPathUsed(key); + } + + // Don't add a matcher if subSelector is a function -- this is to match + // the behavior of Meteor on the server (inherited from the node mongodb + // driver), which is to ignore any part of a selector which is a function. + if (typeof subSelector === 'function') { + return undefined; + } + + const lookUpByIndex = makeLookupFunction(key); + const valueMatcher = compileValueSelector( + subSelector, + matcher, + options.isRoot + ); + + return doc => valueMatcher(lookUpByIndex(doc)); + }).filter(Boolean); + + return andDocumentMatchers(docMatchers); +} + +// Takes in a selector that could match a key-indexed value in a document; eg, +// {$gt: 5, $lt: 9}, or a regular expression, or any non-expression object (to +// indicate equality). Returns a branched matcher: a function mapping +// [branched value]->result object. +function compileValueSelector(valueSelector, matcher, isRoot) { + if (valueSelector instanceof RegExp) { + matcher._isSimple = false; + return convertElementMatcherToBranchedMatcher( + regexpElementMatcher(valueSelector) + ); + } + + if (isOperatorObject(valueSelector)) { + return operatorBranchedMatcher(valueSelector, matcher, isRoot); + } + + return convertElementMatcherToBranchedMatcher( + equalityElementMatcher(valueSelector) + ); +} + +// Given an element matcher (which evaluates a single value), returns a branched +// value (which evaluates the element matcher on all the branches and returns a +// more structured return value possibly including arrayIndices). +function convertElementMatcherToBranchedMatcher(elementMatcher, options = {}) { + return branches => { + const expanded = options.dontExpandLeafArrays + ? branches + : expandArraysInBranches(branches, options.dontIncludeLeafArrays); + + const match = {}; + match.result = expanded.some(element => { + let matched = elementMatcher(element.value); + + // Special case for $elemMatch: it means "true, and use this as an array + // index if I didn't already have one". + if (typeof matched === 'number') { + // XXX This code dates from when we only stored a single array index + // (for the outermost array). Should we be also including deeper array + // indices from the $elemMatch match? + if (!element.arrayIndices) { + element.arrayIndices = [matched]; + } + + matched = true; + } + + // If some element matched, and it's tagged with array indices, include + // those indices in our result object. + if (matched && element.arrayIndices) { + match.arrayIndices = element.arrayIndices; + } + + return matched; + }); + + return match; + }; +} + +// Helpers for $near. +function distanceCoordinatePairs(a, b) { + const pointA = pointToArray(a); + const pointB = pointToArray(b); + + return Math.hypot(pointA[0] - pointB[0], pointA[1] - pointB[1]); +} + +// Takes something that is not an operator object and returns an element matcher +// for equality with that thing. +export function equalityElementMatcher(elementSelector) { + if (isOperatorObject(elementSelector)) { + throw Error('Can\'t create equalityValueSelector for operator object'); + } + + // Special-case: null and undefined are equal (if you got undefined in there + // somewhere, or if you got it due to some branch being non-existent in the + // weird special case), even though they aren't with EJSON.equals. + // undefined or null + if (elementSelector == null) { + return value => value == null; + } + + return value => bigBlobF._equal(elementSelector, value); +} + +function everythingMatcher(docOrBranchedValues) { + return {result: true}; +} + +export function expandArraysInBranches(branches, skipTheArrays) { + const branchesOut = []; + + branches.forEach(branch => { + const thisIsArray = Array.isArray(branch.value); + + // We include the branch itself, *UNLESS* we it's an array that we're going + // to iterate and we're told to skip arrays. (That's right, we include some + // arrays even skipTheArrays is true: these are arrays that were found via + // explicit numerical indices.) + if (!(skipTheArrays && thisIsArray && !branch.dontIterate)) { + branchesOut.push({arrayIndices: branch.arrayIndices, value: branch.value}); + } + + if (thisIsArray && !branch.dontIterate) { + branch.value.forEach((value, i) => { + branchesOut.push({ + arrayIndices: (branch.arrayIndices || []).concat(i), + value + }); + }); + } + }); + + return branchesOut; +} + +// Helpers for $bitsAllSet/$bitsAnySet/$bitsAllClear/$bitsAnyClear. +function getOperandBitmask(operand, selector) { + // numeric bitmask + // You can provide a numeric bitmask to be matched against the operand field. + // It must be representable as a non-negative 32-bit signed integer. + // Otherwise, $bitsAllSet will return an error. + if (Number.isInteger(operand) && operand >= 0) { + return new Uint8Array(new Int32Array([operand]).buffer); + } + + // bindata bitmask + // You can also use an arbitrarily large BinData instance as a bitmask. + if (EJSON.isBinary(operand)) { + return new Uint8Array(operand.buffer); + } + + // position list + // If querying a list of bit positions, each must be a non-negative + // integer. Bit positions start at 0 from the least significant bit. + if (Array.isArray(operand) && + operand.every(x => Number.isInteger(x) && x >= 0)) { + const buffer = new ArrayBuffer((Math.max(...operand) >> 3) + 1); + const view = new Uint8Array(buffer); + + operand.forEach(x => { + view[x >> 3] |= 1 << (x & 0x7); + }); + + return view; + } + + // bad operand + throw Error( + `operand to ${selector} must be a numeric bitmask (representable as a ` + + 'non-negative 32-bit signed integer), a bindata bitmask or an array with ' + + 'bit positions (non-negative integers)' + ); +} + +function getValueBitmask(value, length) { + // The field value must be either numerical or a BinData instance. Otherwise, + // $bits... will not match the current document. + + // numerical + if (Number.isSafeInteger(value)) { + // $bits... will not match numerical values that cannot be represented as a + // signed 64-bit integer. This can be the case if a value is either too + // large or small to fit in a signed 64-bit integer, or if it has a + // fractional component. + const buffer = new ArrayBuffer( + Math.max(length, 2 * Uint32Array.BYTES_PER_ELEMENT) + ); + + let view = new Uint32Array(buffer, 0, 2); + view[0] = value % ((1 << 16) * (1 << 16)) | 0; + view[1] = value / ((1 << 16) * (1 << 16)) | 0; + + // sign extension + if (value < 0) { + view = new Uint8Array(buffer, 2); + view.forEach((byte, i) => { + view[i] = 0xff; + }); + } + + return new Uint8Array(buffer); + } + + // bindata + if (EJSON.isBinary(value)) { + return new Uint8Array(value.buffer); + } + + // no match + return false; +} + +// Actually inserts a key value into the selector document +// However, this checks there is no ambiguity in setting +// the value for the given key, throws otherwise +function insertIntoDocument(document, key, value) { + Object.keys(document).forEach(existingKey => { + if ( + (existingKey.length > key.length && existingKey.indexOf(`${key}.`) === 0) || + (key.length > existingKey.length && key.indexOf(`${existingKey}.`) === 0) + ) { + throw new Error( + `cannot infer query fields to set, both paths '${existingKey}' and ` + + `'${key}' are matched` + ); + } else if (existingKey === key) { + throw new Error( + `cannot infer query fields to set, path '${key}' is matched twice` + ); + } + }); + + document[key] = value; +} + +// Returns a branched matcher that matches iff the given matcher does not. +// Note that this implicitly "deMorganizes" the wrapped function. ie, it +// means that ALL branch values need to fail to match innerBranchedMatcher. +function invertBranchedMatcher(branchedMatcher) { + return branchValues => { + // We explicitly choose to strip arrayIndices here: it doesn't make sense to + // say "update the array element that does not match something", at least + // in mongo-land. + return {result: !branchedMatcher(branchValues).result}; + }; +} + +export function isIndexable(obj) { + return Array.isArray(obj) || _isPlainObject(obj); +} + +export function isNumericKey(s) { + return /^[0-9]+$/.test(s); +} + +// Returns true if this is an object with at least one key and all keys begin +// with $. Unless inconsistentOK is set, throws if some keys begin with $ and +// others don't. +export function isOperatorObject(valueSelector, inconsistentOK) { + if (!_isPlainObject(valueSelector)) { + return false; + } + + let theseAreOperators = undefined; + Object.keys(valueSelector).forEach(selKey => { + const thisIsOperator = selKey.substr(0, 1) === '$' || selKey === 'diff'; + + if (theseAreOperators === undefined) { + theseAreOperators = thisIsOperator; + } else if (theseAreOperators !== thisIsOperator) { + if (!inconsistentOK) { + throw new Error( + `Inconsistent operator: ${JSON.stringify(valueSelector)}` + ); + } + + theseAreOperators = false; + } + }); + + return !!theseAreOperators; // {} has no operators +} + +// Helper for $lt/$gt/$lte/$gte. +function makeInequality(cmpValueComparator) { + return { + compileElementSelector(operand) { + // Arrays never compare false with non-arrays for any inequality. + // XXX This was behavior we observed in pre-release MongoDB 2.5, but + // it seems to have been reverted. + // See https://jira.mongodb.org/browse/SERVER-11444 + if (Array.isArray(operand)) { + return () => false; + } + + // Special case: consider undefined and null the same (so true with + // $gte/$lte). + if (operand === undefined) { + operand = null; + } + + const operandType = bigBlobF._type(operand); + + return value => { + if (value === undefined) { + value = null; + } + + // Comparisons are never true among things of different type (except + // null vs undefined). + if (bigBlobF._type(value) !== operandType) { + return false; + } + + return cmpValueComparator(bigBlobF._cmp(value, operand)); + }; + }, + }; +} + +// makeLookupFunction(key) returns a lookup function. +// +// A lookup function takes in a document and returns an array of matching +// branches. If no arrays are found while looking up the key, this array will +// have exactly one branches (possibly 'undefined', if some segment of the key +// was not found). +// +// If arrays are found in the middle, this can have more than one element, since +// we 'branch'. When we 'branch', if there are more key segments to look up, +// then we only pursue branches that are plain objects (not arrays or scalars). +// This means we can actually end up with no branches! +// +// We do *NOT* branch on arrays that are found at the end (ie, at the last +// dotted member of the key). We just return that array; if you want to +// effectively 'branch' over the array's values, post-process the lookup +// function with expandArraysInBranches. +// +// Each branch is an object with keys: +// - value: the value at the branch +// - dontIterate: an optional bool; if true, it means that 'value' is an array +// that expandArraysInBranches should NOT expand. This specifically happens +// when there is a numeric index in the key, and ensures the +// perhaps-surprising MongoDB behavior where {'a.0': 5} does NOT +// match {a: [[5]]}. +// - arrayIndices: if any array indexing was done during lookup (either due to +// explicit numeric indices or implicit branching), this will be an array of +// the array indices used, from outermost to innermost; it is falsey or +// absent if no array index is used. If an explicit numeric index is used, +// the index will be followed in arrayIndices by the string 'x'. +// +// Note: arrayIndices is used for two purposes. First, it is used to +// implement the '$' modifier feature, which only ever looks at its first +// element. +// +// Second, it is used for sort key generation, which needs to be able to tell +// the difference between different paths. Moreover, it needs to +// differentiate between explicit and implicit branching, which is why +// there's the somewhat hacky 'x' entry: this means that explicit and +// implicit array lookups will have different full arrayIndices paths. (That +// code only requires that different paths have different arrayIndices; it +// doesn't actually 'parse' arrayIndices. As an alternative, arrayIndices +// could contain objects with flags like 'implicit', but I think that only +// makes the code surrounding them more complex.) +// +// (By the way, this field ends up getting passed around a lot without +// cloning, so never mutate any arrayIndices field/var in this package!) +// +// +// At the top level, you may only pass in a plain object or array. +// +// See the test 'minimongo - lookup' for some examples of what lookup functions +// return. +export function makeLookupFunction(key, options = {}) { + const parts = key.split('.'); + const firstPart = parts.length ? parts[0] : ''; + const lookupRest = ( + parts.length > 1 && + makeLookupFunction(parts.slice(1).join('.'), options) + ); + + const omitUnnecessaryFields = result => { + if (!result.dontIterate) { + delete result.dontIterate; + } + + if (result.arrayIndices && !result.arrayIndices.length) { + delete result.arrayIndices; + } + + return result; + }; + + // Doc will always be a plain object or an array. + // apply an explicit numeric index, an array. + return (doc, arrayIndices = []) => { + if (Array.isArray(doc)) { + // If we're being asked to do an invalid lookup into an array (non-integer + // or out-of-bounds), return no results (which is different from returning + // a single undefined result, in that `null` equality checks won't match). + if (!(isNumericKey(firstPart) && firstPart < doc.length)) { + return []; + } + + // Remember that we used this array index. Include an 'x' to indicate that + // the previous index came from being considered as an explicit array + // index (not branching). + arrayIndices = arrayIndices.concat(+firstPart, 'x'); + } + + // Do our first lookup. + const firstLevel = doc[firstPart]; + + // If there is no deeper to dig, return what we found. + // + // If what we found is an array, most value selectors will choose to treat + // the elements of the array as matchable values in their own right, but + // that's done outside of the lookup function. (Exceptions to this are $size + // and stuff relating to $elemMatch. eg, {a: {$size: 2}} does not match {a: + // [[1, 2]]}.) + // + // That said, if we just did an *explicit* array lookup (on doc) to find + // firstLevel, and firstLevel is an array too, we do NOT want value + // selectors to iterate over it. eg, {'a.0': 5} does not match {a: [[5]]}. + // So in that case, we mark the return value as 'don't iterate'. + if (!lookupRest) { + return [omitUnnecessaryFields({ + arrayIndices, + dontIterate: Array.isArray(doc) && Array.isArray(firstLevel), + value: firstLevel + })]; + } + + // We need to dig deeper. But if we can't, because what we've found is not + // an array or plain object, we're done. If we just did a numeric index into + // an array, we return nothing here (this is a change in Mongo 2.5 from + // Mongo 2.4, where {'a.0.b': null} stopped matching {a: [5]}). Otherwise, + // return a single `undefined` (which can, for example, match via equality + // with `null`). + if (!isIndexable(firstLevel)) { + if (Array.isArray(doc)) { + return []; + } + + return [omitUnnecessaryFields({arrayIndices, value: undefined})]; + } + + const result = []; + const appendToResult = more => { + result.push(...more); + }; + + // Dig deeper: look up the rest of the parts on whatever we've found. + // (lookupRest is smart enough to not try to do invalid lookups into + // firstLevel if it's an array.) + appendToResult(lookupRest(firstLevel, arrayIndices)); + + // If we found an array, then in *addition* to potentially treating the next + // part as a literal integer lookup, we should also 'branch': try to look up + // the rest of the parts on each array element in parallel. + // + // In this case, we *only* dig deeper into array elements that are plain + // objects. (Recall that we only got this far if we have further to dig.) + // This makes sense: we certainly don't dig deeper into non-indexable + // objects. And it would be weird to dig into an array: it's simpler to have + // a rule that explicit integer indexes only apply to an outer array, not to + // an array you find after a branching search. + // + // In the special case of a numeric part in a *sort selector* (not a query + // selector), we skip the branching: we ONLY allow the numeric part to mean + // 'look up this index' in that case, not 'also look up this index in all + // the elements of the array'. + if (Array.isArray(firstLevel) && + !(isNumericKey(parts[1]) && options.forSort)) { + firstLevel.forEach((branch, arrayIndex) => { + if (_isPlainObject(branch)) { + appendToResult(lookupRest(branch, arrayIndices.concat(arrayIndex))); + } + }); + } + + return result; + }; +} + +export const _isPlainObject = x => { + return x && bigBlobF._type(x) === 3; +}; + +// Object exported only for unit testing. +// Use it to export private functions to test in Tinytest. +// MinimongoTest = {makeLookupFunction}; +export const MinimongoError = (message, options = {}) => { + if (typeof message === 'string' && options.field) { + message += ` for field '${options.field}'`; + } + + const error = new Error(message); + error.name = 'MinimongoError'; + return error; +}; + +export function nothingMatcher(docOrBranchedValues) { + return {result: false}; +} + +// Takes an operator object (an object with $ keys) and returns a branched +// matcher for it. +function operatorBranchedMatcher(valueSelector, matcher, isRoot) { + // Each valueSelector works separately on the various branches. So one + // operator can match one branch and another can match another branch. This + // is OK. + const operatorMatchers = Object.keys(valueSelector).map(operator => { + const operand = valueSelector[operator]; + + const simpleRange = ( + ['$lt', '$lte', '$gt', '$gte'].includes(operator) && + typeof operand === 'number' + ); + + const simpleEquality = ( + ['$ne', '$eq'].includes(operator) && + operand !== Object(operand) + ); + + const simpleInclusion = ( + ['$in', '$nin'].includes(operator) + && Array.isArray(operand) + && !operand.some(x => x === Object(x)) + ); + + if (!(simpleRange || simpleInclusion || simpleEquality)) { + matcher._isSimple = false; + } + + if (hasOwn.call(VALUE_OPERATORS, operator)) { + return VALUE_OPERATORS[operator](operand, valueSelector, matcher, isRoot); + } + + if (hasOwn.call(ELEMENT_OPERATORS, operator)) { + const options = ELEMENT_OPERATORS[operator]; + return convertElementMatcherToBranchedMatcher( + options.compileElementSelector(operand, valueSelector, matcher), + options + ); + } + + throw new Error(`Unrecognized operator: ${operator}`); + }); + + return andBranchedMatchers(operatorMatchers); +} + +// paths - Array: list of mongo style paths +// newLeafFn - Function: of form function(path) should return a scalar value to +// put into list created for that path +// conflictFn - Function: of form function(node, path, fullPath) is called +// when building a tree path for 'fullPath' node on +// 'path' was already a leaf with a value. Must return a +// conflict resolution. +// initial tree - Optional Object: starting tree. +// @returns - Object: tree represented as a set of nested objects +export function pathsToTree(paths, newLeafFn, conflictFn, root = {}) { + paths.forEach(path => { + const pathArray = path.split('.'); + let tree = root; + + // use .every just for iteration with break + const success = pathArray.slice(0, -1).every((key, i) => { + if (!hasOwn.call(tree, key)) { + tree[key] = {}; + } else if (tree[key] !== Object(tree[key])) { + tree[key] = conflictFn( + tree[key], + pathArray.slice(0, i + 1).join('.'), + path + ); + + // break out of loop if we are failing for this path + if (tree[key] !== Object(tree[key])) { + return false; + } + } + + tree = tree[key]; + + return true; + }); + + if (success) { + const lastKey = pathArray[pathArray.length - 1]; + if (hasOwn.call(tree, lastKey)) { + tree[lastKey] = conflictFn(tree[lastKey], path, path); + } else { + tree[lastKey] = newLeafFn(path); + } + } + }); + + return root; +} + +// Makes sure we get 2 elements array and assume the first one to be x and +// the second one to y no matter what user passes. +// In case user passes { lon: x, lat: y } returns [x, y] +function pointToArray(point) { + return Array.isArray(point) ? point.slice() : [point.x, point.y]; +} + +// Creating a document from an upsert is quite tricky. +// E.g. this selector: {"$or": [{"b.foo": {"$all": ["bar"]}}]}, should result +// in: {"b.foo": "bar"} +// But this selector: {"$or": [{"b": {"foo": {"$all": ["bar"]}}}]} should throw +// an error + +// Some rules (found mainly with trial & error, so there might be more): +// - handle all childs of $and (or implicit $and) +// - handle $or nodes with exactly 1 child +// - ignore $or nodes with more than 1 child +// - ignore $nor and $not nodes +// - throw when a value can not be set unambiguously +// - every value for $all should be dealt with as separate $eq-s +// - threat all children of $all as $eq setters (=> set if $all.length === 1, +// otherwise throw error) +// - you can not mix '$'-prefixed keys and non-'$'-prefixed keys +// - you can only have dotted keys on a root-level +// - you can not have '$'-prefixed keys more than one-level deep in an object + +// Handles one key/value pair to put in the selector document +function populateDocumentWithKeyValue(document, key, value) { + if (value && Object.getPrototypeOf(value) === Object.prototype) { + populateDocumentWithObject(document, key, value); + } else if (!(value instanceof RegExp)) { + insertIntoDocument(document, key, value); + } +} + +// Handles a key, value pair to put in the selector document +// if the value is an object +function populateDocumentWithObject(document, key, value) { + const keys = Object.keys(value); + const unprefixedKeys = keys.filter(op => op[0] !== '$'); + + if (unprefixedKeys.length > 0 || !keys.length) { + // Literal (possibly empty) object ( or empty object ) + // Don't allow mixing '$'-prefixed with non-'$'-prefixed fields + if (keys.length !== unprefixedKeys.length) { + throw new Error(`unknown operator: ${unprefixedKeys[0]}`); + } + + validateObject(value, key); + insertIntoDocument(document, key, value); + } else { + Object.keys(value).forEach(op => { + const object = value[op]; + + if (op === '$eq') { + populateDocumentWithKeyValue(document, key, object); + } else if (op === '$all') { + // every value for $all should be dealt with as separate $eq-s + object.forEach(element => + populateDocumentWithKeyValue(document, key, element) + ); + } + }); + } +} + +// Fills a document with certain fields from an upsert selector +export function populateDocumentWithQueryFields(query, document = {}) { + if (Object.getPrototypeOf(query) === Object.prototype) { + // handle implicit $and + Object.keys(query).forEach(key => { + const value = query[key]; + + if (key === '$and') { + // handle explicit $and + value.forEach(element => + populateDocumentWithQueryFields(element, document) + ); + } else if (key === '$or') { + // handle $or nodes with exactly 1 child + if (value.length === 1) { + populateDocumentWithQueryFields(value[0], document); + } + } else if (key[0] !== '$') { + // Ignore other '$'-prefixed logical selectors + populateDocumentWithKeyValue(document, key, value); + } + }); + } else { + // Handle meteor-specific shortcut for selecting _id + if (selectorIsId(query)) { + insertIntoDocument(document, '_id', query); + } + } + + return document; +} + +// Is this selector just shorthand for lookup by _id? +export function selectorIsId (selector) { + return typeof selector === 'number' || + typeof selector === 'string' || + selector instanceof MongoID.ObjectID +}; + + +// Traverses the keys of passed projection and constructs a tree where all +// leaves are either all True or all False +// @returns Object: +// - tree - Object - tree representation of keys involved in projection +// (exception for '_id' as it is a special case handled separately) +// - including - Boolean - "take only certain fields" type of projection +export function projectionDetails(fields) { + // Find the non-_id keys (_id is handled specially because it is included + // unless explicitly excluded). Sort the keys, so that our code to detect + // overlaps like 'foo' and 'foo.bar' can assume that 'foo' comes first. + let fieldsKeys = Object.keys(fields).sort(); + + // If _id is the only field in the projection, do not remove it, since it is + // required to determine if this is an exclusion or exclusion. Also keep an + // inclusive _id, since inclusive _id follows the normal rules about mixing + // inclusive and exclusive fields. If _id is not the only field in the + // projection and is exclusive, remove it so it can be handled later by a + // special case, since exclusive _id is always allowed. + if (!(fieldsKeys.length === 1 && fieldsKeys[0] === '_id') && + !(fieldsKeys.includes('_id') && fields._id)) { + fieldsKeys = fieldsKeys.filter(key => key !== '_id'); + } + + let including = null; // Unknown + + fieldsKeys.forEach(keyPath => { + const rule = !!fields[keyPath]; + + if (including === null) { + including = rule; + } + + // This error message is copied from MongoDB shell + if (including !== rule) { + throw MinimongoError( + 'You cannot currently mix including and excluding fields.' + ); + } + }); + + const projectionRulesTree = pathsToTree( + fieldsKeys, + path => including, + (node, path, fullPath) => { + // Check passed projection fields' keys: If you have two rules such as + // 'foo.bar' and 'foo.bar.baz', then the result becomes ambiguous. If + // that happens, there is a probability you are doing something wrong, + // framework should notify you about such mistake earlier on cursor + // compilation step than later during runtime. Note, that real mongo + // doesn't do anything about it and the later rule appears in projection + // project, more priority it takes. + // + // Example, assume following in mongo shell: + // > db.coll.insert({ a: { b: 23, c: 44 } }) + // > db.coll.find({}, { 'a': 1, 'a.b': 1 }) + // {"_id": ObjectId("520bfe456024608e8ef24af3"), "a": {"b": 23}} + // > db.coll.find({}, { 'a.b': 1, 'a': 1 }) + // {"_id": ObjectId("520bfe456024608e8ef24af3"), "a": {"b": 23, "c": 44}} + // + // Note, how second time the return set of keys is different. + const currentPath = fullPath; + const anotherPath = path; + throw MinimongoError( + `both ${currentPath} and ${anotherPath} found in fields option, ` + + 'using both of them may trigger unexpected behavior. Did you mean to ' + + 'use only one of them?' + ); + }); + + return {including, tree: projectionRulesTree}; +} + +// Takes a RegExp object and returns an element matcher. +export function regexpElementMatcher(regexp) { + return value => { + if (value instanceof RegExp) { + return value.toString() === regexp.toString(); + } + + // Regexps only work against strings. + if (typeof value !== 'string') { + return false; + } + + // Reset regexp's state to avoid inconsistent matching for objects with the + // same value on consecutive calls of regexp.test. This happens only if the + // regexp has the 'g' flag. Also note that ES6 introduces a new flag 'y' for + // which we should *not* change the lastIndex but MongoDB doesn't support + // either of these flags. + regexp.lastIndex = 0; + + return regexp.test(value); + }; +} + +// Validates the key in a path. +// Objects that are nested more then 1 level cannot have dotted fields +// or fields starting with '$' +function validateKeyInPath(key, path) { + if (key.includes('.')) { + throw new Error( + `The dotted field '${key}' in '${path}.${key} is not valid for storage.` + ); + } + + if (key[0] === '$') { + throw new Error( + `The dollar ($) prefixed field '${path}.${key} is not valid for storage.` + ); + } +} + +// Recursively validates an object that is nested more than one level deep +function validateObject(object, path) { + if (object && Object.getPrototypeOf(object) === Object.prototype) { + Object.keys(object).forEach(key => { + validateKeyInPath(key, path); + validateObject(object[key], path + '.' + key); + }); + } +} + +const Decimal = /*Package['mongo-decimal']?.Decimal ||*/ class DecimalStub {} + +export const bigBlobF = { + // XXX for _all and _in, consider building 'inquery' at compile time.. + _type(v) { + if (typeof v === 'number') { + return 1; + } + + if (typeof v === 'string') { + return 2; + } + + if (typeof v === 'boolean') { + return 8; + } + + if (Array.isArray(v)) { + return 4; + } + + if (v === null) { + return 10; + } + + // note that typeof(/x/) === "object" + if (v instanceof RegExp) { + return 11; + } + + if (typeof v === 'function') { + return 13; + } + + if (v instanceof Date) { + return 9; + } + + if (EJSON.isBinary(v)) { + return 5; + } + + if (v instanceof MongoID.ObjectID) { + return 7; + } + + if (v instanceof Decimal) { + return 1; + } + + // object + return 3; + + // XXX support some/all of these: + // 14, symbol + // 15, javascript code with scope + // 16, 18: 32-bit/64-bit integer + // 17, timestamp + // 255, minkey + // 127, maxkey + }, + + // deep equality test: use for literal document and array matches + _equal(a, b) { + return EJSON.equals(a, b, {keyOrderSensitive: true}); + }, + + // maps a type code to a value that can be used to sort values of different + // types + _typeorder(t) { + // http://www.mongodb.org/display/DOCS/What+is+the+Compare+Order+for+BSON+Types + // XXX what is the correct sort position for Javascript code? + // ('100' in the matrix below) + // XXX minkey/maxkey + return [ + -1, // (not a type) + 1, // number + 2, // string + 3, // object + 4, // array + 5, // binary + -1, // deprecated + 6, // ObjectID + 7, // bool + 8, // Date + 0, // null + 9, // RegExp + -1, // deprecated + 100, // JS code + 2, // deprecated (symbol) + 100, // JS code + 1, // 32-bit int + 8, // Mongo timestamp + 1 // 64-bit int + ][t]; + }, + + // compare two values of unknown type according to BSON ordering + // semantics. (as an extension, consider 'undefined' to be less than + // any other value.) return negative if a is less, positive if b is + // less, or 0 if equal + _cmp(a, b) { + if (a === undefined) { + return b === undefined ? 0 : -1; + } + + if (b === undefined) { + return 1; + } + + let ta = bigBlobF._type(a); + let tb = bigBlobF._type(b); + + const oa = bigBlobF._typeorder(ta); + const ob = bigBlobF._typeorder(tb); + + if (oa !== ob) { + return oa < ob ? -1 : 1; + } + + // XXX need to implement this if we implement Symbol or integers, or + // Timestamp + if (ta !== tb) { + throw Error('Missing type coercion logic in _cmp'); + } + + if (ta === 7) { // ObjectID + // Convert to string. + ta = tb = 2; + a = a.toHexString(); + b = b.toHexString(); + } + + if (ta === 9) { // Date + // Convert to millis. + ta = tb = 1; + a = a.getTime(); + b = b.getTime(); + } + + if (ta === 1) { // double + if (a instanceof Decimal) { + return a.minus(b).toNumber(); + } else { + return a - b; + } + } + + if (tb === 2) // string + return a < b ? -1 : a === b ? 0 : 1; + + if (ta === 3) { // Object + // this could be much more efficient in the expected case ... + const toArray = object => { + const result = []; + + Object.keys(object).forEach(key => { + result.push(key, object[key]); + }); + + return result; + }; + + return bigBlobF._cmp(toArray(a), toArray(b)); + } + + if (ta === 4) { // Array + for (let i = 0; ; i++) { + if (i === a.length) { + return i === b.length ? 0 : -1; + } + + if (i === b.length) { + return 1; + } + + const s = bigBlobF._cmp(a[i], b[i]); + if (s !== 0) { + return s; + } + } + } + + if (ta === 5) { // binary + // Surprisingly, a small binary blob is always less than a large one in + // Mongo. + if (a.length !== b.length) { + return a.length - b.length; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] < b[i]) { + return -1; + } + + if (a[i] > b[i]) { + return 1; + } + } + + return 0; + } + + if (ta === 8) { // boolean + if (a) { + return b ? 0 : 1; + } + + return b ? -1 : 0; + } + + if (ta === 10) // null + return 0; + + if (ta === 11) // regexp + throw Error('Sorting not supported on regular expression'); // XXX + + // 13: javascript code + // 14: symbol + // 15: javascript code with scope + // 16: 32-bit integer + // 17: timestamp + // 18: 64-bit integer + // 255: minkey + // 127: maxkey + if (ta === 13) // javascript code + throw Error('Sorting not supported on Javascript code'); // XXX + + throw Error('Unknown type to sort'); + }, +}; \ No newline at end of file diff --git a/packages/webui/src/meteor/minimongo/index.js b/packages/webui/src/meteor/minimongo/index.js new file mode 100644 index 0000000000..7dd62a20ad --- /dev/null +++ b/packages/webui/src/meteor/minimongo/index.js @@ -0,0 +1,15 @@ +import {LocalCollection } from './local_collection.js'; + +const Minimongo = { + LocalCollection: LocalCollection, + Matcher: LocalCollection.Matcher, + Sorter: LocalCollection.Sorter +}; + +window.LocalCollection = LocalCollection; +window.Minimongo = Minimongo; + +export { + LocalCollection, + Minimongo, +} \ No newline at end of file diff --git a/packages/webui/src/meteor/minimongo/local_collection.js b/packages/webui/src/meteor/minimongo/local_collection.js new file mode 100644 index 0000000000..900db7e1c4 --- /dev/null +++ b/packages/webui/src/meteor/minimongo/local_collection.js @@ -0,0 +1,2974 @@ +import ObserveHandle from './observe_handle.js'; +import { + hasOwn, + isIndexable, + isNumericKey, + isOperatorObject, + populateDocumentWithQueryFields, + projectionDetails, + MinimongoError, + compileDocumentSelector, + nothingMatcher, + expandArraysInBranches, + makeLookupFunction, + selectorIsId, + bigBlobF, + _isPlainObject, +} from './common.js'; +import { Meteor } from '../meteor' +import EJSON from 'ejson' +import { MongoID } from '../mongo-id' +import { Random } from '../random' +import { DiffSequence } from '../diff-sequence' +import { Tracker } from '../tracker' +import { IdMap } from '../id-map' +import { OrderedDict } from '../ordered-dict' + +// XXX type checking on selectors (graceful error if malformed) + +// LocalCollection: a set of documents that supports queries and modifiers. +export class LocalCollection { + constructor(name) { + this.name = name; + // _id -> document (also containing id) + this._docs = new LocalCollection._IdMap(); + + this._observeQueue = new Meteor._SynchronousQueue(); + + this.next_qid = 1; // live query id generator + + // qid -> live query object. keys: + // ordered: bool. ordered queries have addedBefore/movedBefore callbacks. + // results: array (ordered) or object (unordered) of current results + // (aliased with this._docs!) + // resultsSnapshot: snapshot of results. null if not paused. + // cursor: Cursor object for the query. + // selector, sorter, (callbacks): functions + this.queries = Object.create(null); + + // null if not saving originals; an IdMap from id to original document value + // if saving originals. See comments before saveOriginals(). + this._savedOriginals = null; + + // True when observers are paused and we should not send callbacks. + this.paused = false; + } + + // options may include sort, skip, limit, reactive + // sort may be any of these forms: + // {a: 1, b: -1} + // [["a", "asc"], ["b", "desc"]] + // ["a", ["b", "desc"]] + // (in the first form you're beholden to key enumeration order in + // your javascript VM) + // + // reactive: if given, and false, don't register with Tracker (default + // is true) + // + // XXX possibly should support retrieving a subset of fields? and + // have it be a hint (ignored on the client, when not copying the + // doc?) + // + // XXX sort does not yet support subkeys ('a.b') .. fix that! + // XXX add one more sort form: "key" + // XXX tests + find(selector, options) { + // default syntax for everything is to omit the selector argument. + // but if selector is explicitly passed in as false or undefined, we + // want a selector that matches nothing. + if (arguments.length === 0) { + selector = {}; + } + + return new LocalCollection.Cursor(this, selector, options); + } + + findOne(selector, options = {}) { + if (arguments.length === 0) { + selector = {}; + } + + // NOTE: by setting limit 1 here, we end up using very inefficient + // code that recomputes the whole query on each update. The upside is + // that when you reactively depend on a findOne you only get + // invalidated when the found object changes, not any object in the + // collection. Most findOne will be by id, which has a fast path, so + // this might not be a big deal. In most cases, invalidation causes + // the called to re-query anyway, so this should be a net performance + // improvement. + options.limit = 1; + + return this.find(selector, options).fetch()[0]; + } + + // XXX possibly enforce that 'undefined' does not appear (we assume + // this in our handling of null and $exists) + insert(doc, callback) { + doc = EJSON.clone(doc); + + assertHasValidFieldNames(doc); + + // if you really want to use ObjectIDs, set this global. + // Mongo.Collection specifies its own ids and does not use this code. + if (!hasOwn.call(doc, '_id')) { + doc._id = LocalCollection._useOID ? new MongoID.ObjectID() : Random.id(); + } + + const id = doc._id; + + if (this._docs.has(id)) { + throw MinimongoError(`Duplicate _id '${id}'`); + } + + this._saveOriginal(id, undefined); + this._docs.set(id, doc); + + const queriesToRecompute = []; + + // trigger live queries that match + Object.keys(this.queries).forEach(qid => { + const query = this.queries[qid]; + + if (query.dirty) { + return; + } + + const matchResult = query.matcher.documentMatches(doc); + + if (matchResult.result) { + if (query.distances && matchResult.distance !== undefined) { + query.distances.set(id, matchResult.distance); + } + + if (query.cursor.skip || query.cursor.limit) { + queriesToRecompute.push(qid); + } else { + LocalCollection._insertInResults(query, doc); + } + } + }); + + queriesToRecompute.forEach(qid => { + if (this.queries[qid]) { + this._recomputeResults(this.queries[qid]); + } + }); + + this._observeQueue.drain(); + + // Defer because the caller likely doesn't expect the callback to be run + // immediately. + if (callback) { + Meteor.defer(() => { + callback(null, id); + }); + } + + return id; + } + + // Pause the observers. No callbacks from observers will fire until + // 'resumeObservers' is called. + pauseObservers() { + // No-op if already paused. + if (this.paused) { + return; + } + + // Set the 'paused' flag such that new observer messages don't fire. + this.paused = true; + + // Take a snapshot of the query results for each query. + Object.keys(this.queries).forEach(qid => { + const query = this.queries[qid]; + query.resultsSnapshot = EJSON.clone(query.results); + }); + } + + remove(selector, callback) { + // Easy special case: if we're not calling observeChanges callbacks and + // we're not saving originals and we got asked to remove everything, then + // just empty everything directly. + if (this.paused && !this._savedOriginals && EJSON.equals(selector, {})) { + const result = this._docs.size(); + + this._docs.clear(); + + Object.keys(this.queries).forEach(qid => { + const query = this.queries[qid]; + + if (query.ordered) { + query.results = []; + } else { + query.results.clear(); + } + }); + + if (callback) { + Meteor.defer(() => { + callback(null, result); + }); + } + + return result; + } + + const matcher = new LocalCollection.Matcher(selector); + const remove = []; + + this._eachPossiblyMatchingDoc(selector, (doc, id) => { + if (matcher.documentMatches(doc).result) { + remove.push(id); + } + }); + + const queriesToRecompute = []; + const queryRemove = []; + + for (let i = 0; i < remove.length; i++) { + const removeId = remove[i]; + const removeDoc = this._docs.get(removeId); + + Object.keys(this.queries).forEach(qid => { + const query = this.queries[qid]; + + if (query.dirty) { + return; + } + + if (query.matcher.documentMatches(removeDoc).result) { + if (query.cursor.skip || query.cursor.limit) { + queriesToRecompute.push(qid); + } else { + queryRemove.push({qid, doc: removeDoc}); + } + } + }); + + this._saveOriginal(removeId, removeDoc); + this._docs.remove(removeId); + } + + // run live query callbacks _after_ we've removed the documents. + queryRemove.forEach(remove => { + const query = this.queries[remove.qid]; + + if (query) { + query.distances && query.distances.remove(remove.doc._id); + LocalCollection._removeFromResults(query, remove.doc); + } + }); + + queriesToRecompute.forEach(qid => { + const query = this.queries[qid]; + + if (query) { + this._recomputeResults(query); + } + }); + + this._observeQueue.drain(); + + const result = remove.length; + + if (callback) { + Meteor.defer(() => { + callback(null, result); + }); + } + + return result; + } + + // Resume the observers. Observers immediately receive change + // notifications to bring them to the current state of the + // database. Note that this is not just replaying all the changes that + // happened during the pause, it is a smarter 'coalesced' diff. + resumeObservers() { + // No-op if not paused. + if (!this.paused) { + return; + } + + // Unset the 'paused' flag. Make sure to do this first, otherwise + // observer methods won't actually fire when we trigger them. + this.paused = false; + + Object.keys(this.queries).forEach(qid => { + const query = this.queries[qid]; + + if (query.dirty) { + query.dirty = false; + + // re-compute results will perform `LocalCollection._diffQueryChanges` + // automatically. + this._recomputeResults(query, query.resultsSnapshot); + } else { + // Diff the current results against the snapshot and send to observers. + // pass the query object for its observer callbacks. + LocalCollection._diffQueryChanges( + query.ordered, + query.resultsSnapshot, + query.results, + query, + {projectionFn: query.projectionFn} + ); + } + + query.resultsSnapshot = null; + }); + + this._observeQueue.drain(); + } + + retrieveOriginals() { + if (!this._savedOriginals) { + throw new Error('Called retrieveOriginals without saveOriginals'); + } + + const originals = this._savedOriginals; + + this._savedOriginals = null; + + return originals; + } + + // To track what documents are affected by a piece of code, call + // saveOriginals() before it and retrieveOriginals() after it. + // retrieveOriginals returns an object whose keys are the ids of the documents + // that were affected since the call to saveOriginals(), and the values are + // equal to the document's contents at the time of saveOriginals. (In the case + // of an inserted document, undefined is the value.) You must alternate + // between calls to saveOriginals() and retrieveOriginals(). + saveOriginals() { + if (this._savedOriginals) { + throw new Error('Called saveOriginals twice without retrieveOriginals'); + } + + this._savedOriginals = new LocalCollection._IdMap; + } + + // XXX atomicity: if multi is true, and one modification fails, do + // we rollback the whole operation, or what? + update(selector, mod, options, callback) { + if (! callback && options instanceof Function) { + callback = options; + options = null; + } + + if (!options) { + options = {}; + } + + const matcher = new LocalCollection.Matcher(selector, true); + + // Save the original results of any query that we might need to + // _recomputeResults on, because _modifyAndNotify will mutate the objects in + // it. (We don't need to save the original results of paused queries because + // they already have a resultsSnapshot and we won't be diffing in + // _recomputeResults.) + const qidToOriginalResults = {}; + + // We should only clone each document once, even if it appears in multiple + // queries + const docMap = new LocalCollection._IdMap; + const idsMatched = LocalCollection._idsMatchedBySelector(selector); + + Object.keys(this.queries).forEach(qid => { + const query = this.queries[qid]; + + if ((query.cursor.skip || query.cursor.limit) && ! this.paused) { + // Catch the case of a reactive `count()` on a cursor with skip + // or limit, which registers an unordered observe. This is a + // pretty rare case, so we just clone the entire result set with + // no optimizations for documents that appear in these result + // sets and other queries. + if (query.results instanceof LocalCollection._IdMap) { + qidToOriginalResults[qid] = query.results.clone(); + return; + } + + if (!(query.results instanceof Array)) { + throw new Error('Assertion failed: query.results not an array'); + } + + // Clones a document to be stored in `qidToOriginalResults` + // because it may be modified before the new and old result sets + // are diffed. But if we know exactly which document IDs we're + // going to modify, then we only need to clone those. + const memoizedCloneIfNeeded = doc => { + if (docMap.has(doc._id)) { + return docMap.get(doc._id); + } + + const docToMemoize = ( + idsMatched && + !idsMatched.some(id => EJSON.equals(id, doc._id)) + ) ? doc : EJSON.clone(doc); + + docMap.set(doc._id, docToMemoize); + + return docToMemoize; + }; + + qidToOriginalResults[qid] = query.results.map(memoizedCloneIfNeeded); + } + }); + + const recomputeQids = {}; + + let updateCount = 0; + + this._eachPossiblyMatchingDoc(selector, (doc, id) => { + const queryResult = matcher.documentMatches(doc); + + if (queryResult.result) { + // XXX Should we save the original even if mod ends up being a no-op? + this._saveOriginal(id, doc); + this._modifyAndNotify( + doc, + mod, + recomputeQids, + queryResult.arrayIndices + ); + + ++updateCount; + + if (!options.multi) { + return false; // break + } + } + + return true; + }); + + Object.keys(recomputeQids).forEach(qid => { + const query = this.queries[qid]; + + if (query) { + this._recomputeResults(query, qidToOriginalResults[qid]); + } + }); + + this._observeQueue.drain(); + + // If we are doing an upsert, and we didn't modify any documents yet, then + // it's time to do an insert. Figure out what document we are inserting, and + // generate an id for it. + let insertedId; + if (updateCount === 0 && options.upsert) { + const doc = LocalCollection._createUpsertDocument(selector, mod); + if (! doc._id && options.insertedId) { + doc._id = options.insertedId; + } + + insertedId = this.insert(doc); + updateCount = 1; + } + + // Return the number of affected documents, or in the upsert case, an object + // containing the number of affected docs and the id of the doc that was + // inserted, if any. + let result; + if (options._returnObject) { + result = {numberAffected: updateCount}; + + if (insertedId !== undefined) { + result.insertedId = insertedId; + } + } else { + result = updateCount; + } + + if (callback) { + Meteor.defer(() => { + callback(null, result); + }); + } + + return result; + } + + // A convenience wrapper on update. LocalCollection.upsert(sel, mod) is + // equivalent to LocalCollection.update(sel, mod, {upsert: true, + // _returnObject: true}). + upsert(selector, mod, options, callback) { + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + return this.update( + selector, + mod, + Object.assign({}, options, {upsert: true, _returnObject: true}), + callback + ); + } + + // Iterates over a subset of documents that could match selector; calls + // fn(doc, id) on each of them. Specifically, if selector specifies + // specific _id's, it only looks at those. doc is *not* cloned: it is the + // same object that is in _docs. + _eachPossiblyMatchingDoc(selector, fn) { + const specificIds = LocalCollection._idsMatchedBySelector(selector); + + if (specificIds) { + specificIds.some(id => { + const doc = this._docs.get(id); + + if (doc) { + return fn(doc, id) === false; + } + }); + } else { + this._docs.forEach(fn); + } + } + + _modifyAndNotify(doc, mod, recomputeQids, arrayIndices) { + const matched_before = {}; + + Object.keys(this.queries).forEach(qid => { + const query = this.queries[qid]; + + if (query.dirty) { + return; + } + + if (query.ordered) { + matched_before[qid] = query.matcher.documentMatches(doc).result; + } else { + // Because we don't support skip or limit (yet) in unordered queries, we + // can just do a direct lookup. + matched_before[qid] = query.results.has(doc._id); + } + }); + + const old_doc = EJSON.clone(doc); + + LocalCollection._modify(doc, mod, {arrayIndices}); + + Object.keys(this.queries).forEach(qid => { + const query = this.queries[qid]; + + if (query.dirty) { + return; + } + + const afterMatch = query.matcher.documentMatches(doc); + const after = afterMatch.result; + const before = matched_before[qid]; + + if (after && query.distances && afterMatch.distance !== undefined) { + query.distances.set(doc._id, afterMatch.distance); + } + + if (query.cursor.skip || query.cursor.limit) { + // We need to recompute any query where the doc may have been in the + // cursor's window either before or after the update. (Note that if skip + // or limit is set, "before" and "after" being true do not necessarily + // mean that the document is in the cursor's output after skip/limit is + // applied... but if they are false, then the document definitely is NOT + // in the output. So it's safe to skip recompute if neither before or + // after are true.) + if (before || after) { + recomputeQids[qid] = true; + } + } else if (before && !after) { + LocalCollection._removeFromResults(query, doc); + } else if (!before && after) { + LocalCollection._insertInResults(query, doc); + } else if (before && after) { + LocalCollection._updateInResults(query, doc, old_doc); + } + }); + } + + // Recomputes the results of a query and runs observe callbacks for the + // difference between the previous results and the current results (unless + // paused). Used for skip/limit queries. + // + // When this is used by insert or remove, it can just use query.results for + // the old results (and there's no need to pass in oldResults), because these + // operations don't mutate the documents in the collection. Update needs to + // pass in an oldResults which was deep-copied before the modifier was + // applied. + // + // oldResults is guaranteed to be ignored if the query is not paused. + _recomputeResults(query, oldResults) { + if (this.paused) { + // There's no reason to recompute the results now as we're still paused. + // By flagging the query as "dirty", the recompute will be performed + // when resumeObservers is called. + query.dirty = true; + return; + } + + if (!this.paused && !oldResults) { + oldResults = query.results; + } + + if (query.distances) { + query.distances.clear(); + } + + query.results = query.cursor._getRawObjects({ + distances: query.distances, + ordered: query.ordered + }); + + if (!this.paused) { + LocalCollection._diffQueryChanges( + query.ordered, + oldResults, + query.results, + query, + {projectionFn: query.projectionFn} + ); + } + } + + _saveOriginal(id, doc) { + // Are we even trying to save originals? + if (!this._savedOriginals) { + return; + } + + // Have we previously mutated the original (and so 'doc' is not actually + // original)? (Note the 'has' check rather than truth: we store undefined + // here for inserted docs!) + if (this._savedOriginals.has(id)) { + return; + } + + this._savedOriginals.set(id, EJSON.clone(doc)); + } +} + + +LocalCollection.ObserveHandle = ObserveHandle; + +// XXX maybe move these into another ObserveHelpers package or something + +// _CachingChangeObserver is an object which receives observeChanges callbacks +// and keeps a cache of the current cursor state up to date in this.docs. Users +// of this class should read the docs field but not modify it. You should pass +// the "applyChange" field as the callbacks to the underlying observeChanges +// call. Optionally, you can specify your own observeChanges callbacks which are +// invoked immediately before the docs field is updated; this object is made +// available as `this` to those callbacks. +LocalCollection._CachingChangeObserver = class _CachingChangeObserver { + constructor(options = {}) { + const orderedFromCallbacks = ( + options.callbacks && + LocalCollection._observeChangesCallbacksAreOrdered(options.callbacks) + ); + + if (hasOwn.call(options, 'ordered')) { + this.ordered = options.ordered; + + if (options.callbacks && options.ordered !== orderedFromCallbacks) { + throw Error('ordered option doesn\'t match callbacks'); + } + } else if (options.callbacks) { + this.ordered = orderedFromCallbacks; + } else { + throw Error('must provide ordered or callbacks'); + } + + const callbacks = options.callbacks || {}; + + if (this.ordered) { + this.docs = new OrderedDict(MongoID.idStringify); + this.applyChange = { + addedBefore: (id, fields, before) => { + // Take a shallow copy since the top-level properties can be changed + const doc = { ...fields }; + + doc._id = id; + + if (callbacks.addedBefore) { + callbacks.addedBefore.call(this, id, EJSON.clone(fields), before); + } + + // This line triggers if we provide added with movedBefore. + if (callbacks.added) { + callbacks.added.call(this, id, EJSON.clone(fields)); + } + + // XXX could `before` be a falsy ID? Technically + // idStringify seems to allow for them -- though + // OrderedDict won't call stringify on a falsy arg. + this.docs.putBefore(id, doc, before || null); + }, + movedBefore: (id, before) => { + const doc = this.docs.get(id); + + if (callbacks.movedBefore) { + callbacks.movedBefore.call(this, id, before); + } + + this.docs.moveBefore(id, before || null); + }, + }; + } else { + this.docs = new LocalCollection._IdMap; + this.applyChange = { + added: (id, fields) => { + // Take a shallow copy since the top-level properties can be changed + const doc = { ...fields }; + + if (callbacks.added) { + callbacks.added.call(this, id, EJSON.clone(fields)); + } + + doc._id = id; + + this.docs.set(id, doc); + }, + }; + } + + // The methods in _IdMap and OrderedDict used by these callbacks are + // identical. + this.applyChange.changed = (id, fields) => { + const doc = this.docs.get(id); + + if (!doc) { + throw new Error(`Unknown id for changed: ${id}`); + } + + if (callbacks.changed) { + callbacks.changed.call(this, id, EJSON.clone(fields)); + } + + DiffSequence.applyChanges(doc, fields); + }; + + this.applyChange.removed = id => { + if (callbacks.removed) { + callbacks.removed.call(this, id); + } + + this.docs.remove(id); + }; + } +}; + +LocalCollection._IdMap = class _IdMap extends IdMap { + constructor() { + super(MongoID.idStringify, MongoID.idParse); + } +}; + +// Wrap a transform function to return objects that have the _id field +// of the untransformed document. This ensures that subsystems such as +// the observe-sequence package that call `observe` can keep track of +// the documents identities. +// +// - Require that it returns objects +// - If the return value has an _id field, verify that it matches the +// original _id field +// - If the return value doesn't have an _id field, add it back. +LocalCollection.wrapTransform = transform => { + if (!transform) { + return null; + } + + // No need to doubly-wrap transforms. + if (transform.__wrappedTransform__) { + return transform; + } + + const wrapped = doc => { + if (!hasOwn.call(doc, '_id')) { + // XXX do we ever have a transform on the oplog's collection? because that + // collection has no _id. + throw new Error('can only transform documents with _id'); + } + + const id = doc._id; + + // XXX consider making tracker a weak dependency and checking + // Package.tracker here + const transformed = Tracker.nonreactive(() => transform(doc)); + + if (!LocalCollection._isPlainObject(transformed)) { + throw new Error('transform must return object'); + } + + if (hasOwn.call(transformed, '_id')) { + if (!EJSON.equals(transformed._id, id)) { + throw new Error('transformed document can\'t have different _id'); + } + } else { + transformed._id = id; + } + + return transformed; + }; + + wrapped.__wrappedTransform__ = true; + + return wrapped; +}; + +// XXX the sorted-query logic below is laughably inefficient. we'll +// need to come up with a better datastructure for this. +// +// XXX the logic for observing with a skip or a limit is even more +// laughably inefficient. we recompute the whole results every time! + +// This binary search puts a value between any equal values, and the first +// lesser value. +LocalCollection._binarySearch = (cmp, array, value) => { + let first = 0; + let range = array.length; + + while (range > 0) { + const halfRange = Math.floor(range / 2); + + if (cmp(value, array[first + halfRange]) >= 0) { + first += halfRange + 1; + range -= halfRange + 1; + } else { + range = halfRange; + } + } + + return first; +}; + +LocalCollection._checkSupportedProjection = fields => { + if (fields !== Object(fields) || Array.isArray(fields)) { + throw MinimongoError('fields option must be an object'); + } + + Object.keys(fields).forEach(keyPath => { + if (keyPath.split('.').includes('$')) { + throw MinimongoError( + 'Minimongo doesn\'t support $ operator in projections yet.' + ); + } + + const value = fields[keyPath]; + + if (typeof value === 'object' && + ['$elemMatch', '$meta', '$slice'].some(key => + hasOwn.call(value, key) + )) { + throw MinimongoError( + 'Minimongo doesn\'t support operators in projections yet.' + ); + } + + if (![1, 0, true, false].includes(value)) { + throw MinimongoError( + 'Projection values should be one of 1, 0, true, or false' + ); + } + }); +}; + +// Knows how to compile a fields projection to a predicate function. +// @returns - Function: a closure that filters out an object according to the +// fields projection rules: +// @param obj - Object: MongoDB-styled document +// @returns - Object: a document with the fields filtered out +// according to projection rules. Doesn't retain subfields +// of passed argument. +LocalCollection._compileProjection = fields => { + LocalCollection._checkSupportedProjection(fields); + + const _idProjection = fields._id === undefined ? true : fields._id; + const details = projectionDetails(fields); + + // returns transformed doc according to ruleTree + const transform = (doc, ruleTree) => { + // Special case for "sets" + if (Array.isArray(doc)) { + return doc.map(subdoc => transform(subdoc, ruleTree)); + } + + const result = details.including ? {} : EJSON.clone(doc); + + Object.keys(ruleTree).forEach(key => { + if (doc == null || !hasOwn.call(doc, key)) { + return; + } + + const rule = ruleTree[key]; + + if (rule === Object(rule)) { + // For sub-objects/subsets we branch + if (doc[key] === Object(doc[key])) { + result[key] = transform(doc[key], rule); + } + } else if (details.including) { + // Otherwise we don't even touch this subfield + result[key] = EJSON.clone(doc[key]); + } else { + delete result[key]; + } + }); + + return doc != null ? result : doc; + }; + + return doc => { + const result = transform(doc, details.tree); + + if (_idProjection && hasOwn.call(doc, '_id')) { + result._id = doc._id; + } + + if (!_idProjection && hasOwn.call(result, '_id')) { + delete result._id; + } + + return result; + }; +}; + +// Calculates the document to insert in case we're doing an upsert and the +// selector does not match any elements +LocalCollection._createUpsertDocument = (selector, modifier) => { + const selectorDocument = populateDocumentWithQueryFields(selector); + const isModify = LocalCollection._isModificationMod(modifier); + + const newDoc = {}; + + if (selectorDocument._id) { + newDoc._id = selectorDocument._id; + delete selectorDocument._id; + } + + // This double _modify call is made to help with nested properties (see issue + // #8631). We do this even if it's a replacement for validation purposes (e.g. + // ambiguous id's) + LocalCollection._modify(newDoc, {$set: selectorDocument}); + LocalCollection._modify(newDoc, modifier, {isInsert: true}); + + if (isModify) { + return newDoc; + } + + // Replacement can take _id from query document + const replacement = Object.assign({}, modifier); + if (newDoc._id) { + replacement._id = newDoc._id; + } + + return replacement; +}; + +LocalCollection._diffObjects = (left, right, callbacks) => { + return DiffSequence.diffObjects(left, right, callbacks); +}; + +// ordered: bool. +// old_results and new_results: collections of documents. +// if ordered, they are arrays. +// if unordered, they are IdMaps +LocalCollection._diffQueryChanges = (ordered, oldResults, newResults, observer, options) => + DiffSequence.diffQueryChanges(ordered, oldResults, newResults, observer, options) +; + +LocalCollection._diffQueryOrderedChanges = (oldResults, newResults, observer, options) => + DiffSequence.diffQueryOrderedChanges(oldResults, newResults, observer, options) +; + +LocalCollection._diffQueryUnorderedChanges = (oldResults, newResults, observer, options) => + DiffSequence.diffQueryUnorderedChanges(oldResults, newResults, observer, options) +; + +LocalCollection._findInOrderedResults = (query, doc) => { + if (!query.ordered) { + throw new Error('Can\'t call _findInOrderedResults on unordered query'); + } + + for (let i = 0; i < query.results.length; i++) { + if (query.results[i] === doc) { + return i; + } + } + + throw Error('object missing from query'); +}; + +// If this is a selector which explicitly constrains the match by ID to a finite +// number of documents, returns a list of their IDs. Otherwise returns +// null. Note that the selector may have other restrictions so it may not even +// match those document! We care about $in and $and since those are generated +// access-controlled update and remove. +LocalCollection._idsMatchedBySelector = selector => { + // Is the selector just an ID? + if (LocalCollection._selectorIsId(selector)) { + return [selector]; + } + + if (!selector) { + return null; + } + + // Do we have an _id clause? + if (hasOwn.call(selector, '_id')) { + // Is the _id clause just an ID? + if (LocalCollection._selectorIsId(selector._id)) { + return [selector._id]; + } + + // Is the _id clause {_id: {$in: ["x", "y", "z"]}}? + if (selector._id + && Array.isArray(selector._id.$in) + && selector._id.$in.length + && selector._id.$in.every(LocalCollection._selectorIsId)) { + return selector._id.$in; + } + + return null; + } + + // If this is a top-level $and, and any of the clauses constrain their + // documents, then the whole selector is constrained by any one clause's + // constraint. (Well, by their intersection, but that seems unlikely.) + if (Array.isArray(selector.$and)) { + for (let i = 0; i < selector.$and.length; ++i) { + const subIds = LocalCollection._idsMatchedBySelector(selector.$and[i]); + + if (subIds) { + return subIds; + } + } + } + + return null; +}; + +LocalCollection._insertInResults = (query, doc) => { + const fields = EJSON.clone(doc); + + delete fields._id; + + if (query.ordered) { + if (!query.sorter) { + query.addedBefore(doc._id, query.projectionFn(fields), null); + query.results.push(doc); + } else { + const i = LocalCollection._insertInSortedList( + query.sorter.getComparator({distances: query.distances}), + query.results, + doc + ); + + let next = query.results[i + 1]; + if (next) { + next = next._id; + } else { + next = null; + } + + query.addedBefore(doc._id, query.projectionFn(fields), next); + } + + query.added(doc._id, query.projectionFn(fields)); + } else { + query.added(doc._id, query.projectionFn(fields)); + query.results.set(doc._id, doc); + } +}; + +LocalCollection._insertInSortedList = (cmp, array, value) => { + if (array.length === 0) { + array.push(value); + return 0; + } + + const i = LocalCollection._binarySearch(cmp, array, value); + + array.splice(i, 0, value); + + return i; +}; + +LocalCollection._isModificationMod = mod => { + let isModify = false; + let isReplace = false; + + Object.keys(mod).forEach(key => { + if (key.substr(0, 1) === '$') { + isModify = true; + } else { + isReplace = true; + } + }); + + if (isModify && isReplace) { + throw new Error( + 'Update parameter cannot have both modifier and non-modifier fields.' + ); + } + + return isModify; +}; + +// XXX maybe this should be EJSON.isObject, though EJSON doesn't know about +// RegExp +// XXX note that _type(undefined) === 3!!!! +LocalCollection._isPlainObject = _isPlainObject + +// XXX need a strategy for passing the binding of $ into this +// function, from the compiled selector +// +// maybe just {key.up.to.just.before.dollarsign: array_index} +// +// XXX atomicity: if one modification fails, do we roll back the whole +// change? +// +// options: +// - isInsert is set when _modify is being called to compute the document to +// insert as part of an upsert operation. We use this primarily to figure +// out when to set the fields in $setOnInsert, if present. +LocalCollection._modify = (doc, modifier, options = {}) => { + if (!LocalCollection._isPlainObject(modifier)) { + throw MinimongoError('Modifier must be an object'); + } + + // Make sure the caller can't mutate our data structures. + modifier = EJSON.clone(modifier); + + const isModifier = isOperatorObject(modifier); + const newDoc = isModifier ? EJSON.clone(doc) : modifier; + + if (isModifier) { + // apply modifiers to the doc. + Object.keys(modifier).forEach(operator => { + // Treat $setOnInsert as $set if this is an insert. + const setOnInsert = options.isInsert && operator === '$setOnInsert'; + const modFunc = MODIFIERS[setOnInsert ? '$set' : operator]; + const operand = modifier[operator]; + + if (!modFunc) { + throw MinimongoError(`Invalid modifier specified ${operator}`); + } + + Object.keys(operand).forEach(keypath => { + const arg = operand[keypath]; + + if (keypath === '') { + throw MinimongoError('An empty update path is not valid.'); + } + + const keyparts = keypath.split('.'); + + if (!keyparts.every(Boolean)) { + throw MinimongoError( + `The update path '${keypath}' contains an empty field name, ` + + 'which is not allowed.' + ); + } + + const target = findModTarget(newDoc, keyparts, { + arrayIndices: options.arrayIndices, + forbidArray: operator === '$rename', + noCreate: NO_CREATE_MODIFIERS[operator] + }); + + modFunc(target, keyparts.pop(), arg, keypath, newDoc); + }); + }); + + if (doc._id && !EJSON.equals(doc._id, newDoc._id)) { + throw MinimongoError( + `After applying the update to the document {_id: "${doc._id}", ...},` + + ' the (immutable) field \'_id\' was found to have been altered to ' + + `_id: "${newDoc._id}"` + ); + } + } else { + if (doc._id && modifier._id && !EJSON.equals(doc._id, modifier._id)) { + throw MinimongoError( + `The _id field cannot be changed from {_id: "${doc._id}"} to ` + + `{_id: "${modifier._id}"}` + ); + } + + // replace the whole document + assertHasValidFieldNames(modifier); + } + + // move new document into place. + Object.keys(doc).forEach(key => { + // Note: this used to be for (var key in doc) however, this does not + // work right in Opera. Deleting from a doc while iterating over it + // would sometimes cause opera to skip some keys. + if (key !== '_id') { + delete doc[key]; + } + }); + + Object.keys(newDoc).forEach(key => { + doc[key] = newDoc[key]; + }); +}; + +LocalCollection._observeFromObserveChanges = (cursor, observeCallbacks) => { + const transform = cursor.getTransform() || (doc => doc); + let suppressed = !!observeCallbacks._suppress_initial; + + let observeChangesCallbacks; + if (LocalCollection._observeCallbacksAreOrdered(observeCallbacks)) { + // The "_no_indices" option sets all index arguments to -1 and skips the + // linear scans required to generate them. This lets observers that don't + // need absolute indices benefit from the other features of this API -- + // relative order, transforms, and applyChanges -- without the speed hit. + const indices = !observeCallbacks._no_indices; + + observeChangesCallbacks = { + addedBefore(id, fields, before) { + if (suppressed || !(observeCallbacks.addedAt || observeCallbacks.added)) { + return; + } + + const doc = transform(Object.assign(fields, {_id: id})); + + if (observeCallbacks.addedAt) { + observeCallbacks.addedAt( + doc, + indices + ? before + ? this.docs.indexOf(before) + : this.docs.size() + : -1, + before + ); + } else { + observeCallbacks.added(doc); + } + }, + changed(id, fields) { + if (!(observeCallbacks.changedAt || observeCallbacks.changed)) { + return; + } + + let doc = EJSON.clone(this.docs.get(id)); + if (!doc) { + throw new Error(`Unknown id for changed: ${id}`); + } + + const oldDoc = transform(EJSON.clone(doc)); + + DiffSequence.applyChanges(doc, fields); + + if (observeCallbacks.changedAt) { + observeCallbacks.changedAt( + transform(doc), + oldDoc, + indices ? this.docs.indexOf(id) : -1 + ); + } else { + observeCallbacks.changed(transform(doc), oldDoc); + } + }, + movedBefore(id, before) { + if (!observeCallbacks.movedTo) { + return; + } + + const from = indices ? this.docs.indexOf(id) : -1; + let to = indices + ? before + ? this.docs.indexOf(before) + : this.docs.size() + : -1; + + // When not moving backwards, adjust for the fact that removing the + // document slides everything back one slot. + if (to > from) { + --to; + } + + observeCallbacks.movedTo( + transform(EJSON.clone(this.docs.get(id))), + from, + to, + before || null + ); + }, + removed(id) { + if (!(observeCallbacks.removedAt || observeCallbacks.removed)) { + return; + } + + // technically maybe there should be an EJSON.clone here, but it's about + // to be removed from this.docs! + const doc = transform(this.docs.get(id)); + + if (observeCallbacks.removedAt) { + observeCallbacks.removedAt(doc, indices ? this.docs.indexOf(id) : -1); + } else { + observeCallbacks.removed(doc); + } + }, + }; + } else { + observeChangesCallbacks = { + added(id, fields) { + if (!suppressed && observeCallbacks.added) { + observeCallbacks.added(transform(Object.assign(fields, {_id: id}))); + } + }, + changed(id, fields) { + if (observeCallbacks.changed) { + const oldDoc = this.docs.get(id); + const doc = EJSON.clone(oldDoc); + + DiffSequence.applyChanges(doc, fields); + + observeCallbacks.changed( + transform(doc), + transform(EJSON.clone(oldDoc)) + ); + } + }, + removed(id) { + if (observeCallbacks.removed) { + observeCallbacks.removed(transform(this.docs.get(id))); + } + }, + }; + } + + const changeObserver = new LocalCollection._CachingChangeObserver({ + callbacks: observeChangesCallbacks + }); + + // CachingChangeObserver clones all received input on its callbacks + // So we can mark it as safe to reduce the ejson clones. + // This is tested by the `mongo-livedata - (extended) scribbling` tests + changeObserver.applyChange._fromObserve = true; + const handle = cursor.observeChanges(changeObserver.applyChange, + { nonMutatingCallbacks: true }); + + suppressed = false; + + return handle; +}; + +LocalCollection._observeCallbacksAreOrdered = callbacks => { + if (callbacks.added && callbacks.addedAt) { + throw new Error('Please specify only one of added() and addedAt()'); + } + + if (callbacks.changed && callbacks.changedAt) { + throw new Error('Please specify only one of changed() and changedAt()'); + } + + if (callbacks.removed && callbacks.removedAt) { + throw new Error('Please specify only one of removed() and removedAt()'); + } + + return !!( + callbacks.addedAt || + callbacks.changedAt || + callbacks.movedTo || + callbacks.removedAt + ); +}; + +LocalCollection._observeChangesCallbacksAreOrdered = callbacks => { + if (callbacks.added && callbacks.addedBefore) { + throw new Error('Please specify only one of added() and addedBefore()'); + } + + return !!(callbacks.addedBefore || callbacks.movedBefore); +}; + +LocalCollection._removeFromResults = (query, doc) => { + if (query.ordered) { + const i = LocalCollection._findInOrderedResults(query, doc); + + query.removed(doc._id); + query.results.splice(i, 1); + } else { + const id = doc._id; // in case callback mutates doc + + query.removed(doc._id); + query.results.remove(id); + } +}; + +// Is this selector just shorthand for lookup by _id? +LocalCollection._selectorIsId = selectorIsId; + +// Is the selector just lookup by _id (shorthand or not)? +LocalCollection._selectorIsIdPerhapsAsObject = selector => + LocalCollection._selectorIsId(selector) || + LocalCollection._selectorIsId(selector && selector._id) && + Object.keys(selector).length === 1 +; + +LocalCollection._updateInResults = (query, doc, old_doc) => { + if (!EJSON.equals(doc._id, old_doc._id)) { + throw new Error('Can\'t change a doc\'s _id while updating'); + } + + const projectionFn = query.projectionFn; + const changedFields = DiffSequence.makeChangedFields( + projectionFn(doc), + projectionFn(old_doc) + ); + + if (!query.ordered) { + if (Object.keys(changedFields).length) { + query.changed(doc._id, changedFields); + query.results.set(doc._id, doc); + } + + return; + } + + const old_idx = LocalCollection._findInOrderedResults(query, doc); + + if (Object.keys(changedFields).length) { + query.changed(doc._id, changedFields); + } + + if (!query.sorter) { + return; + } + + // just take it out and put it back in again, and see if the index changes + query.results.splice(old_idx, 1); + + const new_idx = LocalCollection._insertInSortedList( + query.sorter.getComparator({distances: query.distances}), + query.results, + doc + ); + + if (old_idx !== new_idx) { + let next = query.results[new_idx + 1]; + if (next) { + next = next._id; + } else { + next = null; + } + + query.movedBefore && query.movedBefore(doc._id, next); + } +}; + +const MODIFIERS = { + $currentDate(target, field, arg) { + if (typeof arg === 'object' && hasOwn.call(arg, '$type')) { + if (arg.$type !== 'date') { + throw MinimongoError( + 'Minimongo does currently only support the date type in ' + + '$currentDate modifiers', + {field} + ); + } + } else if (arg !== true) { + throw MinimongoError('Invalid $currentDate modifier', {field}); + } + + target[field] = new Date(); + }, + $inc(target, field, arg) { + if (typeof arg !== 'number') { + throw MinimongoError('Modifier $inc allowed for numbers only', {field}); + } + + if (field in target) { + if (typeof target[field] !== 'number') { + throw MinimongoError( + 'Cannot apply $inc modifier to non-number', + {field} + ); + } + + target[field] += arg; + } else { + target[field] = arg; + } + }, + $min(target, field, arg) { + if (typeof arg !== 'number') { + throw MinimongoError('Modifier $min allowed for numbers only', {field}); + } + + if (field in target) { + if (typeof target[field] !== 'number') { + throw MinimongoError( + 'Cannot apply $min modifier to non-number', + {field} + ); + } + + if (target[field] > arg) { + target[field] = arg; + } + } else { + target[field] = arg; + } + }, + $max(target, field, arg) { + if (typeof arg !== 'number') { + throw MinimongoError('Modifier $max allowed for numbers only', {field}); + } + + if (field in target) { + if (typeof target[field] !== 'number') { + throw MinimongoError( + 'Cannot apply $max modifier to non-number', + {field} + ); + } + + if (target[field] < arg) { + target[field] = arg; + } + } else { + target[field] = arg; + } + }, + $mul(target, field, arg) { + if (typeof arg !== 'number') { + throw MinimongoError('Modifier $mul allowed for numbers only', {field}); + } + + if (field in target) { + if (typeof target[field] !== 'number') { + throw MinimongoError( + 'Cannot apply $mul modifier to non-number', + {field} + ); + } + + target[field] *= arg; + } else { + target[field] = 0; + } + }, + $rename(target, field, arg, keypath, doc) { + // no idea why mongo has this restriction.. + if (keypath === arg) { + throw MinimongoError('$rename source must differ from target', {field}); + } + + if (target === null) { + throw MinimongoError('$rename source field invalid', {field}); + } + + if (typeof arg !== 'string') { + throw MinimongoError('$rename target must be a string', {field}); + } + + if (arg.includes('\0')) { + // Null bytes are not allowed in Mongo field names + // https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names + throw MinimongoError( + 'The \'to\' field for $rename cannot contain an embedded null byte', + {field} + ); + } + + if (target === undefined) { + return; + } + + const object = target[field]; + + delete target[field]; + + const keyparts = arg.split('.'); + const target2 = findModTarget(doc, keyparts, {forbidArray: true}); + + if (target2 === null) { + throw MinimongoError('$rename target field invalid', {field}); + } + + target2[keyparts.pop()] = object; + }, + $set(target, field, arg) { + if (target !== Object(target)) { // not an array or an object + const error = MinimongoError( + 'Cannot set property on non-object field', + {field} + ); + error.setPropertyError = true; + throw error; + } + + if (target === null) { + const error = MinimongoError('Cannot set property on null', {field}); + error.setPropertyError = true; + throw error; + } + + assertHasValidFieldNames(arg); + + target[field] = arg; + }, + $setOnInsert(target, field, arg) { + // converted to `$set` in `_modify` + }, + $unset(target, field, arg) { + if (target !== undefined) { + if (target instanceof Array) { + if (field in target) { + target[field] = null; + } + } else { + delete target[field]; + } + } + }, + $push(target, field, arg) { + if (target[field] === undefined) { + target[field] = []; + } + + if (!(target[field] instanceof Array)) { + throw MinimongoError('Cannot apply $push modifier to non-array', {field}); + } + + if (!(arg && arg.$each)) { + // Simple mode: not $each + assertHasValidFieldNames(arg); + + target[field].push(arg); + + return; + } + + // Fancy mode: $each (and maybe $slice and $sort and $position) + const toPush = arg.$each; + if (!(toPush instanceof Array)) { + throw MinimongoError('$each must be an array', {field}); + } + + assertHasValidFieldNames(toPush); + + // Parse $position + let position = undefined; + if ('$position' in arg) { + if (typeof arg.$position !== 'number') { + throw MinimongoError('$position must be a numeric value', {field}); + } + + // XXX should check to make sure integer + if (arg.$position < 0) { + throw MinimongoError( + '$position in $push must be zero or positive', + {field} + ); + } + + position = arg.$position; + } + + // Parse $slice. + let slice = undefined; + if ('$slice' in arg) { + if (typeof arg.$slice !== 'number') { + throw MinimongoError('$slice must be a numeric value', {field}); + } + + // XXX should check to make sure integer + slice = arg.$slice; + } + + // Parse $sort. + let sortFunction = undefined; + if (arg.$sort) { + if (slice === undefined) { + throw MinimongoError('$sort requires $slice to be present', {field}); + } + + // XXX this allows us to use a $sort whose value is an array, but that's + // actually an extension of the Node driver, so it won't work + // server-side. Could be confusing! + // XXX is it correct that we don't do geo-stuff here? + sortFunction = new LocalCollection.Sorter(arg.$sort).getComparator(); + + toPush.forEach(element => { + if (LocalCollection._f._type(element) !== 3) { + throw MinimongoError( + '$push like modifiers using $sort require all elements to be ' + + 'objects', + {field} + ); + } + }); + } + + // Actually push. + if (position === undefined) { + toPush.forEach(element => { + target[field].push(element); + }); + } else { + const spliceArguments = [position, 0]; + + toPush.forEach(element => { + spliceArguments.push(element); + }); + + target[field].splice(...spliceArguments); + } + + // Actually sort. + if (sortFunction) { + target[field].sort(sortFunction); + } + + // Actually slice. + if (slice !== undefined) { + if (slice === 0) { + target[field] = []; // differs from Array.slice! + } else if (slice < 0) { + target[field] = target[field].slice(slice); + } else { + target[field] = target[field].slice(0, slice); + } + } + }, + $pushAll(target, field, arg) { + if (!(typeof arg === 'object' && arg instanceof Array)) { + throw MinimongoError('Modifier $pushAll/pullAll allowed for arrays only'); + } + + assertHasValidFieldNames(arg); + + const toPush = target[field]; + + if (toPush === undefined) { + target[field] = arg; + } else if (!(toPush instanceof Array)) { + throw MinimongoError( + 'Cannot apply $pushAll modifier to non-array', + {field} + ); + } else { + toPush.push(...arg); + } + }, + $addToSet(target, field, arg) { + let isEach = false; + + if (typeof arg === 'object') { + // check if first key is '$each' + const keys = Object.keys(arg); + if (keys[0] === '$each') { + isEach = true; + } + } + + const values = isEach ? arg.$each : [arg]; + + assertHasValidFieldNames(values); + + const toAdd = target[field]; + if (toAdd === undefined) { + target[field] = values; + } else if (!(toAdd instanceof Array)) { + throw MinimongoError( + 'Cannot apply $addToSet modifier to non-array', + {field} + ); + } else { + values.forEach(value => { + if (toAdd.some(element => LocalCollection._f._equal(value, element))) { + return; + } + + toAdd.push(value); + }); + } + }, + $pop(target, field, arg) { + if (target === undefined) { + return; + } + + const toPop = target[field]; + + if (toPop === undefined) { + return; + } + + if (!(toPop instanceof Array)) { + throw MinimongoError('Cannot apply $pop modifier to non-array', {field}); + } + + if (typeof arg === 'number' && arg < 0) { + toPop.splice(0, 1); + } else { + toPop.pop(); + } + }, + $pull(target, field, arg) { + if (target === undefined) { + return; + } + + const toPull = target[field]; + if (toPull === undefined) { + return; + } + + if (!(toPull instanceof Array)) { + throw MinimongoError( + 'Cannot apply $pull/pullAll modifier to non-array', + {field} + ); + } + + let out; + if (arg != null && typeof arg === 'object' && !(arg instanceof Array)) { + // XXX would be much nicer to compile this once, rather than + // for each document we modify.. but usually we're not + // modifying that many documents, so we'll let it slide for + // now + + // XXX Minimongo.Matcher isn't up for the job, because we need + // to permit stuff like {$pull: {a: {$gt: 4}}}.. something + // like {$gt: 4} is not normally a complete selector. + // same issue as $elemMatch possibly? + const matcher = new LocalCollection.Matcher(arg); + + out = toPull.filter(element => !matcher.documentMatches(element).result); + } else { + out = toPull.filter(element => !LocalCollection._f._equal(element, arg)); + } + + target[field] = out; + }, + $pullAll(target, field, arg) { + if (!(typeof arg === 'object' && arg instanceof Array)) { + throw MinimongoError( + 'Modifier $pushAll/pullAll allowed for arrays only', + {field} + ); + } + + if (target === undefined) { + return; + } + + const toPull = target[field]; + + if (toPull === undefined) { + return; + } + + if (!(toPull instanceof Array)) { + throw MinimongoError( + 'Cannot apply $pull/pullAll modifier to non-array', + {field} + ); + } + + target[field] = toPull.filter(object => + !arg.some(element => LocalCollection._f._equal(object, element)) + ); + }, + $bit(target, field, arg) { + // XXX mongo only supports $bit on integers, and we only support + // native javascript numbers (doubles) so far, so we can't support $bit + throw MinimongoError('$bit is not supported', {field}); + }, + $v() { + // As discussed in https://github.com/meteor/meteor/issues/9623, + // the `$v` operator is not needed by Meteor, but problems can occur if + // it's not at least callable (as of Mongo >= 3.6). It's defined here as + // a no-op to work around these problems. + } +}; + +const NO_CREATE_MODIFIERS = { + $pop: true, + $pull: true, + $pullAll: true, + $rename: true, + $unset: true +}; + +// Make sure field names do not contain Mongo restricted +// characters ('.', '$', '\0'). +// https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names +const invalidCharMsg = { + $: 'start with \'$\'', + '.': 'contain \'.\'', + '\0': 'contain null bytes' +}; + +// checks if all field names in an object are valid +function assertHasValidFieldNames(doc) { + if (doc && typeof doc === 'object') { + JSON.stringify(doc, (key, value) => { + assertIsValidFieldName(key); + return value; + }); + } +} + +function assertIsValidFieldName(key) { + let match; + if (typeof key === 'string' && (match = key.match(/^\$|\.|\0/))) { + throw MinimongoError(`Key ${key} must not ${invalidCharMsg[match[0]]}`); + } +} + +// for a.b.c.2.d.e, keyparts should be ['a', 'b', 'c', '2', 'd', 'e'], +// and then you would operate on the 'e' property of the returned +// object. +// +// if options.noCreate is falsey, creates intermediate levels of +// structure as necessary, like mkdir -p (and raises an exception if +// that would mean giving a non-numeric property to an array.) if +// options.noCreate is true, return undefined instead. +// +// may modify the last element of keyparts to signal to the caller that it needs +// to use a different value to index into the returned object (for example, +// ['a', '01'] -> ['a', 1]). +// +// if forbidArray is true, return null if the keypath goes through an array. +// +// if options.arrayIndices is set, use its first element for the (first) '$' in +// the path. +function findModTarget(doc, keyparts, options = {}) { + let usedArrayIndex = false; + + for (let i = 0; i < keyparts.length; i++) { + const last = i === keyparts.length - 1; + let keypart = keyparts[i]; + + if (!isIndexable(doc)) { + if (options.noCreate) { + return undefined; + } + + const error = MinimongoError( + `cannot use the part '${keypart}' to traverse ${doc}` + ); + error.setPropertyError = true; + throw error; + } + + if (doc instanceof Array) { + if (options.forbidArray) { + return null; + } + + if (keypart === '$') { + if (usedArrayIndex) { + throw MinimongoError('Too many positional (i.e. \'$\') elements'); + } + + if (!options.arrayIndices || !options.arrayIndices.length) { + throw MinimongoError( + 'The positional operator did not find the match needed from the ' + + 'query' + ); + } + + keypart = options.arrayIndices[0]; + usedArrayIndex = true; + } else if (isNumericKey(keypart)) { + keypart = parseInt(keypart); + } else { + if (options.noCreate) { + return undefined; + } + + throw MinimongoError( + `can't append to array using string field name [${keypart}]` + ); + } + + if (last) { + keyparts[i] = keypart; // handle 'a.01' + } + + if (options.noCreate && keypart >= doc.length) { + return undefined; + } + + while (doc.length < keypart) { + doc.push(null); + } + + if (!last) { + if (doc.length === keypart) { + doc.push({}); + } else if (typeof doc[keypart] !== 'object') { + throw MinimongoError( + `can't modify field '${keyparts[i + 1]}' of list value ` + + JSON.stringify(doc[keypart]) + ); + } + } + } else { + assertIsValidFieldName(keypart); + + if (!(keypart in doc)) { + if (options.noCreate) { + return undefined; + } + + if (!last) { + doc[keypart] = {}; + } + } + } + + if (last) { + return doc; + } + + doc = doc[keypart]; + } + + // notreached +} + + + +// The minimongo selector compiler! + +// Terminology: +// - a 'selector' is the EJSON object representing a selector +// - a 'matcher' is its compiled form (whether a full Minimongo.Matcher +// object or one of the component lambdas that matches parts of it) +// - a 'result object' is an object with a 'result' field and maybe +// distance and arrayIndices. +// - a 'branched value' is an object with a 'value' field and maybe +// 'dontIterate' and 'arrayIndices'. +// - a 'document' is a top-level object that can be stored in a collection. +// - a 'lookup function' is a function that takes in a document and returns +// an array of 'branched values'. +// - a 'branched matcher' maps from an array of branched values to a result +// object. +// - an 'element matcher' maps from a single value to a bool. + +// Main entry point. +// var matcher = new Minimongo.Matcher({a: {$gt: 5}}); +// if (matcher.documentMatches({a: 7})) ... +LocalCollection.Matcher = class Matcher { + constructor(selector, isUpdate) { + // A set (object mapping string -> *) of all of the document paths looked + // at by the selector. Also includes the empty string if it may look at any + // path (eg, $where). + this._paths = {}; + // Set to true if compilation finds a $near. + this._hasGeoQuery = false; + // Set to true if compilation finds a $where. + this._hasWhere = false; + // Set to false if compilation finds anything other than a simple equality + // or one or more of '$gt', '$gte', '$lt', '$lte', '$ne', '$in', '$nin' used + // with scalars as operands. + this._isSimple = true; + // Set to a dummy document which always matches this Matcher. Or set to null + // if such document is too hard to find. + this._matchingDocument = undefined; + // A clone of the original selector. It may just be a function if the user + // passed in a function; otherwise is definitely an object (eg, IDs are + // translated into {_id: ID} first. Used by canBecomeTrueByModifier and + // Sorter._useWithMatcher. + this._selector = null; + this._docMatcher = this._compileSelector(selector); + // Set to true if selection is done for an update operation + // Default is false + // Used for $near array update (issue #3599) + this._isUpdate = isUpdate; + } + + documentMatches(doc) { + if (doc !== Object(doc)) { + throw Error('documentMatches needs a document'); + } + + return this._docMatcher(doc); + } + + hasGeoQuery() { + return this._hasGeoQuery; + } + + hasWhere() { + return this._hasWhere; + } + + isSimple() { + return this._isSimple; + } + + // Given a selector, return a function that takes one argument, a + // document. It returns a result object. + _compileSelector(selector) { + // you can pass a literal function instead of a selector + if (selector instanceof Function) { + this._isSimple = false; + this._selector = selector; + this._recordPathUsed(''); + + return doc => ({result: !!selector.call(doc)}); + } + + // shorthand -- scalar _id + if (LocalCollection._selectorIsId(selector)) { + this._selector = {_id: selector}; + this._recordPathUsed('_id'); + + return doc => ({result: EJSON.equals(doc._id, selector)}); + } + + // protect against dangerous selectors. falsey and {_id: falsey} are both + // likely programmer error, and not what you want, particularly for + // destructive operations. + if (!selector || hasOwn.call(selector, '_id') && !selector._id) { + this._isSimple = false; + return nothingMatcher; + } + + // Top level can't be an array or true or binary. + if (Array.isArray(selector) || + EJSON.isBinary(selector) || + typeof selector === 'boolean') { + throw new Error(`Invalid selector: ${selector}`); + } + + this._selector = EJSON.clone(selector); + + return compileDocumentSelector(selector, this, {isRoot: true}); + } + + // Returns a list of key paths the given selector is looking for. It includes + // the empty string if there is a $where. + _getPaths() { + return Object.keys(this._paths); + } + + _recordPathUsed(path) { + this._paths[path] = true; + } +} + +// helpers used by compiled selector code +LocalCollection._f = bigBlobF + + + // Give a sort spec, which can be in any of these forms: + // {"key1": 1, "key2": -1} + // [["key1", "asc"], ["key2", "desc"]] + // ["key1", ["key2", "desc"]] + // + // (.. with the first form being dependent on the key enumeration + // behavior of your javascript VM, which usually does what you mean in + // this case if the key names don't look like integers ..) + // + // return a function that takes two objects, and returns -1 if the + // first object comes first in order, 1 if the second object comes + // first, or 0 if neither object comes before the other. + + LocalCollection.Sorter = class Sorter { + constructor(spec) { + this._sortSpecParts = []; + this._sortFunction = null; + + const addSpecPart = (path, ascending) => { + if (!path) { + throw Error('sort keys must be non-empty'); + } + + if (path.charAt(0) === '$') { + throw Error(`unsupported sort key: ${path}`); + } + + this._sortSpecParts.push({ + ascending, + lookup: makeLookupFunction(path, {forSort: true}), + path + }); + }; + + if (spec instanceof Array) { + spec.forEach(element => { + if (typeof element === 'string') { + addSpecPart(element, true); + } else { + addSpecPart(element[0], element[1] !== 'desc'); + } + }); + } else if (typeof spec === 'object') { + Object.keys(spec).forEach(key => { + addSpecPart(key, spec[key] >= 0); + }); + } else if (typeof spec === 'function') { + this._sortFunction = spec; + } else { + throw Error(`Bad sort specification: ${JSON.stringify(spec)}`); + } + + // If a function is specified for sorting, we skip the rest. + if (this._sortFunction) { + return; + } + + // To implement affectedByModifier, we piggy-back on top of Matcher's + // affectedByModifier code; we create a selector that is affected by the + // same modifiers as this sort order. This is only implemented on the + // server. + if (this.affectedByModifier) { + const selector = {}; + + this._sortSpecParts.forEach(spec => { + selector[spec.path] = 1; + }); + + this._selectorForAffectedByModifier = new LocalCollection.Matcher(selector); + } + + this._keyComparator = composeComparators( + this._sortSpecParts.map((spec, i) => this._keyFieldComparator(i)) + ); + } + + getComparator(options) { + // If sort is specified or have no distances, just use the comparator from + // the source specification (which defaults to "everything is equal". + // issue #3599 + // https://docs.mongodb.com/manual/reference/operator/query/near/#sort-operation + // sort effectively overrides $near + if (this._sortSpecParts.length || !options || !options.distances) { + return this._getBaseComparator(); + } + + const distances = options.distances; + + // Return a comparator which compares using $near distances. + return (a, b) => { + if (!distances.has(a._id)) { + throw Error(`Missing distance for ${a._id}`); + } + + if (!distances.has(b._id)) { + throw Error(`Missing distance for ${b._id}`); + } + + return distances.get(a._id) - distances.get(b._id); + }; + } + + // Takes in two keys: arrays whose lengths match the number of spec + // parts. Returns negative, 0, or positive based on using the sort spec to + // compare fields. + _compareKeys(key1, key2) { + if (key1.length !== this._sortSpecParts.length || + key2.length !== this._sortSpecParts.length) { + throw Error('Key has wrong length'); + } + + return this._keyComparator(key1, key2); + } + + // Iterates over each possible "key" from doc (ie, over each branch), calling + // 'cb' with the key. + _generateKeysFromDoc(doc, cb) { + if (this._sortSpecParts.length === 0) { + throw new Error('can\'t generate keys without a spec'); + } + + const pathFromIndices = indices => `${indices.join(',')},`; + + let knownPaths = null; + + // maps index -> ({'' -> value} or {path -> value}) + const valuesByIndexAndPath = this._sortSpecParts.map(spec => { + // Expand any leaf arrays that we find, and ignore those arrays + // themselves. (We never sort based on an array itself.) + let branches = expandArraysInBranches(spec.lookup(doc), true); + + // If there are no values for a key (eg, key goes to an empty array), + // pretend we found one undefined value. + if (!branches.length) { + branches = [{ value: void 0 }]; + } + + const element = Object.create(null); + let usedPaths = false; + + branches.forEach(branch => { + if (!branch.arrayIndices) { + // If there are no array indices for a branch, then it must be the + // only branch, because the only thing that produces multiple branches + // is the use of arrays. + if (branches.length > 1) { + throw Error('multiple branches but no array used?'); + } + + element[''] = branch.value; + return; + } + + usedPaths = true; + + const path = pathFromIndices(branch.arrayIndices); + + if (hasOwn.call(element, path)) { + throw Error(`duplicate path: ${path}`); + } + + element[path] = branch.value; + + // If two sort fields both go into arrays, they have to go into the + // exact same arrays and we have to find the same paths. This is + // roughly the same condition that makes MongoDB throw this strange + // error message. eg, the main thing is that if sort spec is {a: 1, + // b:1} then a and b cannot both be arrays. + // + // (In MongoDB it seems to be OK to have {a: 1, 'a.x.y': 1} where 'a' + // and 'a.x.y' are both arrays, but we don't allow this for now. + // #NestedArraySort + // XXX achieve full compatibility here + if (knownPaths && !hasOwn.call(knownPaths, path)) { + throw Error('cannot index parallel arrays'); + } + }); + + if (knownPaths) { + // Similarly to above, paths must match everywhere, unless this is a + // non-array field. + if (!hasOwn.call(element, '') && + Object.keys(knownPaths).length !== Object.keys(element).length) { + throw Error('cannot index parallel arrays!'); + } + } else if (usedPaths) { + knownPaths = {}; + + Object.keys(element).forEach(path => { + knownPaths[path] = true; + }); + } + + return element; + }); + + if (!knownPaths) { + // Easy case: no use of arrays. + const soleKey = valuesByIndexAndPath.map(values => { + if (!hasOwn.call(values, '')) { + throw Error('no value in sole key case?'); + } + + return values['']; + }); + + cb(soleKey); + + return; + } + + Object.keys(knownPaths).forEach(path => { + const key = valuesByIndexAndPath.map(values => { + if (hasOwn.call(values, '')) { + return values['']; + } + + if (!hasOwn.call(values, path)) { + throw Error('missing path?'); + } + + return values[path]; + }); + + cb(key); + }); + } + + // Returns a comparator that represents the sort specification (but not + // including a possible geoquery distance tie-breaker). + _getBaseComparator() { + if (this._sortFunction) { + return this._sortFunction; + } + + // If we're only sorting on geoquery distance and no specs, just say + // everything is equal. + if (!this._sortSpecParts.length) { + return (doc1, doc2) => 0; + } + + return (doc1, doc2) => { + const key1 = this._getMinKeyFromDoc(doc1); + const key2 = this._getMinKeyFromDoc(doc2); + return this._compareKeys(key1, key2); + }; + } + + // Finds the minimum key from the doc, according to the sort specs. (We say + // "minimum" here but this is with respect to the sort spec, so "descending" + // sort fields mean we're finding the max for that field.) + // + // Note that this is NOT "find the minimum value of the first field, the + // minimum value of the second field, etc"... it's "choose the + // lexicographically minimum value of the key vector, allowing only keys which + // you can find along the same paths". ie, for a doc {a: [{x: 0, y: 5}, {x: + // 1, y: 3}]} with sort spec {'a.x': 1, 'a.y': 1}, the only keys are [0,5] and + // [1,3], and the minimum key is [0,5]; notably, [0,3] is NOT a key. + _getMinKeyFromDoc(doc) { + let minKey = null; + + this._generateKeysFromDoc(doc, key => { + if (minKey === null) { + minKey = key; + return; + } + + if (this._compareKeys(key, minKey) < 0) { + minKey = key; + } + }); + + return minKey; + } + + _getPaths() { + return this._sortSpecParts.map(part => part.path); + } + + // Given an index 'i', returns a comparator that compares two key arrays based + // on field 'i'. + _keyFieldComparator(i) { + const invert = !this._sortSpecParts[i].ascending; + + return (key1, key2) => { + const compare = LocalCollection._f._cmp(key1[i], key2[i]); + return invert ? -compare : compare; + }; + } + } + + // Given an array of comparators + // (functions (a,b)->(negative or positive or zero)), returns a single + // comparator which uses each comparator in order and returns the first + // non-zero value. + function composeComparators(comparatorArray) { + return (a, b) => { + for (let i = 0; i < comparatorArray.length; ++i) { + const compare = comparatorArray[i](a, b); + if (compare !== 0) { + return compare; + } + } + + return 0; + }; + } + + + +// Cursor: a specification for a particular subset of documents, w/ a defined +// order, limit, and offset. creating a Cursor with LocalCollection.find(), +LocalCollection.Cursor = class Cursor { + // don't call this ctor directly. use LocalCollection.find(). + constructor(collection, selector, options = {}) { + this.collection = collection; + this.sorter = null; + this.matcher = new LocalCollection.Matcher(selector); + + if (LocalCollection._selectorIsIdPerhapsAsObject(selector)) { + // stash for fast _id and { _id } + this._selectorId = hasOwn.call(selector, '_id') + ? selector._id + : selector; + } else { + this._selectorId = undefined; + + if (this.matcher.hasGeoQuery() || options.sort) { + this.sorter = new LocalCollection.Sorter(options.sort || []); + } + } + + this.skip = options.skip || 0; + this.limit = options.limit; + this.fields = options.projection || options.fields; + + this._projectionFn = LocalCollection._compileProjection(this.fields || {}); + + this._transform = LocalCollection.wrapTransform(options.transform); + + // by default, queries register w/ Tracker when it is available. + if (typeof Tracker !== 'undefined') { + this.reactive = options.reactive === undefined ? true : options.reactive; + } + } + + /** + * @summary Returns the number of documents that match a query. + * @memberOf Mongo.Cursor + * @method count + * @instance + * @locus Anywhere + * @returns {Number} + */ + count() { + if (this.reactive) { + // allow the observe to be unordered + this._depend({added: true, removed: true}, true); + } + + return this._getRawObjects({ + ordered: true, + }).length; + } + + /** + * @summary Return all matching documents as an Array. + * @memberOf Mongo.Cursor + * @method fetch + * @instance + * @locus Anywhere + * @returns {Object[]} + */ + fetch() { + const result = []; + + this.forEach(doc => { + result.push(doc); + }); + + return result; + } + + [Symbol.iterator]() { + if (this.reactive) { + this._depend({ + addedBefore: true, + removed: true, + changed: true, + movedBefore: true}); + } + + let index = 0; + const objects = this._getRawObjects({ordered: true}); + + return { + next: () => { + if (index < objects.length) { + // This doubles as a clone operation. + let element = this._projectionFn(objects[index++]); + + if (this._transform) + element = this._transform(element); + + return {value: element}; + } + + return {done: true}; + } + }; + } + + /** + * @callback IterationCallback + * @param {Object} doc + * @param {Number} index + */ + /** + * @summary Call `callback` once for each matching document, sequentially and + * synchronously. + * @locus Anywhere + * @method forEach + * @instance + * @memberOf Mongo.Cursor + * @param {IterationCallback} callback Function to call. It will be called + * with three arguments: the document, a + * 0-based index, and cursor + * itself. + * @param {Any} [thisArg] An object which will be the value of `this` inside + * `callback`. + */ + forEach(callback, thisArg) { + if (this.reactive) { + this._depend({ + addedBefore: true, + removed: true, + changed: true, + movedBefore: true}); + } + + this._getRawObjects({ordered: true}).forEach((element, i) => { + // This doubles as a clone operation. + element = this._projectionFn(element); + + if (this._transform) { + element = this._transform(element); + } + + callback.call(thisArg, element, i, this); + }); + } + + getTransform() { + return this._transform; + } + + /** + * @summary Map callback over all matching documents. Returns an Array. + * @locus Anywhere + * @method map + * @instance + * @memberOf Mongo.Cursor + * @param {IterationCallback} callback Function to call. It will be called + * with three arguments: the document, a + * 0-based index, and cursor + * itself. + * @param {Any} [thisArg] An object which will be the value of `this` inside + * `callback`. + */ + map(callback, thisArg) { + const result = []; + + this.forEach((doc, i) => { + result.push(callback.call(thisArg, doc, i, this)); + }); + + return result; + } + + // options to contain: + // * callbacks for observe(): + // - addedAt (document, atIndex) + // - added (document) + // - changedAt (newDocument, oldDocument, atIndex) + // - changed (newDocument, oldDocument) + // - removedAt (document, atIndex) + // - removed (document) + // - movedTo (document, oldIndex, newIndex) + // + // attributes available on returned query handle: + // * stop(): end updates + // * collection: the collection this query is querying + // + // iff x is a returned query handle, (x instanceof + // LocalCollection.ObserveHandle) is true + // + // initial results delivered through added callback + // XXX maybe callbacks should take a list of objects, to expose transactions? + // XXX maybe support field limiting (to limit what you're notified on) + + /** + * @summary Watch a query. Receive callbacks as the result set changes. + * @locus Anywhere + * @memberOf Mongo.Cursor + * @instance + * @param {Object} callbacks Functions to call to deliver the result set as it + * changes + */ + observe(options) { + return LocalCollection._observeFromObserveChanges(this, options); + } + + /** + * @summary Watch a query. Receive callbacks as the result set changes. Only + * the differences between the old and new documents are passed to + * the callbacks. + * @locus Anywhere + * @memberOf Mongo.Cursor + * @instance + * @param {Object} callbacks Functions to call to deliver the result set as it + * changes + */ + observeChanges(options) { + const ordered = LocalCollection._observeChangesCallbacksAreOrdered(options); + + // there are several places that assume you aren't combining skip/limit with + // unordered observe. eg, update's EJSON.clone, and the "there are several" + // comment in _modifyAndNotify + // XXX allow skip/limit with unordered observe + if (!options._allow_unordered && !ordered && (this.skip || this.limit)) { + throw new Error( + "Must use an ordered observe with skip or limit (i.e. 'addedBefore' " + + "for observeChanges or 'addedAt' for observe, instead of 'added')." + ); + } + + if (this.fields && (this.fields._id === 0 || this.fields._id === false)) { + throw Error('You may not observe a cursor with {fields: {_id: 0}}'); + } + + const distances = ( + this.matcher.hasGeoQuery() && + ordered && + new LocalCollection._IdMap + ); + + const query = { + cursor: this, + dirty: false, + distances, + matcher: this.matcher, // not fast pathed + ordered, + projectionFn: this._projectionFn, + resultsSnapshot: null, + sorter: ordered && this.sorter + }; + + let qid; + + // Non-reactive queries call added[Before] and then never call anything + // else. + if (this.reactive) { + qid = this.collection.next_qid++; + this.collection.queries[qid] = query; + } + + query.results = this._getRawObjects({ordered, distances: query.distances}); + + if (this.collection.paused) { + query.resultsSnapshot = ordered ? [] : new LocalCollection._IdMap; + } + + // wrap callbacks we were passed. callbacks only fire when not paused and + // are never undefined + // Filters out blacklisted fields according to cursor's projection. + // XXX wrong place for this? + + // furthermore, callbacks enqueue until the operation we're working on is + // done. + const wrapCallback = fn => { + if (!fn) { + return () => {}; + } + + const self = this; + return function(/* args*/) { + if (self.collection.paused) { + return; + } + + const args = arguments; + + self.collection._observeQueue.queueTask(() => { + fn.apply(this, args); + }); + }; + }; + + query.added = wrapCallback(options.added); + query.changed = wrapCallback(options.changed); + query.removed = wrapCallback(options.removed); + + if (ordered) { + query.addedBefore = wrapCallback(options.addedBefore); + query.movedBefore = wrapCallback(options.movedBefore); + } + + if (!options._suppress_initial && !this.collection.paused) { + query.results.forEach(doc => { + const fields = EJSON.clone(doc); + + delete fields._id; + + if (ordered) { + query.addedBefore(doc._id, this._projectionFn(fields), null); + } + + query.added(doc._id, this._projectionFn(fields)); + }); + } + + const handle = Object.assign(new LocalCollection.ObserveHandle, { + collection: this.collection, + stop: () => { + if (this.reactive) { + delete this.collection.queries[qid]; + } + } + }); + + if (this.reactive && Tracker.active) { + // XXX in many cases, the same observe will be recreated when + // the current autorun is rerun. we could save work by + // letting it linger across rerun and potentially get + // repurposed if the same observe is performed, using logic + // similar to that of Meteor.subscribe. + Tracker.onInvalidate(() => { + handle.stop(); + }); + } + + // run the observe callbacks resulting from the initial contents + // before we leave the observe. + this.collection._observeQueue.drain(); + + return handle; + } + + // XXX Maybe we need a version of observe that just calls a callback if + // anything changed. + _depend(changers, _allow_unordered) { + if (Tracker.active) { + const dependency = new Tracker.Dependency; + const notify = dependency.changed.bind(dependency); + + dependency.depend(); + + const options = {_allow_unordered, _suppress_initial: true}; + + ['added', 'addedBefore', 'changed', 'movedBefore', 'removed'] + .forEach(fn => { + if (changers[fn]) { + options[fn] = notify; + } + }); + + // observeChanges will stop() when this computation is invalidated + this.observeChanges(options); + } + } + + _getCollectionName() { + return this.collection.name; + } + + // Returns a collection of matching objects, but doesn't deep copy them. + // + // If ordered is set, returns a sorted array, respecting sorter, skip, and + // limit properties of the query provided that options.applySkipLimit is + // not set to false (#1201). If sorter is falsey, no sort -- you get the + // natural order. + // + // If ordered is not set, returns an object mapping from ID to doc (sorter, + // skip and limit should not be set). + // + // If ordered is set and this cursor is a $near geoquery, then this function + // will use an _IdMap to track each distance from the $near argument point in + // order to use it as a sort key. If an _IdMap is passed in the 'distances' + // argument, this function will clear it and use it for this purpose + // (otherwise it will just create its own _IdMap). The observeChanges + // implementation uses this to remember the distances after this function + // returns. + _getRawObjects(options = {}) { + // By default this method will respect skip and limit because .fetch(), + // .forEach() etc... expect this behaviour. It can be forced to ignore + // skip and limit by setting applySkipLimit to false (.count() does this, + // for example) + const applySkipLimit = options.applySkipLimit !== false; + + // XXX use OrderedDict instead of array, and make IdMap and OrderedDict + // compatible + const results = options.ordered ? [] : new LocalCollection._IdMap; + + // fast path for single ID value + if (this._selectorId !== undefined) { + // If you have non-zero skip and ask for a single id, you get nothing. + // This is so it matches the behavior of the '{_id: foo}' path. + if (applySkipLimit && this.skip) { + return results; + } + + const selectedDoc = this.collection._docs.get(this._selectorId); + + if (selectedDoc) { + if (options.ordered) { + results.push(selectedDoc); + } else { + results.set(this._selectorId, selectedDoc); + } + } + + return results; + } + + // slow path for arbitrary selector, sort, skip, limit + + // in the observeChanges case, distances is actually part of the "query" + // (ie, live results set) object. in other cases, distances is only used + // inside this function. + let distances; + if (this.matcher.hasGeoQuery() && options.ordered) { + if (options.distances) { + distances = options.distances; + distances.clear(); + } else { + distances = new LocalCollection._IdMap(); + } + } + + this.collection._docs.forEach((doc, id) => { + const matchResult = this.matcher.documentMatches(doc); + + if (matchResult.result) { + if (options.ordered) { + results.push(doc); + + if (distances && matchResult.distance !== undefined) { + distances.set(id, matchResult.distance); + } + } else { + results.set(id, doc); + } + } + + // Override to ensure all docs are matched if ignoring skip & limit + if (!applySkipLimit) { + return true; + } + + // Fast path for limited unsorted queries. + // XXX 'length' check here seems wrong for ordered + return ( + !this.limit || + this.skip || + this.sorter || + results.length !== this.limit + ); + }); + + if (!options.ordered) { + return results; + } + + if (this.sorter) { + results.sort(this.sorter.getComparator({distances})); + } + + // Return the full set of results if there is no skip or limit or if we're + // ignoring them + if (!applySkipLimit || (!this.limit && !this.skip)) { + return results; + } + + return results.slice( + this.skip, + this.limit ? this.limit + this.skip : results.length + ); + } + + _publishCursor(subscription) { + // // XXX minimongo should not depend on mongo-livedata! + // if (!Package.mongo) { + // throw new Error( + // 'Can\'t publish from Minimongo without the `mongo` package.' + // ); + // } + + if (!this.collection.name) { + throw new Error( + 'Can\'t publish a cursor from a collection without a name.' + ); + } + + return LocalCollection.Mongo.Collection._publishCursor( + this, + subscription, + this.collection.name + ); + } +} \ No newline at end of file diff --git a/packages/webui/src/meteor/minimongo/observe_handle.js b/packages/webui/src/meteor/minimongo/observe_handle.js new file mode 100644 index 0000000000..d8580f93a9 --- /dev/null +++ b/packages/webui/src/meteor/minimongo/observe_handle.js @@ -0,0 +1,2 @@ +// ObserveHandle: the return value of a live query. +export default class ObserveHandle {} \ No newline at end of file diff --git a/packages/webui/src/meteor/mongo-id.js b/packages/webui/src/meteor/mongo-id.js new file mode 100644 index 0000000000..ecbf7e4b99 --- /dev/null +++ b/packages/webui/src/meteor/mongo-id.js @@ -0,0 +1,101 @@ +import EJSON from 'ejson'; +import { Random } from './random'; + +const MongoID = {}; + +MongoID._looksLikeObjectID = str => str.length === 24 && str.match(/^[0-9a-f]*$/); + +MongoID.ObjectID = class ObjectID { + constructor (hexString) { + //random-based impl of Mongo ObjectID + if (hexString) { + hexString = hexString.toLowerCase(); + if (!MongoID._looksLikeObjectID(hexString)) { + throw new Error('Invalid hexadecimal string for creating an ObjectID'); + } + // meant to work with _.isEqual(), which relies on structural equality + this._str = hexString; + } else { + this._str = Random.hexString(24); + } + } + + equals(other) { + return other instanceof MongoID.ObjectID && + this.valueOf() === other.valueOf(); + } + + toString() { + return `ObjectID("${this._str}")`; + } + + clone() { + return new MongoID.ObjectID(this._str); + } + + typeName() { + return 'oid'; + } + + getTimestamp() { + return Number.parseInt(this._str.substr(0, 8), 16); + } + + valueOf() { + return this._str; + } + + toJSONValue() { + return this.valueOf(); + } + + toHexString() { + return this.valueOf(); + } + +} + +EJSON.addType('oid', str => new MongoID.ObjectID(str)); + +MongoID.idStringify = (id) => { + if (id instanceof MongoID.ObjectID) { + return id.valueOf(); + } else if (typeof id === 'string') { + var firstChar = id.charAt(0); + if (id === '') { + return id; + } else if (firstChar === '-' || // escape previously dashed strings + firstChar === '~' || // escape escaped numbers, true, false + MongoID._looksLikeObjectID(id) || // escape object-id-form strings + firstChar === '{') { // escape object-form strings, for maybe implementing later + return `-${id}`; + } else { + return id; // other strings go through unchanged. + } + } else if (id === undefined) { + return '-'; + } else if (typeof id === 'object' && id !== null) { + throw new Error('Meteor does not currently support objects other than ObjectID as ids'); + } else { // Numbers, true, false, null + return `~${JSON.stringify(id)}`; + } +}; + +MongoID.idParse = (id) => { + var firstChar = id.charAt(0); + if (id === '') { + return id; + } else if (id === '-') { + return undefined; + } else if (firstChar === '-') { + return id.substr(1); + } else if (firstChar === '~') { + return JSON.parse(id.substr(1)); + } else if (MongoID._looksLikeObjectID(id)) { + return new MongoID.ObjectID(id); + } else { + return id; + } +}; + +export { MongoID }; \ No newline at end of file diff --git a/packages/webui/src/meteor/mongo/index.d.ts b/packages/webui/src/meteor/mongo/index.d.ts new file mode 100644 index 0000000000..b18236b63a --- /dev/null +++ b/packages/webui/src/meteor/mongo/index.d.ts @@ -0,0 +1,527 @@ +import * as MongoNpmModule from 'mongodb'; +// tslint:disable-next-line:no-duplicate-imports +import { Collection as MongoCollection, CreateIndexesOptions, Db as MongoDb, Hint, IndexSpecification, MongoClient } from 'mongodb'; +import { Meteor } from '../meteor'; + + // Based on https://github.com/microsoft/TypeScript/issues/28791#issuecomment-443520161 + type UnionOmit = T extends T ? Pick> : never; + +export namespace Mongo { + // prettier-ignore + type BsonType = 1 | "double" | + 2 | "string" | + 3 | "object" | + 4 | "array" | + 5 | "binData" | + 6 | "undefined" | + 7 | "objectId" | + 8 | "bool" | + 9 | "date" | + 10 | "null" | + 11 | "regex" | + 12 | "dbPointer" | + 13 | "javascript" | + 14 | "symbol" | + 15 | "javascriptWithScope" | + 16 | "int" | + 17 | "timestamp" | + 18 | "long" | + 19 | "decimal" | + -1 | "minKey" | + 127 | "maxKey" | "number"; + + type FieldExpression = { + $eq?: T | undefined; + $gt?: T | undefined; + $gte?: T | undefined; + $lt?: T | undefined; + $lte?: T | undefined; + $in?: T[] | undefined; + $nin?: T[] | undefined; + $ne?: T | undefined; + $exists?: boolean | undefined; + $type?: BsonType[] | BsonType | undefined; + $not?: FieldExpression | undefined; + $expr?: FieldExpression | undefined; + $jsonSchema?: any; + $mod?: number[] | undefined; + $regex?: RegExp | string | undefined; + $options?: string | undefined; + $text?: + | { + $search: string; + $language?: string | undefined; + $caseSensitive?: boolean | undefined; + $diacriticSensitive?: boolean | undefined; + } + | undefined; + $where?: string | Function | undefined; + $geoIntersects?: any; + $geoWithin?: any; + $near?: any; + $nearSphere?: any; + $all?: T[] | undefined; + $elemMatch?: T extends {} ? Query : FieldExpression | undefined; + $size?: number | undefined; + $bitsAllClear?: any; + $bitsAllSet?: any; + $bitsAnyClear?: any; + $bitsAnySet?: any; + $comment?: string | undefined; + }; + + type Flatten = T extends any[] ? T[0] : T; + + type Query = { [P in keyof T]?: Flatten | RegExp | FieldExpression> } & { + $or?: Query[] | undefined; + $and?: Query[] | undefined; + $nor?: Query[] | undefined; + } & Dictionary; + + type QueryWithModifiers = { + $query: Query; + $comment?: string | undefined; + $explain?: any; + $hint?: Hint; + $maxScan?: any; + $max?: any; + $maxTimeMS?: any; + $min?: any; + $orderby?: any; + $returnKey?: any; + $showDiskLoc?: any; + $natural?: any; + }; + + type Selector = Query | QueryWithModifiers; + + type Dictionary = { [key: string]: T }; + type PartialMapTo = Partial>; + type OnlyArrays = T extends any[] ? T : never; + type OnlyElementsOfArrays = T extends any[] ? Partial : never; + type ElementsOf = { + [P in keyof T]?: OnlyElementsOfArrays; + }; + type PushModifier = { + [P in keyof T]?: + | OnlyElementsOfArrays + | { + $each?: T[P] | undefined; + $position?: number | undefined; + $slice?: number | undefined; + $sort?: 1 | -1 | Dictionary | undefined; + }; + }; + type ArraysOrEach = { + [P in keyof T]?: OnlyElementsOfArrays | { $each: T[P] }; + }; + type CurrentDateModifier = { $type: 'timestamp' | 'date' } | true; + type Modifier = + | T + | { + $currentDate?: + | (Partial> & Dictionary) + | undefined; + $inc?: (PartialMapTo & Dictionary) | undefined; + $min?: (PartialMapTo & Dictionary) | undefined; + $max?: (PartialMapTo & Dictionary) | undefined; + $mul?: (PartialMapTo & Dictionary) | undefined; + $rename?: (PartialMapTo & Dictionary) | undefined; + $set?: (Partial & Dictionary) | undefined; + $setOnInsert?: (Partial & Dictionary) | undefined; + $unset?: (PartialMapTo & Dictionary) | undefined; + $addToSet?: (ArraysOrEach & Dictionary) | undefined; + $push?: (PushModifier & Dictionary) | undefined; + $pull?: (ElementsOf & Dictionary) | undefined; + $pullAll?: (Partial & Dictionary) | undefined; + $pop?: (PartialMapTo & Dictionary<1 | -1>) | undefined; + }; + + type OptionalId = UnionOmit & { _id?: any }; + + interface SortSpecifier {} + interface FieldSpecifier { + [id: string]: Number; + } + + type Transform = ((doc: T) => any) | null | undefined; + + type Options = { + /** Sort order (default: natural order) */ + sort?: SortSpecifier | undefined; + /** Number of results to skip at the beginning */ + skip?: number | undefined; + /** Maximum number of results to return */ + limit?: number | undefined; + /** Dictionary of fields to return or exclude. */ + fields?: FieldSpecifier | undefined; + /** (Server only) Overrides MongoDB's default index selection and query optimization process. Specify an index to force its use, either by its name or index specification. */ + hint?: Hint | undefined; + /** (Client only) Default `true`; pass `false` to disable reactivity */ + reactive?: boolean | undefined; + /** Overrides `transform` on the [`Collection`](#collections) for this cursor. Pass `null` to disable transformation. */ + transform?: Transform | undefined; + }; + + type DispatchTransform = Transform extends (...args: any) => any + ? ReturnType + : Transform extends null + ? T + : U; + + var Collection: CollectionStatic; + interface CollectionStatic { + /** + * Constructor for a Collection + * @param name The name of the collection. If null, creates an unmanaged (unsynchronized) local collection. + */ + new ( + name: string | null, + options?: { + /** + * The server connection that will manage this collection. Uses the default connection if not specified. Pass the return value of calling `DDP.connect` to specify a different + * server. Pass `null` to specify no connection. Unmanaged (`name` is null) collections cannot specify a connection. + */ + connection?: Object | null | undefined; + /** The method of generating the `_id` fields of new documents in this collection. Possible values: + * - **`'STRING'`**: random strings + * - **`'MONGO'`**: random [`Mongo.ObjectID`](#mongo_object_id) values + * + * The default id generation technique is `'STRING'`. + */ + idGeneration?: string | undefined; + /** + * An optional transformation function. Documents will be passed through this function before being returned from `fetch` or `findOne`, and before being passed to callbacks of + * `observe`, `map`, `forEach`, `allow`, and `deny`. Transforms are *not* applied for the callbacks of `observeChanges` or to cursors returned from publish functions. + */ + transform?: (doc: T) => U; + /** Set to `false` to skip setting up the mutation methods that enable insert/update/remove from client code. Default `true`. */ + defineMutationMethods?: boolean | undefined; + }, + ): Collection; + } + interface Collection { + allow = undefined>(options: { + insert?: ((userId: string, doc: DispatchTransform) => boolean) | undefined; + update?: + | (( + userId: string, + doc: DispatchTransform, + fieldNames: string[], + modifier: any, + ) => boolean) + | undefined; + remove?: ((userId: string, doc: DispatchTransform) => boolean) | undefined; + fetch?: string[] | undefined; + transform?: Fn | undefined; + }): boolean; + createCappedCollectionAsync(byteSize?: number, maxDocuments?: number): Promise; + createIndex(indexSpec: IndexSpecification, options?: CreateIndexesOptions): void; + createIndexAsync(indexSpec: IndexSpecification, options?: CreateIndexesOptions): Promise; + deny = undefined>(options: { + insert?: ((userId: string, doc: DispatchTransform) => boolean) | undefined; + update?: + | (( + userId: string, + doc: DispatchTransform, + fieldNames: string[], + modifier: any, + ) => boolean) + | undefined; + remove?: ((userId: string, doc: DispatchTransform) => boolean) | undefined; + fetch?: string[] | undefined; + transform?: Fn | undefined; + }): boolean; + dropCollectionAsync(): Promise; + dropIndexAsync(indexName: string): void; + /** + * Find the documents in a collection that match the selector. + * @param selector A query describing the documents to find + */ + find(selector?: Selector | ObjectID | string): Cursor; + /** + * Find the documents in a collection that match the selector. + * @param selector A query describing the documents to find + */ + find>( + selector?: Selector | ObjectID | string, + options?: O, + ): Cursor>; + /** + * Finds the first document that matches the selector, as ordered by sort and skip options. Returns `undefined` if no matching document is found. + * @param selector A query describing the documents to find + */ + findOne(selector?: Selector | ObjectID | string): U | undefined; + /** + * Finds the first document that matches the selector, as ordered by sort and skip options. Returns `undefined` if no matching document is found. + * @param selector A query describing the documents to find + */ + findOne, 'limit'>>( + selector?: Selector | ObjectID | string, + options?: O, + ): DispatchTransform | undefined; + /** + * Finds the first document that matches the selector, as ordered by sort and skip options. Returns `undefined` if no matching document is found. + * @param selector A query describing the documents to find + */ + findOneAsync(selector?: Selector | ObjectID | string): Promise; + /** + * Finds the first document that matches the selector, as ordered by sort and skip options. Returns `undefined` if no matching document is found. + * @param selector A query describing the documents to find + */ + findOneAsync, 'limit'>>( + selector?: Selector | ObjectID | string, + options?: O, + ): Promise | undefined>; + /** + * Insert a document in the collection. Returns its unique _id. + * @param doc The document to insert. May not yet have an _id attribute, in which case Meteor will generate one for you. + * @param callback If present, called with an error object as the first argument and, if no error, the _id as the second. + */ + insert(doc: OptionalId, callback?: Function): string; + /** + * Insert a document in the collection. Returns its unique _id. + * @param doc The document to insert. May not yet have an _id attribute, in which case Meteor will generate one for you. + * @param callback If present, called with an error object as the first argument and, if no error, the _id as the second. + */ + insertAsync(doc: OptionalId, callback?: Function): Promise; + /** + * Returns the [`Collection`](http://mongodb.github.io/node-mongodb-native/3.0/api/Collection.html) object corresponding to this collection from the + * [npm `mongodb` driver module](https://www.npmjs.com/package/mongodb) which is wrapped by `Mongo.Collection`. + */ + rawCollection(): MongoCollection; + /** + * Returns the [`Db`](http://mongodb.github.io/node-mongodb-native/3.0/api/Db.html) object corresponding to this collection's database connection from the + * [npm `mongodb` driver module](https://www.npmjs.com/package/mongodb) which is wrapped by `Mongo.Collection`. + */ + rawDatabase(): MongoDb; + /** + * Remove documents from the collection + * @param selector Specifies which documents to remove + * @param callback If present, called with an error object as its argument. + */ + remove(selector: Selector | ObjectID | string, callback?: Function): number; + /** + * Remove documents from the collection + * @param selector Specifies which documents to remove + * @param callback If present, called with an error object as its argument. + */ + removeAsync(selector: Selector | ObjectID | string, callback?: Function): Promise; + /** + * Modify one or more documents in the collection. Returns the number of matched documents. + * @param selector Specifies which documents to modify + * @param modifier Specifies how to modify the documents + * @param callback If present, called with an error object as the first argument and, if no error, the number of affected documents as the second. + */ + update( + selector: Selector | ObjectID | string, + modifier: Modifier, + options?: { + /** True to modify all matching documents; false to only modify one of the matching documents (the default). */ + multi?: boolean | undefined; + /** True to insert a document if no matching documents are found. */ + upsert?: boolean | undefined; + /** + * Used in combination with MongoDB [filtered positional operator](https://docs.mongodb.com/manual/reference/operator/update/positional-filtered/) to specify which elements to + * modify in an array field. + */ + arrayFilters?: { [identifier: string]: any }[] | undefined; + }, + callback?: Function, + ): number; + /** + * Modify one or more documents in the collection. Returns the number of matched documents. + * @param selector Specifies which documents to modify + * @param modifier Specifies how to modify the documents + * @param callback If present, called with an error object as the first argument and, if no error, the number of affected documents as the second. + */ + updateAsync( + selector: Selector | ObjectID | string, + modifier: Modifier, + options?: { + /** True to modify all matching documents; false to only modify one of the matching documents (the default). */ + multi?: boolean | undefined; + /** True to insert a document if no matching documents are found. */ + upsert?: boolean | undefined; + /** + * Used in combination with MongoDB [filtered positional operator](https://docs.mongodb.com/manual/reference/operator/update/positional-filtered/) to specify which elements to + * modify in an array field. + */ + arrayFilters?: { [identifier: string]: any }[] | undefined; + }, + callback?: Function, + ): Promise; + /** + * Modify one or more documents in the collection, or insert one if no matching documents were found. Returns an object with keys `numberAffected` (the number of documents modified) and + * `insertedId` (the unique _id of the document that was inserted, if any). + * @param selector Specifies which documents to modify + * @param modifier Specifies how to modify the documents + * @param callback If present, called with an error object as the first argument and, if no error, the number of affected documents as the second. + */ + upsert( + selector: Selector | ObjectID | string, + modifier: Modifier, + options?: { + /** True to modify all matching documents; false to only modify one of the matching documents (the default). */ + multi?: boolean | undefined; + }, + callback?: Function, + ): { + numberAffected?: number | undefined; + insertedId?: string | undefined; + }; + /** + * Modify one or more documents in the collection, or insert one if no matching documents were found. Returns an object with keys `numberAffected` (the number of documents modified) and + * `insertedId` (the unique _id of the document that was inserted, if any). + * @param selector Specifies which documents to modify + * @param modifier Specifies how to modify the documents + * @param callback If present, called with an error object as the first argument and, if no error, the number of affected documents as the second. + */ + upsertAsync( + selector: Selector | ObjectID | string, + modifier: Modifier, + options?: { + /** True to modify all matching documents; false to only modify one of the matching documents (the default). */ + multi?: boolean | undefined; + }, + callback?: Function, + ): Promise<{ + numberAffected?: number | undefined; + insertedId?: string | undefined; + }>; + _createCappedCollection(byteSize?: number, maxDocuments?: number): void; + /** @deprecated */ + _ensureIndex(indexSpec: IndexSpecification, options?: CreateIndexesOptions): void; + _dropCollection(): Promise; + _dropIndex(indexName: string): void; + } + + var Cursor: CursorStatic; + interface CursorStatic { + /** + * To create a cursor, use find. To access the documents in a cursor, use forEach, map, or fetch. + */ + new (): Cursor; + } + interface ObserveCallbacks { + added?(document: T): void; + addedAt?(document: T, atIndex: number, before: T | null): void; + changed?(newDocument: T, oldDocument: T): void; + changedAt?(newDocument: T, oldDocument: T, indexAt: number): void; + removed?(oldDocument: T): void; + removedAt?(oldDocument: T, atIndex: number): void; + movedTo?(document: T, fromIndex: number, toIndex: number, before: T | null): void; + } + interface ObserveChangesCallbacks { + added?(id: string, fields: Partial): void; + addedBefore?(id: string, fields: Partial, before: T | null): void; + changed?(id: string, fields: Partial): void; + movedBefore?(id: string, before: T | null): void; + removed?(id: string): void; + } + interface Cursor { + /** + * Returns the number of documents that match a query. + * @param applySkipLimit If set to `false`, the value returned will reflect the total number of matching documents, ignoring any value supplied for limit. (Default: true) + */ + count(applySkipLimit?: boolean): number; + /** + * Returns the number of documents that match a query. + * @param applySkipLimit If set to `false`, the value returned will reflect the total number of matching documents, ignoring any value supplied for limit. (Default: true) + */ + countAsync(applySkipLimit?: boolean): Promise; + /** + * Return all matching documents as an Array. + */ + fetch(): Array; + /** + * Return all matching documents as an Array. + */ + fetchAsync(): Promise>; + /** + * Call `callback` once for each matching document, sequentially and + * synchronously. + * @param callback Function to call. It will be called with three arguments: the document, a 0-based index, and cursor itself. + * @param thisArg An object which will be the value of `this` inside `callback`. + */ + forEach(callback: (doc: U, index: number, cursor: Cursor) => void, thisArg?: any): void; + /** + * Call `callback` once for each matching document, sequentially and + * synchronously. + * @param callback Function to call. It will be called with three arguments: the document, a 0-based index, and cursor itself. + * @param thisArg An object which will be the value of `this` inside `callback`. + */ + forEachAsync(callback: (doc: U, index: number, cursor: Cursor) => void, thisArg?: any): Promise; + /** + * Map callback over all matching documents. Returns an Array. + * @param callback Function to call. It will be called with three arguments: the document, a 0-based index, and cursor itself. + * @param thisArg An object which will be the value of `this` inside `callback`. + */ + map(callback: (doc: U, index: number, cursor: Cursor) => M, thisArg?: any): Array; + /** + * Map callback over all matching documents. Returns an Array. + * @param callback Function to call. It will be called with three arguments: the document, a 0-based index, and cursor itself. + * @param thisArg An object which will be the value of `this` inside `callback`. + */ + mapAsync(callback: (doc: U, index: number, cursor: Cursor) => M, thisArg?: any): Promise>; + /** + * Watch a query. Receive callbacks as the result set changes. + * @param callbacks Functions to call to deliver the result set as it changes + */ + observe(callbacks: ObserveCallbacks): Meteor.LiveQueryHandle; + /** + * Watch a query. Receive callbacks as the result set changes. Only the differences between the old and new documents are passed to the callbacks. + * @param callbacks Functions to call to deliver the result set as it changes + */ + observeChanges( + callbacks: ObserveChangesCallbacks, + options?: { nonMutatingCallbacks?: boolean | undefined }, + ): Meteor.LiveQueryHandle; + [Symbol.iterator](): Iterator; + [Symbol.asyncIterator](): AsyncIterator; + } + + var ObjectID: ObjectIDStatic; + interface ObjectIDStatic { + /** + * Create a Mongo-style `ObjectID`. If you don't specify a `hexString`, the `ObjectID` will generated randomly (not using MongoDB's ID construction rules). + + * @param hexString The 24-character hexadecimal contents of the ObjectID to create + */ + new (hexString?: string): ObjectID; + } + interface ObjectID { + toHexString(): string; + equals(otherID: ObjectID): boolean; + } + + function setConnectionOptions(options: any): void; + + + interface AllowDenyOptions { + insert?: ((userId: string, doc: any) => boolean) | undefined; + update?: ((userId: string, doc: any, fieldNames: string[], modifier: any) => boolean) | undefined; + remove?: ((userId: string, doc: any) => boolean) | undefined; + fetch?: string[] | undefined; + transform?: Function | null | undefined; + } + } + + +// declare module MongoInternals { +// interface MongoConnection { +// db: MongoDb; +// client: MongoClient; +// } + +// function defaultRemoteCollectionDriver(): { +// mongo: MongoConnection; +// }; + +// var NpmModules: { +// mongodb: { +// version: string, +// module: typeof MongoNpmModule +// } +// }; +// } diff --git a/packages/webui/src/meteor/mongo/index.js b/packages/webui/src/meteor/mongo/index.js new file mode 100644 index 0000000000..da1fca05d1 --- /dev/null +++ b/packages/webui/src/meteor/mongo/index.js @@ -0,0 +1,803 @@ +// options.connection, if given, is a LivedataClient or LivedataServer +// XXX presently there is no way to destroy/clean up a Collection + +import { normalizeProjection } from "./mongo_utils"; +import { Meteor } from '../meteor' +import { LocalCollection } from "../minimongo"; +import { Random } from '../random' +import { MongoID } from '../mongo-id' +import EJSON from 'ejson' +import { check, Match } from '../check' +import { DDP } from '../ddp' +import { AllowDeny } from '../allow-deny' +import { LocalCollectionDriver } from './local_collection_driver.js' + +/** + * @summary Namespace for MongoDB-related items + * @namespace + */ +const Mongo = {}; + +LocalCollection.Mongo = Mongo; + +/** + * @summary Constructor for a Collection + * @locus Anywhere + * @instancename collection + * @class + * @param {String} name The name of the collection. If null, creates an unmanaged (unsynchronized) local collection. + * @param {Object} [options] + * @param {Object} options.connection The server connection that will manage this collection. Uses the default connection if not specified. Pass the return value of calling [`DDP.connect`](#ddp_connect) to specify a different server. Pass `null` to specify no connection. Unmanaged (`name` is null) collections cannot specify a connection. + * @param {String} options.idGeneration The method of generating the `_id` fields of new documents in this collection. Possible values: + + - **`'STRING'`**: random strings + - **`'MONGO'`**: random [`Mongo.ObjectID`](#mongo_object_id) values + +The default id generation technique is `'STRING'`. + * @param {Function} options.transform An optional transformation function. Documents will be passed through this function before being returned from `fetch` or `findOne`, and before being passed to callbacks of `observe`, `map`, `forEach`, `allow`, and `deny`. Transforms are *not* applied for the callbacks of `observeChanges` or to cursors returned from publish functions. + * @param {Boolean} options.defineMutationMethods Set to `false` to skip setting up the mutation methods that enable insert/update/remove from client code. Default `true`. + */ +Mongo.Collection = function Collection(name, options) { + if (!name && name !== null) { + Meteor._debug( + 'Warning: creating anonymous collection. It will not be ' + + 'saved or synchronized over the network. (Pass null for ' + + 'the collection name to turn off this warning.)' + ); + name = null; + } + + if (name !== null && typeof name !== 'string') { + throw new Error( + 'First argument to new Mongo.Collection must be a string or null' + ); + } + + if (options && options.methods) { + // Backwards compatibility hack with original signature (which passed + // "connection" directly instead of in options. (Connections must have a "methods" + // method.) + // XXX remove before 1.0 + options = { connection: options }; + } + // Backwards compatibility: "connection" used to be called "manager". + if (options && options.manager && !options.connection) { + options.connection = options.manager; + } + + options = { + connection: undefined, + idGeneration: 'STRING', + transform: null, + _driver: undefined, + _preventAutopublish: false, + ...options, + }; + + switch (options.idGeneration) { + case 'MONGO': + this._makeNewID = function() { + var src = name + ? DDP.randomStream('/collection/' + name) + : Random.insecure; + return new Mongo.ObjectID(src.hexString(24)); + }; + break; + case 'STRING': + default: + this._makeNewID = function() { + var src = name + ? DDP.randomStream('/collection/' + name) + : Random.insecure; + return src.id(); + }; + break; + } + + this._transform = LocalCollection.wrapTransform(options.transform); + + if (!name || options.connection === null) + // note: nameless collections never have a connection + this._connection = null; + else if (options.connection) this._connection = options.connection; + else if (Meteor.isClient) this._connection = Meteor.connection; + else this._connection = Meteor.server; + + if (!options._driver) { + // XXX This check assumes that webapp is loaded so that Meteor.server !== + // null. We should fully support the case of "want to use a Mongo-backed + // collection from Node code without webapp", but we don't yet. + // #MeteorServerNull + // if ( + // name && + // this._connection === Meteor.server && + // typeof MongoInternals !== 'undefined' && + // MongoInternals.defaultRemoteCollectionDriver + // ) { + // options._driver = MongoInternals.defaultRemoteCollectionDriver(); + // } else { + options._driver = LocalCollectionDriver; + // } + } + + this._collection = options._driver.open(name, this._connection); + this._name = name; + this._driver = options._driver; + + this._maybeSetUpReplication(name, options); + + // XXX don't define these until allow or deny is actually used for this + // collection. Could be hard if the security rules are only defined on the + // server. + if (options.defineMutationMethods !== false) { + try { + this._defineMutationMethods({ + useExisting: options._suppressSameNameError === true, + }); + } catch (error) { + // Throw a more understandable error on the server for same collection name + if ( + error.message === `A method named '/${name}/insert' is already defined` + ) + throw new Error(`There is already a collection named "${name}"`); + throw error; + } + } + + // // autopublish + // if ( + // Package.autopublish && + // !options._preventAutopublish && + // this._connection && + // this._connection.publish + // ) { + // this._connection.publish(null, () => this.find(), { + // is_auto: true, + // }); + // } +}; + +Object.assign(Mongo.Collection.prototype, { + _maybeSetUpReplication(name, { _suppressSameNameError = false }) { + const self = this; + if (!(self._connection && self._connection.registerStore)) { + return; + } + + // OK, we're going to be a slave, replicating some remote + // database, except possibly with some temporary divergence while + // we have unacknowledged RPC's. + const ok = self._connection.registerStore(name, { + // Called at the beginning of a batch of updates. batchSize is the number + // of update calls to expect. + // + // XXX This interface is pretty janky. reset probably ought to go back to + // being its own function, and callers shouldn't have to calculate + // batchSize. The optimization of not calling pause/remove should be + // delayed until later: the first call to update() should buffer its + // message, and then we can either directly apply it at endUpdate time if + // it was the only update, or do pauseObservers/apply/apply at the next + // update() if there's another one. + beginUpdate(batchSize, reset) { + // pause observers so users don't see flicker when updating several + // objects at once (including the post-reconnect reset-and-reapply + // stage), and so that a re-sorting of a query can take advantage of the + // full _diffQuery moved calculation instead of applying change one at a + // time. + if (batchSize > 1 || reset) self._collection.pauseObservers(); + + if (reset) self._collection.remove({}); + }, + + // Apply an update. + // XXX better specify this interface (not in terms of a wire message)? + update(msg) { + var mongoId = MongoID.idParse(msg.id); + var doc = self._collection._docs.get(mongoId); + + //When the server's mergebox is disabled for a collection, the client must gracefully handle it when: + // *We receive an added message for a document that is already there. Instead, it will be changed + // *We reeive a change message for a document that is not there. Instead, it will be added + // *We receive a removed messsage for a document that is not there. Instead, noting wil happen. + + //Code is derived from client-side code originally in peerlibrary:control-mergebox + //https://github.com/peerlibrary/meteor-control-mergebox/blob/master/client.coffee + + //For more information, refer to discussion "Initial support for publication strategies in livedata server": + //https://github.com/meteor/meteor/pull/11151 + if (Meteor.isClient) { + if (msg.msg === 'added' && doc) { + msg.msg = 'changed'; + } else if (msg.msg === 'removed' && !doc) { + return; + } else if (msg.msg === 'changed' && !doc) { + msg.msg = 'added'; + let _ref = msg.fields; + for (let field in _ref) { + let value = _ref[field]; + if (value === void 0) { + delete msg.fields[field]; + } + } + } + } + + // Is this a "replace the whole doc" message coming from the quiescence + // of method writes to an object? (Note that 'undefined' is a valid + // value meaning "remove it".) + if (msg.msg === 'replace') { + var replace = msg.replace; + if (!replace) { + if (doc) self._collection.remove(mongoId); + } else if (!doc) { + self._collection.insert(replace); + } else { + // XXX check that replace has no $ ops + self._collection.update(mongoId, replace); + } + return; + } else if (msg.msg === 'added') { + if (doc) { + throw new Error( + 'Expected not to find a document already present for an add' + ); + } + self._collection.insert({ _id: mongoId, ...msg.fields }); + } else if (msg.msg === 'removed') { + if (!doc) + throw new Error( + 'Expected to find a document already present for removed' + ); + self._collection.remove(mongoId); + } else if (msg.msg === 'changed') { + if (!doc) throw new Error('Expected to find a document to change'); + const keys = Object.keys(msg.fields); + if (keys.length > 0) { + var modifier = {}; + keys.forEach(key => { + const value = msg.fields[key]; + if (EJSON.equals(doc[key], value)) { + return; + } + if (typeof value === 'undefined') { + if (!modifier.$unset) { + modifier.$unset = {}; + } + modifier.$unset[key] = 1; + } else { + if (!modifier.$set) { + modifier.$set = {}; + } + modifier.$set[key] = value; + } + }); + if (Object.keys(modifier).length > 0) { + self._collection.update(mongoId, modifier); + } + } + } else { + throw new Error("I don't know how to deal with this message"); + } + }, + + // Called at the end of a batch of updates. + endUpdate() { + self._collection.resumeObservers(); + }, + + // Called around method stub invocations to capture the original versions + // of modified documents. + saveOriginals() { + self._collection.saveOriginals(); + }, + retrieveOriginals() { + return self._collection.retrieveOriginals(); + }, + + // Used to preserve current versions of documents across a store reset. + getDoc(id) { + return self.findOne(id); + }, + + // To be able to get back to the collection from the store. + _getCollection() { + return self; + }, + }); + + if (!ok) { + const message = `There is already a collection named "${name}"`; + if (_suppressSameNameError === true) { + // XXX In theory we do not have to throw when `ok` is falsy. The + // store is already defined for this collection name, but this + // will simply be another reference to it and everything should + // work. However, we have historically thrown an error here, so + // for now we will skip the error only when _suppressSameNameError + // is `true`, allowing people to opt in and give this some real + // world testing. + console.warn ? console.warn(message) : console.log(message); + } else { + throw new Error(message); + } + } + }, + + /// + /// Main collection API + /// + + _getFindSelector(args) { + if (args.length == 0) return {}; + else return args[0]; + }, + + _getFindOptions(args) { + const [, options] = args || []; + const newOptions = normalizeProjection(options); + + var self = this; + if (args.length < 2) { + return { transform: self._transform }; + } else { + check( + newOptions, + Match.Optional( + Match.ObjectIncluding({ + projection: Match.Optional(Match.OneOf(Object, undefined)), + sort: Match.Optional( + Match.OneOf(Object, Array, Function, undefined) + ), + limit: Match.Optional(Match.OneOf(Number, undefined)), + skip: Match.Optional(Match.OneOf(Number, undefined)), + }) + ) + ); + + + return { + transform: self._transform, + ...newOptions, + }; + } + }, + + /** + * @summary Find the documents in a collection that match the selector. + * @locus Anywhere + * @method find + * @memberof Mongo.Collection + * @instance + * @param {MongoSelector} [selector] A query describing the documents to find + * @param {Object} [options] + * @param {MongoSortSpecifier} options.sort Sort order (default: natural order) + * @param {Number} options.skip Number of results to skip at the beginning + * @param {Number} options.limit Maximum number of results to return + * @param {MongoFieldSpecifier} options.fields Dictionary of fields to return or exclude. + * @param {Boolean} options.reactive (Client only) Default `true`; pass `false` to disable reactivity + * @param {Function} options.transform Overrides `transform` on the [`Collection`](#collections) for this cursor. Pass `null` to disable transformation. + * @param {Boolean} options.disableOplog (Server only) Pass true to disable oplog-tailing on this query. This affects the way server processes calls to `observe` on this query. Disabling the oplog can be useful when working with data that updates in large batches. + * @param {Number} options.pollingIntervalMs (Server only) When oplog is disabled (through the use of `disableOplog` or when otherwise not available), the frequency (in milliseconds) of how often to poll this query when observing on the server. Defaults to 10000ms (10 seconds). + * @param {Number} options.pollingThrottleMs (Server only) When oplog is disabled (through the use of `disableOplog` or when otherwise not available), the minimum time (in milliseconds) to allow between re-polling when observing on the server. Increasing this will save CPU and mongo load at the expense of slower updates to users. Decreasing this is not recommended. Defaults to 50ms. + * @param {Number} options.maxTimeMs (Server only) If set, instructs MongoDB to set a time limit for this cursor's operations. If the operation reaches the specified time limit (in milliseconds) without the having been completed, an exception will be thrown. Useful to prevent an (accidental or malicious) unoptimized query from causing a full collection scan that would disrupt other database users, at the expense of needing to handle the resulting error. + * @param {String|Object} options.hint (Server only) Overrides MongoDB's default index selection and query optimization process. Specify an index to force its use, either by its name or index specification. You can also specify `{ $natural : 1 }` to force a forwards collection scan, or `{ $natural : -1 }` for a reverse collection scan. Setting this is only recommended for advanced users. + * @param {String} options.readPreference (Server only) Specifies a custom MongoDB [`readPreference`](https://docs.mongodb.com/manual/core/read-preference) for this particular cursor. Possible values are `primary`, `primaryPreferred`, `secondary`, `secondaryPreferred` and `nearest`. + * @returns {Mongo.Cursor} + */ + find(...args) { + // Collection.find() (return all docs) behaves differently + // from Collection.find(undefined) (return 0 docs). so be + // careful about the length of arguments. + return this._collection.find( + this._getFindSelector(args), + this._getFindOptions(args) + ); + }, + + /** + * @summary Finds the first document that matches the selector, as ordered by sort and skip options. Returns `undefined` if no matching document is found. + * @locus Anywhere + * @method findOne + * @memberof Mongo.Collection + * @instance + * @param {MongoSelector} [selector] A query describing the documents to find + * @param {Object} [options] + * @param {MongoSortSpecifier} options.sort Sort order (default: natural order) + * @param {Number} options.skip Number of results to skip at the beginning + * @param {MongoFieldSpecifier} options.fields Dictionary of fields to return or exclude. + * @param {Boolean} options.reactive (Client only) Default true; pass false to disable reactivity + * @param {Function} options.transform Overrides `transform` on the [`Collection`](#collections) for this cursor. Pass `null` to disable transformation. + * @param {String} options.readPreference (Server only) Specifies a custom MongoDB [`readPreference`](https://docs.mongodb.com/manual/core/read-preference) for fetching the document. Possible values are `primary`, `primaryPreferred`, `secondary`, `secondaryPreferred` and `nearest`. + * @returns {Object} + */ + findOne(...args) { + return this._collection.findOne( + this._getFindSelector(args), + this._getFindOptions(args) + ); + }, +}); + +Object.assign(Mongo.Collection, { + _publishCursor(cursor, sub, collection) { + var observeHandle = cursor.observeChanges( + { + added: function(id, fields) { + sub.added(collection, id, fields); + }, + changed: function(id, fields) { + sub.changed(collection, id, fields); + }, + removed: function(id) { + sub.removed(collection, id); + }, + }, + // Publications don't mutate the documents + // This is tested by the `livedata - publish callbacks clone` test + { nonMutatingCallbacks: true } + ); + + // We don't call sub.ready() here: it gets called in livedata_server, after + // possibly calling _publishCursor on multiple returned cursors. + + // register stop callback (expects lambda w/ no args). + sub.onStop(function() { + observeHandle.stop(); + }); + + // return the observeHandle in case it needs to be stopped early + return observeHandle; + }, + + // protect against dangerous selectors. falsey and {_id: falsey} are both + // likely programmer error, and not what you want, particularly for destructive + // operations. If a falsey _id is sent in, a new string _id will be + // generated and returned; if a fallbackId is provided, it will be returned + // instead. + _rewriteSelector(selector, { fallbackId } = {}) { + // shorthand -- scalars match _id + if (LocalCollection._selectorIsId(selector)) selector = { _id: selector }; + + if (Array.isArray(selector)) { + // This is consistent with the Mongo console itself; if we don't do this + // check passing an empty array ends up selecting all items + throw new Error("Mongo selector can't be an array."); + } + + if (!selector || ('_id' in selector && !selector._id)) { + // can't match anything + return { _id: fallbackId || Random.id() }; + } + + return selector; + }, +}); + +Object.assign(Mongo.Collection.prototype, { + // 'insert' immediately returns the inserted document's new _id. + // The others return values immediately if you are in a stub, an in-memory + // unmanaged collection, or a mongo-backed collection and you don't pass a + // callback. 'update' and 'remove' return the number of affected + // documents. 'upsert' returns an object with keys 'numberAffected' and, if an + // insert happened, 'insertedId'. + // + // Otherwise, the semantics are exactly like other methods: they take + // a callback as an optional last argument; if no callback is + // provided, they block until the operation is complete, and throw an + // exception if it fails; if a callback is provided, then they don't + // necessarily block, and they call the callback when they finish with error and + // result arguments. (The insert method provides the document ID as its result; + // update and remove provide the number of affected docs as the result; upsert + // provides an object with numberAffected and maybe insertedId.) + // + // On the client, blocking is impossible, so if a callback + // isn't provided, they just return immediately and any error + // information is lost. + // + // There's one more tweak. On the client, if you don't provide a + // callback, then if there is an error, a message will be logged with + // Meteor._debug. + // + // The intent (though this is actually determined by the underlying + // drivers) is that the operations should be done synchronously, not + // generating their result until the database has acknowledged + // them. In the future maybe we should provide a flag to turn this + // off. + + /** + * @summary Insert a document in the collection. Returns its unique _id. + * @locus Anywhere + * @method insert + * @memberof Mongo.Collection + * @instance + * @param {Object} doc The document to insert. May not yet have an _id attribute, in which case Meteor will generate one for you. + * @param {Function} [callback] Optional. If present, called with an error object as the first argument and, if no error, the _id as the second. + */ + insert(doc, callback) { + // Make sure we were passed a document to insert + if (!doc) { + throw new Error('insert requires an argument'); + } + + // Make a shallow clone of the document, preserving its prototype. + doc = Object.create( + Object.getPrototypeOf(doc), + Object.getOwnPropertyDescriptors(doc) + ); + + if ('_id' in doc) { + if ( + !doc._id || + !(typeof doc._id === 'string' || doc._id instanceof Mongo.ObjectID) + ) { + throw new Error( + 'Meteor requires document _id fields to be non-empty strings or ObjectIDs' + ); + } + } else { + let generateId = true; + + // Don't generate the id if we're the client and the 'outermost' call + // This optimization saves us passing both the randomSeed and the id + // Passing both is redundant. + if (this._isRemoteCollection()) { + const enclosing = DDP._CurrentMethodInvocation.get(); + if (!enclosing) { + generateId = false; + } + } + + if (generateId) { + doc._id = this._makeNewID(); + } + } + + // On inserts, always return the id that we generated; on all other + // operations, just return the result from the collection. + var chooseReturnValueFromCollectionResult = function(result) { + if (doc._id) { + return doc._id; + } + + // XXX what is this for?? + // It's some iteraction between the callback to _callMutatorMethod and + // the return value conversion + doc._id = result; + + return result; + }; + + const wrappedCallback = wrapCallback( + callback, + chooseReturnValueFromCollectionResult + ); + + if (this._isRemoteCollection()) { + const result = this._callMutatorMethod('insert', [doc], wrappedCallback); + return chooseReturnValueFromCollectionResult(result); + } + + // it's my collection. descend into the collection object + // and propagate any exception. + try { + // If the user provided a callback and the collection implements this + // operation asynchronously, then queryRet will be undefined, and the + // result will be returned through the callback instead. + const result = this._collection.insert(doc, wrappedCallback); + return chooseReturnValueFromCollectionResult(result); + } catch (e) { + if (callback) { + callback(e); + return null; + } + throw e; + } + }, + + /** + * @summary Modify one or more documents in the collection. Returns the number of matched documents. + * @locus Anywhere + * @method update + * @memberof Mongo.Collection + * @instance + * @param {MongoSelector} selector Specifies which documents to modify + * @param {MongoModifier} modifier Specifies how to modify the documents + * @param {Object} [options] + * @param {Boolean} options.multi True to modify all matching documents; false to only modify one of the matching documents (the default). + * @param {Boolean} options.upsert True to insert a document if no matching documents are found. + * @param {Array} options.arrayFilters Optional. Used in combination with MongoDB [filtered positional operator](https://docs.mongodb.com/manual/reference/operator/update/positional-filtered/) to specify which elements to modify in an array field. + * @param {Function} [callback] Optional. If present, called with an error object as the first argument and, if no error, the number of affected documents as the second. + */ + update(selector, modifier, ...optionsAndCallback) { + const callback = popCallbackFromArgs(optionsAndCallback); + + // We've already popped off the callback, so we are left with an array + // of one or zero items + const options = { ...(optionsAndCallback[0] || null) }; + let insertedId; + if (options && options.upsert) { + // set `insertedId` if absent. `insertedId` is a Meteor extension. + if (options.insertedId) { + if ( + !( + typeof options.insertedId === 'string' || + options.insertedId instanceof Mongo.ObjectID + ) + ) + throw new Error('insertedId must be string or ObjectID'); + insertedId = options.insertedId; + } else if (!selector || !selector._id) { + insertedId = this._makeNewID(); + options.generatedId = true; + options.insertedId = insertedId; + } + } + + selector = Mongo.Collection._rewriteSelector(selector, { + fallbackId: insertedId, + }); + + const wrappedCallback = wrapCallback(callback); + + if (this._isRemoteCollection()) { + const args = [selector, modifier, options]; + + return this._callMutatorMethod('update', args, wrappedCallback); + } + + // it's my collection. descend into the collection object + // and propagate any exception. + try { + // If the user provided a callback and the collection implements this + // operation asynchronously, then queryRet will be undefined, and the + // result will be returned through the callback instead. + return this._collection.update( + selector, + modifier, + options, + wrappedCallback + ); + } catch (e) { + if (callback) { + callback(e); + return null; + } + throw e; + } + }, + + /** + * @summary Remove documents from the collection + * @locus Anywhere + * @method remove + * @memberof Mongo.Collection + * @instance + * @param {MongoSelector} selector Specifies which documents to remove + * @param {Function} [callback] Optional. If present, called with an error object as its argument. + */ + remove(selector, callback) { + selector = Mongo.Collection._rewriteSelector(selector); + + const wrappedCallback = wrapCallback(callback); + + if (this._isRemoteCollection()) { + return this._callMutatorMethod('remove', [selector], wrappedCallback); + } + + // it's my collection. descend into the collection object + // and propagate any exception. + try { + // If the user provided a callback and the collection implements this + // operation asynchronously, then queryRet will be undefined, and the + // result will be returned through the callback instead. + return this._collection.remove(selector, wrappedCallback); + } catch (e) { + if (callback) { + callback(e); + return null; + } + throw e; + } + }, + + // Determine if this collection is simply a minimongo representation of a real + // database on another server + _isRemoteCollection() { + // XXX see #MeteorServerNull + return this._connection && this._connection !== Meteor.server; + }, + + /** + * @summary Modify one or more documents in the collection, or insert one if no matching documents were found. Returns an object with keys `numberAffected` (the number of documents modified) and `insertedId` (the unique _id of the document that was inserted, if any). + * @locus Anywhere + * @method upsert + * @memberof Mongo.Collection + * @instance + * @param {MongoSelector} selector Specifies which documents to modify + * @param {MongoModifier} modifier Specifies how to modify the documents + * @param {Object} [options] + * @param {Boolean} options.multi True to modify all matching documents; false to only modify one of the matching documents (the default). + * @param {Function} [callback] Optional. If present, called with an error object as the first argument and, if no error, the number of affected documents as the second. + */ + upsert(selector, modifier, options, callback) { + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + return this.update( + selector, + modifier, + { + ...options, + _returnObject: true, + upsert: true, + }, + callback + ); + }, + + +}); + +// Convert the callback to not return a result if there is an error +function wrapCallback(callback, convertResult) { + return ( + callback && + function(error, result) { + if (error) { + callback(error); + } else if (typeof convertResult === 'function') { + callback(error, convertResult(result)); + } else { + callback(error, result); + } + } + ); +} + +/** + * @summary Create a Mongo-style `ObjectID`. If you don't specify a `hexString`, the `ObjectID` will generated randomly (not using MongoDB's ID construction rules). + * @locus Anywhere + * @class + * @param {String} [hexString] Optional. The 24-character hexadecimal contents of the ObjectID to create + */ +Mongo.ObjectID = MongoID.ObjectID; + +/** + * @summary To create a cursor, use find. To access the documents in a cursor, use forEach, map, or fetch. + * @class + * @instanceName cursor + */ +Mongo.Cursor = LocalCollection.Cursor; + +/** + * @deprecated in 0.9.1 + */ +Mongo.Collection.Cursor = Mongo.Cursor; + +/** + * @deprecated in 0.9.1 + */ +Mongo.Collection.ObjectID = Mongo.ObjectID; + +/** + * @deprecated in 0.9.1 + */ +Meteor.Collection = Mongo.Collection; + +// Allow deny stuff is now in the allow-deny package +Object.assign(Meteor.Collection.prototype, AllowDeny.CollectionPrototype); + +function popCallbackFromArgs(args) { + // Pull off any callback (or perhaps a 'callback' variable that was passed + // in undefined, like how 'upsert' does it). + if ( + args.length && + (args[args.length - 1] === undefined || + args[args.length - 1] instanceof Function) + ) { + return args.pop(); + } +} + +export { Mongo } \ No newline at end of file diff --git a/packages/webui/src/meteor/mongo/local_collection_driver.js b/packages/webui/src/meteor/mongo/local_collection_driver.js new file mode 100644 index 0000000000..497d54bfe1 --- /dev/null +++ b/packages/webui/src/meteor/mongo/local_collection_driver.js @@ -0,0 +1,33 @@ +import { LocalCollection } from '../minimongo'; + + +// singleton +export const LocalCollectionDriver = new (class LocalCollectionDriver { + constructor() { + this.noConnCollections = Object.create(null); + } + + open(name, conn) { + if (! name) { + return new LocalCollection; + } + + if (! conn) { + return ensureCollection(name, this.noConnCollections); + } + + if (! conn._mongo_livedata_collections) { + conn._mongo_livedata_collections = Object.create(null); + } + + // XXX is there a way to keep track of a connection's collections without + // dangling it off the connection object? + return ensureCollection(name, conn._mongo_livedata_collections); + } + }); + + function ensureCollection(name, collections) { + return (name in collections) + ? collections[name] + : collections[name] = new LocalCollection(name); + } \ No newline at end of file diff --git a/packages/webui/src/meteor/mongo/mongo_utils.js b/packages/webui/src/meteor/mongo/mongo_utils.js new file mode 100644 index 0000000000..d7b43c4053 --- /dev/null +++ b/packages/webui/src/meteor/mongo/mongo_utils.js @@ -0,0 +1,11 @@ +export const normalizeProjection = options => { + // transform fields key in projection + const { fields, projection, ...otherOptions } = options || {}; + // TODO: enable this comment when deprecating the fields option + // Log.debug(`fields option has been deprecated, please use the new 'projection' instead`) + + return { + ...otherOptions, + ...(projection || fields ? { projection: fields || projection } : {}), + }; + }; \ No newline at end of file diff --git a/packages/webui/src/meteor/ordered-dict.js b/packages/webui/src/meteor/ordered-dict.js new file mode 100644 index 0000000000..3e6c0065cd --- /dev/null +++ b/packages/webui/src/meteor/ordered-dict.js @@ -0,0 +1,223 @@ +// https://github.com/meteor/meteor/tree/73fd519de6eef8e116d813fb457c8442db9d1cdd/packages/ordered-dict + +// This file defines an ordered dictionary abstraction that is useful for +// maintaining a dataset backed by observeChanges. It supports ordering items +// by specifying the item they now come before. + +// The implementation is a dictionary that contains nodes of a doubly-linked +// list as its values. + +// constructs a new element struct +// next and prev are whole elements, not keys. +function element(key, value, next, prev) { + return { + key: key, + value: value, + next: next, + prev: prev + }; + } + + export class OrderedDict { + constructor(...args) { + this._dict = Object.create(null); + this._first = null; + this._last = null; + this._size = 0; + + if (typeof args[0] === 'function') { + this._stringify = args.shift(); + } else { + this._stringify = function (x) { return x; }; + } + + args.forEach(kv => this.putBefore(kv[0], kv[1], null)); + } + + // the "prefix keys with a space" thing comes from here + // https://github.com/documentcloud/underscore/issues/376#issuecomment-2815649 + _k(key) { + return " " + this._stringify(key); + } + + empty() { + return !this._first; + } + + size() { + return this._size; + } + + _linkEltIn(elt) { + if (!elt.next) { + elt.prev = this._last; + if (this._last) + this._last.next = elt; + this._last = elt; + } else { + elt.prev = elt.next.prev; + elt.next.prev = elt; + if (elt.prev) + elt.prev.next = elt; + } + if (this._first === null || this._first === elt.next) + this._first = elt; + } + + _linkEltOut(elt) { + if (elt.next) + elt.next.prev = elt.prev; + if (elt.prev) + elt.prev.next = elt.next; + if (elt === this._last) + this._last = elt.prev; + if (elt === this._first) + this._first = elt.next; + } + + putBefore(key, item, before) { + if (this._dict[this._k(key)]) + throw new Error("Item " + key + " already present in OrderedDict"); + var elt = before ? + element(key, item, this._dict[this._k(before)]) : + element(key, item, null); + if (typeof elt.next === "undefined") + throw new Error("could not find item to put this one before"); + this._linkEltIn(elt); + this._dict[this._k(key)] = elt; + this._size++; + } + + append(key, item) { + this.putBefore(key, item, null); + } + + remove(key) { + var elt = this._dict[this._k(key)]; + if (typeof elt === "undefined") + throw new Error("Item " + key + " not present in OrderedDict"); + this._linkEltOut(elt); + this._size--; + delete this._dict[this._k(key)]; + return elt.value; + } + + get(key) { + if (this.has(key)) { + return this._dict[this._k(key)].value; + } + } + + has(key) { + return Object.prototype.hasOwnProperty.call( + this._dict, + this._k(key) + ); + } + + // Iterate through the items in this dictionary in order, calling + // iter(value, key, index) on each one. + + // Stops whenever iter returns OrderedDict.BREAK, or after the last element. + forEach(iter, context = null) { + var i = 0; + var elt = this._first; + while (elt !== null) { + var b = iter.call(context, elt.value, elt.key, i); + if (b === OrderedDict.BREAK) return; + elt = elt.next; + i++; + } + } + + first() { + if (this.empty()) { + return; + } + return this._first.key; + } + + firstValue() { + if (this.empty()) { + return; + } + return this._first.value; + } + + last() { + if (this.empty()) { + return; + } + return this._last.key; + } + + lastValue() { + if (this.empty()) { + return; + } + return this._last.value; + } + + prev(key) { + if (this.has(key)) { + var elt = this._dict[this._k(key)]; + if (elt.prev) + return elt.prev.key; + } + return null; + } + + next(key) { + if (this.has(key)) { + var elt = this._dict[this._k(key)]; + if (elt.next) + return elt.next.key; + } + return null; + } + + moveBefore(key, before) { + var elt = this._dict[this._k(key)]; + var eltBefore = before ? this._dict[this._k(before)] : null; + if (typeof elt === "undefined") { + throw new Error("Item to move is not present"); + } + if (typeof eltBefore === "undefined") { + throw new Error("Could not find element to move this one before"); + } + if (eltBefore === elt.next) // no moving necessary + return; + // remove from its old place + this._linkEltOut(elt); + // patch into its new place + elt.next = eltBefore; + this._linkEltIn(elt); + } + + // Linear, sadly. + indexOf(key) { + var ret = null; + this.forEach((v, k, i) => { + if (this._k(k) === this._k(key)) { + ret = i; + return OrderedDict.BREAK; + } + return; + }); + return ret; + } + + _checkRep() { + Object.keys(this._dict).forEach(k => { + const v = this._dict[k]; + if (v.next === v) { + throw new Error("Next is a loop"); + } + if (v.prev === v) { + throw new Error("Prev is a loop"); + } + }); + } + } + + OrderedDict.BREAK = {"break": true}; \ No newline at end of file diff --git a/packages/webui/src/meteor/random/AbstractRandomGenerator.js b/packages/webui/src/meteor/random/AbstractRandomGenerator.js new file mode 100644 index 0000000000..46b2c992d7 --- /dev/null +++ b/packages/webui/src/meteor/random/AbstractRandomGenerator.js @@ -0,0 +1,99 @@ +// We use cryptographically strong PRNGs (crypto.getRandomBytes() on the server, +// window.crypto.getRandomValues() in the browser) when available. If these +// PRNGs fail, we fall back to the Alea PRNG, which is not cryptographically +// strong, and we seed it with various sources such as the date, Math.random, +// and window size on the client. When using crypto.getRandomValues(), our +// primitive is hexString(), from which we construct fraction(). When using +// window.crypto.getRandomValues() or alea, the primitive is fraction and we use +// that to construct hex string. + +const UNMISTAKABLE_CHARS = '23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz'; +const BASE64_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + + '0123456789-_'; + +// `type` is one of `RandomGenerator.Type` as defined below. +// +// options: +// - seeds: (required, only for RandomGenerator.Type.ALEA) an array +// whose items will be `toString`ed and used as the seed to the Alea +// algorithm +export default class RandomGenerator { + + /** + * @name Random.fraction + * @summary Return a number between 0 and 1, like `Math.random`. + * @locus Anywhere + */ + fraction () { + throw new Error(`Unknown random generator type`); + } + + /** + * @name Random.hexString + * @summary Return a random string of `n` hexadecimal digits. + * @locus Anywhere + * @param {Number} n Length of the string + */ + hexString (digits) { + return this._randomString(digits, '0123456789abcdef'); + } + + _randomString (charsCount, alphabet) { + let result = ''; + for (let i = 0; i < charsCount; i++) { + result += this.choice(alphabet); + } + return result; + } + + /** + * @name Random.id + * @summary Return a unique identifier, such as `"Jjwjg6gouWLXhMGKW"`, that is + * likely to be unique in the whole world. + * @locus Anywhere + * @param {Number} [n] Optional length of the identifier in characters + * (defaults to 17) + */ + id (charsCount) { + // 17 characters is around 96 bits of entropy, which is the amount of + // state in the Alea PRNG. + if (charsCount === undefined) { + charsCount = 17; + } + + return this._randomString(charsCount, UNMISTAKABLE_CHARS); + } + + /** + * @name Random.secret + * @summary Return a random string of printable characters with 6 bits of + * entropy per character. Use `Random.secret` for security-critical secrets + * that are intended for machine, rather than human, consumption. + * @locus Anywhere + * @param {Number} [n] Optional length of the secret string (defaults to 43 + * characters, or 256 bits of entropy) + */ + secret (charsCount) { + // Default to 256 bits of entropy, or 43 characters at 6 bits per + // character. + if (charsCount === undefined) { + charsCount = 43; + } + + return this._randomString(charsCount, BASE64_CHARS); + } + + /** + * @name Random.choice + * @summary Return a random element of the given array or string. + * @locus Anywhere + * @param {Array|String} arrayOrString Array or string to choose from + */ + choice (arrayOrString) { + const index = Math.floor(this.fraction() * arrayOrString.length); + if (typeof arrayOrString === 'string') { + return arrayOrString.substr(index, 1); + } + return arrayOrString[index]; + } +} \ No newline at end of file diff --git a/packages/webui/src/meteor/random/AleaRandomGenerator.js b/packages/webui/src/meteor/random/AleaRandomGenerator.js new file mode 100644 index 0000000000..76b48607b6 --- /dev/null +++ b/packages/webui/src/meteor/random/AleaRandomGenerator.js @@ -0,0 +1,94 @@ +import RandomGenerator from './AbstractRandomGenerator'; + +// Alea PRNG, which is not cryptographically strong +// see http://baagoe.org/en/wiki/Better_random_numbers_for_javascript +// for a full discussion and Alea implementation. +function Alea(seeds) { + function Mash() { + let n = 0xefc8249d; + + const mash = (data) => { + data = data.toString(); + for (let i = 0; i < data.length; i++) { + n += data.charCodeAt(i); + let h = 0.02519603282416938 * n; + n = h >>> 0; + h -= n; + h *= n; + n = h >>> 0; + h -= n; + n += h * 0x100000000; // 2^32 + } + return (n >>> 0) * 2.3283064365386963e-10; // 2^-32 + }; + + mash.version = 'Mash 0.9'; + return mash; + } + + let s0 = 0; + let s1 = 0; + let s2 = 0; + let c = 1; + if (seeds.length === 0) { + seeds = [+new Date()]; + } + let mash = Mash(); + s0 = mash(' '); + s1 = mash(' '); + s2 = mash(' '); + + for (let i = 0; i < seeds.length; i++) { + s0 -= mash(seeds[i]); + if (s0 < 0) { + s0 += 1; + } + s1 -= mash(seeds[i]); + if (s1 < 0) { + s1 += 1; + } + s2 -= mash(seeds[i]); + if (s2 < 0) { + s2 += 1; + } + } + mash = null; + + const random = () => { + const t = (2091639 * s0) + (c * 2.3283064365386963e-10); // 2^-32 + s0 = s1; + s1 = s2; + return s2 = t - (c = t | 0); + }; + + random.uint32 = () => random() * 0x100000000; // 2^32 + random.fract53 = () => random() + + ((random() * 0x200000 | 0) * 1.1102230246251565e-16); // 2^-53 + + random.version = 'Alea 0.9'; + random.args = seeds; + return random; +} + +// options: +// - seeds: an array +// whose items will be `toString`ed and used as the seed to the Alea +// algorithm +export default class AleaRandomGenerator extends RandomGenerator { + constructor ({ seeds = [] } = {}) { + super(); + if (!seeds) { + throw new Error('No seeds were provided for Alea PRNG'); + } + this.alea = Alea(seeds); + } + + /** + * @name Random.fraction + * @summary Return a number between 0 and 1, like `Math.random`. + * @locus Anywhere + */ + fraction () { + return this.alea(); + } +} \ No newline at end of file diff --git a/packages/webui/src/meteor/random/BrowserRandomGenerator.js b/packages/webui/src/meteor/random/BrowserRandomGenerator.js new file mode 100644 index 0000000000..54b3f10bd0 --- /dev/null +++ b/packages/webui/src/meteor/random/BrowserRandomGenerator.js @@ -0,0 +1,15 @@ +import RandomGenerator from './AbstractRandomGenerator'; + +// cryptographically strong PRNGs available in modern browsers +export default class BrowserRandomGenerator extends RandomGenerator { + /** + * @name Random.fraction + * @summary Return a number between 0 and 1, like `Math.random`. + * @locus Anywhere + */ + fraction () { + const array = new Uint32Array(1); + window.crypto.getRandomValues(array); + return array[0] * 2.3283064365386963e-10; // 2^-32 + } +} \ No newline at end of file diff --git a/packages/webui/src/meteor/random/createAleaGenerator.js b/packages/webui/src/meteor/random/createAleaGenerator.js new file mode 100644 index 0000000000..c6430226d2 --- /dev/null +++ b/packages/webui/src/meteor/random/createAleaGenerator.js @@ -0,0 +1,31 @@ +import AleaRandomGenerator from './AleaRandomGenerator'; + +// instantiate RNG. Heuristically collect entropy from various sources when a +// cryptographic PRNG isn't available. + +// client sources +const height = (typeof window !== 'undefined' && window.innerHeight) || + (typeof document !== 'undefined' + && document.documentElement + && document.documentElement.clientHeight) || + (typeof document !== 'undefined' + && document.body + && document.body.clientHeight) || + 1; + +const width = (typeof window !== 'undefined' && window.innerWidth) || + (typeof document !== 'undefined' + && document.documentElement + && document.documentElement.clientWidth) || + (typeof document !== 'undefined' + && document.body + && document.body.clientWidth) || + 1; + +const agent = (typeof navigator !== 'undefined' && navigator.userAgent) || ''; + +export default function createAleaGenerator() { + return new AleaRandomGenerator({ + seeds: [new Date(), height, width, agent, Math.random()], + }); +} \ No newline at end of file diff --git a/packages/webui/src/meteor/random/createRandom.js b/packages/webui/src/meteor/random/createRandom.js new file mode 100644 index 0000000000..5ed2fb6c0b --- /dev/null +++ b/packages/webui/src/meteor/random/createRandom.js @@ -0,0 +1,19 @@ +import AleaRandomGenerator from './AleaRandomGenerator' +import createAleaGeneratorWithGeneratedSeed from './createAleaGenerator'; + +export default function createRandom(generator) { + // Create a non-cryptographically secure PRNG with a given seed (using + // the Alea algorithm) + generator.createWithSeeds = (...seeds) => { + if (seeds.length === 0) { + throw new Error('No seeds were provided'); + } + return new AleaRandomGenerator({ seeds }); + }; + + // Used like `Random`, but much faster and not cryptographically + // secure + generator.insecure = createAleaGeneratorWithGeneratedSeed(); + + return generator; +} \ No newline at end of file diff --git a/packages/webui/src/meteor/random/index.d.ts b/packages/webui/src/meteor/random/index.d.ts new file mode 100644 index 0000000000..8016d1ff96 --- /dev/null +++ b/packages/webui/src/meteor/random/index.d.ts @@ -0,0 +1,13 @@ +export namespace Random { + function id(numberOfChars?: number): string; + + function secret(numberOfChars?: number): string; + + function fraction(): number; + // @param numberOfDigits, @returns a random hex string of the given length + function hexString(numberOfDigits: number): string; + // @param array, @return a random element in array + function choice(array: T[]): T | undefined; + // @param str, @return a random char in str + function choice(str: string): string; +} diff --git a/packages/webui/src/meteor/random/index.js b/packages/webui/src/meteor/random/index.js new file mode 100644 index 0000000000..9b4849db6f --- /dev/null +++ b/packages/webui/src/meteor/random/index.js @@ -0,0 +1,28 @@ +// https://github.com/meteor/meteor/tree/73fd519de6eef8e116d813fb457c8442db9d1cdd/packages/random + +// We use cryptographically strong PRNGs (window.crypto.getRandomValues()) +// when available. If these PRNGs fail, we fall back to the Alea PRNG, which is +// not cryptographically strong, and we seed it with various sources +// such as the date, Math.random, and window size on the client. +// When using window.crypto.getRandomValues() or alea, the primitive is fraction +// and we use that to construct hex string. + +import BrowserRandomGenerator from './BrowserRandomGenerator'; +import createAleaGeneratorWithGeneratedSeed from './createAleaGenerator'; +import createRandom from './createRandom'; + +let generator; +if (typeof window !== 'undefined' && window.crypto && + window.crypto.getRandomValues) { + generator = new BrowserRandomGenerator(); +} else { + // On IE 10 and below, there's no browser crypto API + // available. Fall back to Alea + // + // XXX looks like at the moment, we use Alea in IE 11 as well, + // which has `window.msCrypto` instead of `window.crypto`. + generator = createAleaGeneratorWithGeneratedSeed(); +} + + +export const Random = createRandom(generator); \ No newline at end of file diff --git a/packages/webui/src/meteor/reactive-var.d.ts b/packages/webui/src/meteor/reactive-var.d.ts new file mode 100644 index 0000000000..122ea35c0d --- /dev/null +++ b/packages/webui/src/meteor/reactive-var.d.ts @@ -0,0 +1,22 @@ + var ReactiveVar: ReactiveVarStatic; + interface ReactiveVarStatic { + /** + * Constructor for a ReactiveVar, which represents a single reactive variable. + * @param initialValue The initial value to set. `equalsFunc` is ignored when setting the initial value. + * @param equalsFunc A function of two arguments, called on the old value and the new value whenever the ReactiveVar is set. If it returns true, no set is performed. If omitted, the default + * `equalsFunc` returns true if its arguments are `===` and are of type number, boolean, string, undefined, or null. + */ + new (initialValue: T, equalsFunc?: (oldValue: T, newValue: T) => boolean): ReactiveVar; + } + interface ReactiveVar { + /** + * Returns the current value of the ReactiveVar, establishing a reactive dependency. + */ + get(): T; + /** + * Sets the current value of the ReactiveVar, invalidating the Computations that called `get` if `newValue` is different from the old value. + */ + set(newValue: T): void; + } + + export { ReactiveVar } \ No newline at end of file diff --git a/packages/webui/src/meteor/reactive-var.js b/packages/webui/src/meteor/reactive-var.js new file mode 100644 index 0000000000..0817f62e55 --- /dev/null +++ b/packages/webui/src/meteor/reactive-var.js @@ -0,0 +1,98 @@ +/* + * ## [new] ReactiveVar(initialValue, [equalsFunc]) + * + * A ReactiveVar holds a single value that can be get and set, + * such that calling `set` will invalidate any Computations that + * called `get`, according to the usual contract for reactive + * data sources. + * + * A ReactiveVar is much like a Session variable -- compare `foo.get()` + * to `Session.get("foo")` -- but it doesn't have a global name and isn't + * automatically migrated across hot code pushes. Also, while Session + * variables can only hold JSON or EJSON, ReactiveVars can hold any value. + * + * An important property of ReactiveVars, which is sometimes the reason + * to use one, is that setting the value to the same value as before has + * no effect, meaning ReactiveVars can be used to absorb extra + * invalidations that wouldn't serve a purpose. However, by default, + * ReactiveVars are extremely conservative about what changes they + * absorb. Calling `set` with an object argument will *always* trigger + * invalidations, because even if the new value is `===` the old value, + * the object may have been mutated. You can change the default behavior + * by passing a function of two arguments, `oldValue` and `newValue`, + * to the constructor as `equalsFunc`. + * + * This class is extremely basic right now, but the idea is to evolve + * it into the ReactiveVar of Geoff's Lickable Forms proposal. + */ + +import { Tracker } from './tracker' + +/** + * @class + * @instanceName reactiveVar + * @summary Constructor for a ReactiveVar, which represents a single reactive variable. + * @locus Client + * @param {Any} initialValue The initial value to set. `equalsFunc` is ignored when setting the initial value. + * @param {Function} [equalsFunc] Optional. A function of two arguments, called on the old value and the new value whenever the ReactiveVar is set. If it returns true, no set is performed. If omitted, the default `equalsFunc` returns true if its arguments are `===` and are of type number, boolean, string, undefined, or null. + */ +export const ReactiveVar = function (initialValue, equalsFunc) { + if (! (this instanceof ReactiveVar)) + // called without `new` + return new ReactiveVar(initialValue, equalsFunc); + + this.curValue = initialValue; + this.equalsFunc = equalsFunc; + this.dep = new Tracker.Dependency; + }; + + ReactiveVar._isEqual = function (oldValue, newValue) { + var a = oldValue, b = newValue; + // Two values are "equal" here if they are `===` and are + // number, boolean, string, undefined, or null. + if (a !== b) + return false; + else + return ((!a) || (typeof a === 'number') || (typeof a === 'boolean') || + (typeof a === 'string')); + }; + + /** + * @summary Returns the current value of the ReactiveVar, establishing a reactive dependency. + * @locus Client + */ + ReactiveVar.prototype.get = function () { + if (Tracker.active) + this.dep.depend(); + + return this.curValue; + }; + + /** + * @summary Sets the current value of the ReactiveVar, invalidating the Computations that called `get` if `newValue` is different from the old value. + * @locus Client + * @param {Any} newValue + */ + ReactiveVar.prototype.set = function (newValue) { + var oldValue = this.curValue; + + if ((this.equalsFunc || ReactiveVar._isEqual)(oldValue, newValue)) + // value is same as last time + return; + + this.curValue = newValue; + this.dep.changed(); + }; + + ReactiveVar.prototype.toString = function () { + return 'ReactiveVar{' + this.get() + '}'; + }; + + ReactiveVar.prototype._numListeners = function() { + // Tests want to know. + // Accesses a private field of Tracker.Dependency. + var count = 0; + for (var id in this.dep._dependentsById) + count++; + return count; + }; \ No newline at end of file diff --git a/packages/webui/src/meteor/reload.js b/packages/webui/src/meteor/reload.js new file mode 100644 index 0000000000..bbaf4e1c67 --- /dev/null +++ b/packages/webui/src/meteor/reload.js @@ -0,0 +1,309 @@ +/** + * This code does _NOT_ support hot (session-restoring) reloads on + * IE6,7. It only works on browsers with sessionStorage support. + * + * There are a couple approaches to add IE6,7 support: + * + * - use IE's "userData" mechanism in combination with window.name. + * This mostly works, however the problem is that it can not get to the + * data until after DOMReady. This is a problem for us since this API + * relies on the data being ready before API users run. We could + * refactor using Meteor.startup in all API users, but that might slow + * page loads as we couldn't start the stream until after DOMReady. + * Here are some resources on this approach: + * https://github.com/hugeinc/USTORE.js + * http://thudjs.tumblr.com/post/419577524/localstorage-userdata + * http://www.javascriptkit.com/javatutors/domstorage2.shtml + * + * - POST the data to the server, and have the server send it back on + * page load. This is nice because it sidesteps all the local storage + * compatibility issues, however it is kinda tricky. We can use a unique + * token in the URL, then get rid of it with HTML5 pushstate, but that + * only works on pushstate browsers. + * + * This will all need to be reworked entirely when we add server-side + * HTML rendering. In that case, the server will need to have access to + * the client's session to render properly. + */ + +// XXX when making this API public, also expose a flag for the app +// developer to know whether a hot code push is happening. This is +// useful for apps using `window.onbeforeunload`. See +// https://github.com/meteor/meteor/pull/657 + +import { Meteor } from './meteor' + +export const Reload = {}; + +const reloadSettings = + (Meteor.settings && + Meteor.settings.public && + Meteor.settings.public.packages && + Meteor.settings.public.packages.reload) || + {}; + +function debug(message, context) { + if (!reloadSettings.debug) { + return; + } + // eslint-disable-next-line no-console + console.log(`[reload] ${message}`, JSON.stringify(context)); +} + +const KEY_NAME = 'Meteor_Reload'; + +let old_data = {}; +// read in old data at startup. +let old_json; + +// This logic for sessionStorage detection is based on browserstate/history.js +let safeSessionStorage = null; +try { + // This throws a SecurityError on Chrome if cookies & localStorage are + // explicitly disabled + // + // On Firefox with dom.storage.enabled set to false, sessionStorage is null + // + // We can't even do (typeof sessionStorage) on Chrome, it throws. So we rely + // on the throw if sessionStorage == null; the alternative is browser + // detection, but this seems better. + safeSessionStorage = window.sessionStorage; + + // Check we can actually use it + if (safeSessionStorage) { + safeSessionStorage.setItem('__dummy__', '1'); + safeSessionStorage.removeItem('__dummy__'); + } else { + // Be consistently null, for safety + safeSessionStorage = null; + } +} catch (e) { + // Expected on chrome with strict security, or if sessionStorage not supported + safeSessionStorage = null; +} + +// Exported for test. +Reload._getData = function () { + return safeSessionStorage && safeSessionStorage.getItem(KEY_NAME); +}; + +if (safeSessionStorage) { + old_json = Reload._getData(); + safeSessionStorage.removeItem(KEY_NAME); +} else { + // Unsupported browser (IE 6,7) or locked down security settings. + // No session resumption. + // Meteor._debug("XXX UNSUPPORTED BROWSER/SETTINGS"); +} + +if (!old_json) old_json = '{}'; +let old_parsed = {}; +try { + old_parsed = JSON.parse(old_json); + if (typeof old_parsed !== 'object') { + Meteor._debug('Got bad data on reload. Ignoring.'); + old_parsed = {}; + } +} catch (err) { + Meteor._debug('Got invalid JSON on reload. Ignoring.'); +} + +if (old_parsed.reload && typeof old_parsed.data === 'object') { + // Meteor._debug("Restoring reload data."); + old_data = old_parsed.data; +} + +let providers = []; + +////////// External API ////////// + +// Packages that support migration should register themselves by calling +// this function. When it's time to migrate, callback will be called +// with one argument, the "retry function," and an optional 'option' +// argument (containing a key 'immediateMigration'). If the package +// is ready to migrate, it should return [true, data], where data is +// its migration data, an arbitrary JSON value (or [true] if it has +// no migration data this time). If the package needs more time +// before it is ready to migrate, it should return false. Then, once +// it is ready to migrating again, it should call the retry +// function. The retry function will return immediately, but will +// schedule the migration to be retried, meaning that every package +// will be polled once again for its migration data. If they are all +// ready this time, then the migration will happen. name must be set if there +// is migration data. If 'immediateMigration' is set in the options +// argument, then it doesn't matter whether the package is ready to +// migrate or not; the reload will happen immediately without waiting +// (used for OAuth redirect login). +// +Reload._onMigrate = function (name, callback) { + debug('_onMigrate', {name}); + if (!callback) { + // name not provided, so first arg is callback. + callback = name; + name = undefined; + debug('_onMigrate no callback'); + } + + providers.push({name: name, callback: callback}); +}; + +// Called by packages when they start up. +// Returns the object that was saved, or undefined if none saved. +// +Reload._migrationData = function (name) { + debug('_migrationData', {name}); + return old_data[name]; +}; + +// Options are the same as for `Reload._migrate`. +const pollProviders = function (tryReload, options) { + debug('pollProviders', {options}); + tryReload = tryReload || function () { + }; + options = options || {}; + + const {immediateMigration} = options; + debug( + `pollProviders is ${immediateMigration ? '' : 'NOT '}immediateMigration`, + {options} + ); + const migrationData = {}; + let allReady = true; + providers.forEach(p => { + const {callback, name} = p || {}; + const [ready, data] = callback(tryReload, options) || []; + + debug( + `pollProviders provider ${name || 'unknown'} is ${ + ready ? 'ready' : 'NOT ready' + }`, + {options} + ); + if (!ready) { + allReady = false; + } + + if (data !== undefined && name) { + migrationData[name] = data; + } + }); + + if (allReady) { + debug('pollProviders allReady', {options, migrationData}); + return migrationData; + } + + if (immediateMigration) { + debug('pollProviders immediateMigration', {options, migrationData}); + return migrationData; + } + + return null; +}; + +// Options are: +// - immediateMigration: true if the page will be reloaded immediately +// regardless of whether packages report that they are ready or not. +Reload._migrate = function (tryReload, options) { + debug('_migrate', {options}); + // Make sure each package is ready to go, and collect their + // migration data + const migrationData = pollProviders(tryReload, options); + if (migrationData === null) { + return false; // not ready yet.. + } + + let json; + try { + // Persist the migration data + json = JSON.stringify({ + data: migrationData, + reload: true, + }); + } catch (err) { + Meteor._debug("Couldn't serialize data for migration", migrationData); + throw err; + } + + if (safeSessionStorage) { + try { + safeSessionStorage.setItem(KEY_NAME, json); + } catch (err) { + // We should have already checked this, but just log - don't throw + Meteor._debug("Couldn't save data for migration to sessionStorage", err); + } + } else { + Meteor._debug( + 'Browser does not support sessionStorage. Not saving migration state.' + ); + } + + return true; +}; + +// Allows tests to isolate the list of providers. +Reload._withFreshProvidersForTest = function (f) { + const originalProviders = providers.slice(0); + providers = []; + try { + f(); + } finally { + providers = originalProviders; + } +}; + +// Migrating reload: reload this page (presumably to pick up a new +// version of the code or assets), but save the program state and +// migrate it over. This function returns immediately. The reload +// will happen at some point in the future once all of the packages +// are ready to migrate. +// +let reloading = false; +Reload._reload = function (options) { + debug('_reload', {options}); + options = options || {}; + + if (reloading) { + debug('reloading in progress already', {options}); + return; + } + reloading = true; + + function tryReload() { + debug('tryReload'); + setTimeout(reload, 1); + } + + function forceBrowserReload() { + debug('forceBrowserReload'); + // We'd like to make the browser reload the page using location.replace() + // instead of location.reload(), because this avoids validating assets + // with the server if we still have a valid cached copy. This doesn't work + // when the location contains a hash however, because that wouldn't reload + // the page and just scroll to the hash location instead. + if (window.location.hash || window.location.href.endsWith('#')) { + window.location.reload(); + return; + } + + window.location.replace(window.location.href); + } + + function reload() { + debug('reload'); + if (!Reload._migrate(tryReload, options)) { + return; + } + + // if (Meteor.isCordova) { + // WebAppLocalServer.switchToPendingVersion(() => { + // forceBrowserReload(); + // }); + // return; + // } + + forceBrowserReload(); + } + + tryReload(); +}; \ No newline at end of file diff --git a/packages/webui/src/meteor/retry.js b/packages/webui/src/meteor/retry.js new file mode 100644 index 0000000000..23d4ad5aa1 --- /dev/null +++ b/packages/webui/src/meteor/retry.js @@ -0,0 +1,69 @@ +import { Meteor } from "./meteor"; +import { Random } from "./random"; + +// Retry logic with an exponential backoff. +// +// options: +// baseTimeout: time for initial reconnect attempt (ms). +// exponent: exponential factor to increase timeout each attempt. +// maxTimeout: maximum time between retries (ms). +// minCount: how many times to reconnect "instantly". +// minTimeout: time to wait for the first `minCount` retries (ms). +// fuzz: factor to randomize retry times by (to avoid retry storms). + +export class Retry { + constructor({ + baseTimeout = 1000, + exponent = 2.2, + // The default is high-ish to ensure a server can recover from a + // failure caused by load. + maxTimeout = 5 * 60 * 1000, + minTimeout = 10, + minCount = 2, + fuzz = 0.5, + } = {}) { + this.baseTimeout = baseTimeout; + this.exponent = exponent; + this.maxTimeout = maxTimeout; + this.minTimeout = minTimeout; + this.minCount = minCount; + this.fuzz = fuzz; + this.retryTimer = null; + } + + // Reset a pending retry, if any. + clear() { + if (this.retryTimer) { + clearTimeout(this.retryTimer); + } + this.retryTimer = null; + } + + // Calculate how long to wait in milliseconds to retry, based on the + // `count` of which retry this is. + _timeout(count) { + if (count < this.minCount) { + return this.minTimeout; + } + + // fuzz the timeout randomly, to avoid reconnect storms when a + // server goes down. + var timeout = Math.min( + this.maxTimeout, + this.baseTimeout * Math.pow(this.exponent, count) + ) * ( + Random.fraction() * this.fuzz + (1 - this.fuzz / 2) + ); + + return timeout; + } + + // Call `fn` after a delay, based on the `count` of which retry this is. + retryLater(count, fn) { + var timeout = this._timeout(count); + if (this.retryTimer) + clearTimeout(this.retryTimer); + this.retryTimer = Meteor.setTimeout(fn, timeout); + return timeout; + } + } \ No newline at end of file diff --git a/packages/webui/src/meteor/socket-stream-client/common.js b/packages/webui/src/meteor/socket-stream-client/common.js new file mode 100644 index 0000000000..df5d8b53ec --- /dev/null +++ b/packages/webui/src/meteor/socket-stream-client/common.js @@ -0,0 +1,178 @@ +import { Retry } from '../retry'; +import { Tracker } from '../tracker'; + +const forcedReconnectError = new Error("forced reconnect"); + +export class StreamClientCommon { + constructor(options) { + this.options = { + retry: true, + ...(options || null), + }; + + this.ConnectionError = + options && options.ConnectionError || Error; + } + + // Register for callbacks. + on(name, callback) { + if (name !== 'message' && name !== 'reset' && name !== 'disconnect') + throw new Error('unknown event type: ' + name); + + if (!this.eventCallbacks[name]) this.eventCallbacks[name] = []; + this.eventCallbacks[name].push(callback); + } + + forEachCallback(name, cb) { + if (!this.eventCallbacks[name] || !this.eventCallbacks[name].length) { + return; + } + + this.eventCallbacks[name].forEach(cb); + } + + _initCommon(options) { + options = options || Object.create(null); + + //// Constants + + // how long to wait until we declare the connection attempt + // failed. + this.CONNECT_TIMEOUT = options.connectTimeoutMs || 10000; + + this.eventCallbacks = Object.create(null); // name -> [callback] + + this._forcedToDisconnect = false; + + //// Reactive status + this.currentStatus = { + status: 'connecting', + connected: false, + retryCount: 0 + }; + + this.statusListeners = new Tracker.Dependency(); + + this.statusChanged = () => { + if (this.statusListeners) { + this.statusListeners.changed(); + } + }; + + //// Retry logic + this._retry = new Retry(); + this.connectionTimer = null; + } + + // Trigger a reconnect. + reconnect(options) { + options = options || Object.create(null); + + if (options.url) { + this._changeUrl(options.url); + } + + if (options._sockjsOptions) { + this.options._sockjsOptions = options._sockjsOptions; + } + + if (this.currentStatus.connected) { + if (options._force || options.url) { + this._lostConnection(forcedReconnectError); + } + return; + } + + // if we're mid-connection, stop it. + if (this.currentStatus.status === 'connecting') { + // Pretend it's a clean close. + this._lostConnection(); + } + + this._retry.clear(); + this.currentStatus.retryCount -= 1; // don't count manual retries + this._retryNow(); + } + + disconnect(options) { + options = options || Object.create(null); + + // Failed is permanent. If we're failed, don't let people go back + // online by calling 'disconnect' then 'reconnect'. + if (this._forcedToDisconnect) return; + + // If _permanent is set, permanently disconnect a stream. Once a stream + // is forced to disconnect, it can never reconnect. This is for + // error cases such as ddp version mismatch, where trying again + // won't fix the problem. + if (options._permanent) { + this._forcedToDisconnect = true; + } + + this._cleanup(); + this._retry.clear(); + + this.currentStatus = { + status: options._permanent ? 'failed' : 'offline', + connected: false, + retryCount: 0 + }; + + if (options._permanent && options._error) + this.currentStatus.reason = options._error; + + this.statusChanged(); + } + + // maybeError is set unless it's a clean protocol-level close. + _lostConnection(maybeError) { + this._cleanup(maybeError); + this._retryLater(maybeError); // sets status. no need to do it here. + } + + // fired when we detect that we've gone online. try to reconnect + // immediately. + _online() { + // if we've requested to be offline by disconnecting, don't reconnect. + if (this.currentStatus.status != 'offline') this.reconnect(); + } + + _retryLater(maybeError) { + var timeout = 0; + if (this.options.retry || + maybeError === forcedReconnectError) { + timeout = this._retry.retryLater( + this.currentStatus.retryCount, + this._retryNow.bind(this) + ); + this.currentStatus.status = 'waiting'; + this.currentStatus.retryTime = new Date().getTime() + timeout; + } else { + this.currentStatus.status = 'failed'; + delete this.currentStatus.retryTime; + } + + this.currentStatus.connected = false; + this.statusChanged(); + } + + _retryNow() { + if (this._forcedToDisconnect) return; + + this.currentStatus.retryCount += 1; + this.currentStatus.status = 'connecting'; + this.currentStatus.connected = false; + delete this.currentStatus.retryTime; + this.statusChanged(); + + this._launchConnection(); + } + + // Get current status. Reactive. + status() { + if (this.statusListeners) { + this.statusListeners.depend(); + } + return this.currentStatus; + } +} \ No newline at end of file diff --git a/packages/webui/src/meteor/socket-stream-client/index.js b/packages/webui/src/meteor/socket-stream-client/index.js new file mode 100644 index 0000000000..de04d15fb2 --- /dev/null +++ b/packages/webui/src/meteor/socket-stream-client/index.js @@ -0,0 +1,216 @@ +import { + toSockjsUrl, + toWebsocketUrl, + } from "./urls.js"; + + import { StreamClientCommon } from "./common.js"; + + // Statically importing SockJS here will prevent native WebSocket usage + // below (in favor of SockJS), but will ensure maximum compatibility for + // clients stuck in unusual networking environments. + import { SockJS } from "./sockjs-0.3.4.js"; + + export class ClientStream extends StreamClientCommon { + // @param url {String} URL to Meteor app + // "http://subdomain.meteor.com/" or "/" or + // "ddp+sockjs://foo-**.meteor.com/sockjs" + constructor(url, options) { + super(options); + + this._initCommon(this.options); + + //// Constants + + // how long between hearing heartbeat from the server until we declare + // the connection dead. heartbeats come every 45s (stream_server.js) + // + // NOTE: this is a older timeout mechanism. We now send heartbeats at + // the DDP level (https://github.com/meteor/meteor/pull/1865), and + // expect those timeouts to kill a non-responsive connection before + // this timeout fires. This is kept around for compatibility (when + // talking to a server that doesn't support DDP heartbeats) and can be + // removed later. + this.HEARTBEAT_TIMEOUT = 100 * 1000; + + this.rawUrl = url; + this.socket = null; + this.lastError = null; + + this.heartbeatTimer = null; + + // Listen to global 'online' event if we are running in a browser. + window.addEventListener( + 'online', + this._online.bind(this), + false /* useCapture */ + ); + + //// Kickoff! + this._launchConnection(); + } + + // data is a utf8 string. Data sent while not connected is dropped on + // the floor, and it is up the user of this API to retransmit lost + // messages on 'reset' + send(data) { + if (this.currentStatus.connected) { + this.socket.send(data); + } + } + + // Changes where this connection points + _changeUrl(url) { + this.rawUrl = url; + } + + _connected() { + if (this.connectionTimer) { + clearTimeout(this.connectionTimer); + this.connectionTimer = null; + } + + if (this.currentStatus.connected) { + // already connected. do nothing. this probably shouldn't happen. + return; + } + + // update status + this.currentStatus.status = 'connected'; + this.currentStatus.connected = true; + this.currentStatus.retryCount = 0; + this.statusChanged(); + + // fire resets. This must come after status change so that clients + // can call send from within a reset callback. + this.forEachCallback('reset', callback => { + callback(); + }); + } + + _cleanup(maybeError) { + this._clearConnectionAndHeartbeatTimers(); + if (this.socket) { + this.socket.onmessage = this.socket.onclose = this.socket.onerror = this.socket.onheartbeat = () => {}; + this.socket.close(); + this.socket = null; + } + + this.forEachCallback('disconnect', callback => { + callback(maybeError); + }); + } + + _clearConnectionAndHeartbeatTimers() { + if (this.connectionTimer) { + clearTimeout(this.connectionTimer); + this.connectionTimer = null; + } + if (this.heartbeatTimer) { + clearTimeout(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + _heartbeat_timeout() { + console.log('Connection timeout. No sockjs heartbeat received.'); + this._lostConnection(new this.ConnectionError("Heartbeat timed out")); + } + + _heartbeat_received() { + // If we've already permanently shut down this stream, the timeout is + // already cleared, and we don't need to set it again. + if (this._forcedToDisconnect) return; + if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer); + this.heartbeatTimer = setTimeout( + this._heartbeat_timeout.bind(this), + this.HEARTBEAT_TIMEOUT + ); + } + + _sockjsProtocolsWhitelist() { + // only allow polling protocols. no streaming. streaming + // makes safari spin. + var protocolsWhitelist = [ + 'xdr-polling', + 'xhr-polling', + 'iframe-xhr-polling', + 'jsonp-polling' + ]; + + // iOS 4 and 5 and below crash when using websockets over certain + // proxies. this seems to be resolved with iOS 6. eg + // https://github.com/LearnBoost/socket.io/issues/193#issuecomment-7308865. + // + // iOS <4 doesn't support websockets at all so sockjs will just + // immediately fall back to http + var noWebsockets = + navigator && + /iPhone|iPad|iPod/.test(navigator.userAgent) && + /OS 4_|OS 5_/.test(navigator.userAgent); + + if (!noWebsockets) + protocolsWhitelist = ['websocket'].concat(protocolsWhitelist); + + return protocolsWhitelist; + } + + _launchConnection() { + this._cleanup(); // cleanup the old socket, if there was one. + + var options = { + protocols_whitelist: this._sockjsProtocolsWhitelist(), + ...this.options._sockjsOptions + }; + + const hasSockJS = typeof SockJS === "function"; + const disableSockJS = typeof window.__meteor_runtime_config__ !== 'undefined' ? window.__meteor_runtime_config__.DISABLE_SOCKJS : false; + this.socket = hasSockJS && !disableSockJS + // Convert raw URL to SockJS URL each time we open a connection, so + // that we can connect to random hostnames and get around browser + // per-host connection limits. + ? new SockJS(toSockjsUrl(this.rawUrl), undefined, options) + : new WebSocket(toWebsocketUrl(this.rawUrl)); + + this.socket.onopen = data => { + this.lastError = null; + this._connected(); + }; + + this.socket.onmessage = data => { + this.lastError = null; + this._heartbeat_received(); + if (this.currentStatus.connected) { + this.forEachCallback('message', callback => { + callback(data.data); + }); + } + }; + + this.socket.onclose = () => { + this._lostConnection(); + }; + + this.socket.onerror = error => { + const { lastError } = this; + this.lastError = error; + if (lastError) return; + console.error( + 'stream error', + error, + new Date().toDateString() + ); + }; + + this.socket.onheartbeat = () => { + this.lastError = null; + this._heartbeat_received(); + }; + + if (this.connectionTimer) clearTimeout(this.connectionTimer); + this.connectionTimer = setTimeout(() => { + this._lostConnection( + new this.ConnectionError("DDP connection timed out") + ); + }, this.CONNECT_TIMEOUT); + } + } \ No newline at end of file diff --git a/packages/webui/src/meteor/socket-stream-client/sockjs-0.3.4.js b/packages/webui/src/meteor/socket-stream-client/sockjs-0.3.4.js new file mode 100644 index 0000000000..80b5d6716e --- /dev/null +++ b/packages/webui/src/meteor/socket-stream-client/sockjs-0.3.4.js @@ -0,0 +1,2470 @@ +/* eslint no-undef: 0 */ +/* eslint no-restricted-globals: 0 */ + + +// XXX METEOR changes in + +/* SockJS client, version 0.3.4, http://sockjs.org, MIT License + +Copyright (c) 2011-2012 VMware, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +// Commented out JSO implementation (use json package instead). +// JSON2 by Douglas Crockford (minified). +// var JSON;JSON||(JSON={}),function(){function str(a,b){var c,d,e,f,g=gap,h,i=b[a];i&&typeof i=="object"&&typeof i.toJSON=="function"&&(i=i.toJSON(a)),typeof rep=="function"&&(i=rep.call(b,a,i));switch(typeof i){case"string":return quote(i);case"number":return isFinite(i)?String(i):"null";case"boolean":case"null":return String(i);case"object":if(!i)return"null";gap+=indent,h=[];if(Object.prototype.toString.apply(i)==="[object Array]"){f=i.length;for(c=0;c + +// [*] Including lib/index.js +// Public object +export const SockJS = (function(){ + var _document = document; + var _window = window; + var utils = {}; + + +// [*] Including lib/reventtarget.js +/* + * ***** BEGIN LICENSE BLOCK ***** + * Copyright (c) 2011-2012 VMware, Inc. + * + * For the license see COPYING. + * ***** END LICENSE BLOCK ***** + */ + +/* Simplified implementation of DOM2 EventTarget. + * http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-EventTarget + */ +var REventTarget = function() {}; +REventTarget.prototype.addEventListener = function (eventType, listener) { + if(!this._listeners) { + this._listeners = {}; + } + if(!(eventType in this._listeners)) { + this._listeners[eventType] = []; + } + var arr = this._listeners[eventType]; + if(utils.arrIndexOf(arr, listener) === -1) { + arr.push(listener); + } + return; +}; + +REventTarget.prototype.removeEventListener = function (eventType, listener) { + if(!(this._listeners && (eventType in this._listeners))) { + return; + } + var arr = this._listeners[eventType]; + var idx = utils.arrIndexOf(arr, listener); + if (idx !== -1) { + if(arr.length > 1) { + this._listeners[eventType] = arr.slice(0, idx).concat( arr.slice(idx+1) ); + } else { + delete this._listeners[eventType]; + } + return; + } + return; +}; + +REventTarget.prototype.dispatchEvent = function (event) { + var t = event.type; + var args = Array.prototype.slice.call(arguments, 0); + if (this['on'+t]) { + this['on'+t].apply(this, args); + } + if (this._listeners && t in this._listeners) { + for(var i=0; i < this._listeners[t].length; i++) { + this._listeners[t][i].apply(this, args); + } + } +}; +// [*] End of lib/reventtarget.js + + +// [*] Including lib/simpleevent.js +/* + * ***** BEGIN LICENSE BLOCK ***** + * Copyright (c) 2011-2012 VMware, Inc. + * + * For the license see COPYING. + * ***** END LICENSE BLOCK ***** + */ + +var SimpleEvent = function(type, obj) { + this.type = type; + if (typeof obj !== 'undefined') { + for(var k in obj) { + if (!obj.hasOwnProperty(k)) continue; + this[k] = obj[k]; + } + } +}; + +SimpleEvent.prototype.toString = function() { + var r = []; + for(var k in this) { + if (!this.hasOwnProperty(k)) continue; + var v = this[k]; + if (typeof v === 'function') v = '[function]'; + r.push(k + '=' + v); + } + return 'SimpleEvent(' + r.join(', ') + ')'; +}; +// [*] End of lib/simpleevent.js + + +// [*] Including lib/eventemitter.js +/* + * ***** BEGIN LICENSE BLOCK ***** + * Copyright (c) 2011-2012 VMware, Inc. + * + * For the license see COPYING. + * ***** END LICENSE BLOCK ***** + */ + +var EventEmitter = function(events) { + var that = this; + that._events = events || []; + that._listeners = {}; +}; +EventEmitter.prototype.emit = function(type) { + var that = this; + that._verifyType(type); + if (that._nuked) return; + + var args = Array.prototype.slice.call(arguments, 1); + if (that['on'+type]) { + that['on'+type].apply(that, args); + } + if (type in that._listeners) { + for(var i = 0; i < that._listeners[type].length; i++) { + that._listeners[type][i].apply(that, args); + } + } +}; + +EventEmitter.prototype.on = function(type, callback) { + var that = this; + that._verifyType(type); + if (that._nuked) return; + + if (!(type in that._listeners)) { + that._listeners[type] = []; + } + that._listeners[type].push(callback); +}; + +EventEmitter.prototype._verifyType = function(type) { + var that = this; + if (utils.arrIndexOf(that._events, type) === -1) { + utils.log('Event ' + JSON.stringify(type) + + ' not listed ' + JSON.stringify(that._events) + + ' in ' + that); + } +}; + +EventEmitter.prototype.nuke = function() { + var that = this; + that._nuked = true; + for(var i=0; i +// https://github.com/sockjs/sockjs-client/issues/79 +utils.isSameOriginScheme = function(url_a, url_b) { + if (!url_b) url_b = _window.location.href; + + return (url_a.split(':')[0] + === + url_b.split(':')[0]); +}; +// + + +utils.getParentDomain = function(url) { + // ipv4 ip address + if (/^[0-9.]*$/.test(url)) return url; + // ipv6 ip address + if (/^\[/.test(url)) return url; + // no dots + if (!(/[.]/.test(url))) return url; + + var parts = url.split('.').slice(1); + return parts.join('.'); +}; + +utils.objectExtend = function(dst, src) { + for(var k in src) { + if (src.hasOwnProperty(k)) { + dst[k] = src[k]; + } + } + return dst; +}; + +var WPrefix = '_jp'; + +utils.polluteGlobalNamespace = function() { + if (!(WPrefix in _window)) { + _window[WPrefix] = {}; + } +}; + +utils.closeFrame = function (code, reason) { + return 'c'+JSON.stringify([code, reason]); +}; + +utils.userSetCode = function (code) { + return code === 1000 || (code >= 3000 && code <= 4999); +}; + +// See: http://www.erg.abdn.ac.uk/~gerrit/dccp/notes/ccid2/rto_estimator/ +// and RFC 2988. +utils.countRTO = function (rtt) { + var rto; + if (rtt > 100) { + rto = 3 * rtt; // rto > 300msec + } else { + rto = rtt + 200; // 200msec < rto <= 300msec + } + return rto; +} + +utils.log = function() { + if (_window.console && console.log && console.log.apply) { + console.log.apply(console, arguments); + } +}; + +utils.debug = function() { + if (_window.console && console.debug && console.debug.apply) { + console.debug.apply(console, arguments); + } +}; + +utils.bind = function(fun, that) { + if (fun.bind) { + return fun.bind(that); + } else { + return function() { + return fun.apply(that, arguments); + }; + } +}; + +utils.flatUrl = function(url) { + return url.indexOf('?') === -1 && url.indexOf('#') === -1; +}; + +// `relativeTo` is an optional absolute URL. If provided, `url` will be +// interpreted relative to `relativeTo`. Defaults to `document.location`. +// +utils.amendUrl = function(url, relativeTo) { + var baseUrl; + if (relativeTo === undefined) { + baseUrl = _document.location; + } else { + var protocolMatch = /^([a-z0-9.+-]+:)/i.exec(relativeTo); + if (protocolMatch) { + var protocol = protocolMatch[0].toLowerCase(); + var rest = relativeTo.substring(protocol.length); + var hostMatch = /[a-z0-9\.-]+(:[0-9]+)?/.exec(rest); + if (hostMatch) + var host = hostMatch[0]; + } + if (! protocol || ! host) + throw new Error("relativeTo must be an absolute url"); + baseUrl = { + protocol: protocol, + host: host + }; + } + if (!url) { + throw new Error('Wrong url for SockJS'); + } + if (!utils.flatUrl(url)) { + throw new Error('Only basic urls are supported in SockJS'); + } + + // '//abc' --> 'http://abc' + if (url.indexOf('//') === 0) { + url = baseUrl.protocol + url; + } + // '/abc' --> 'http://localhost:1234/abc' + if (url.indexOf('/') === 0) { + url = baseUrl.protocol + '//' + baseUrl.host + url; + } + // + // strip trailing slashes + url = url.replace(/[/]+$/,''); + + // We have a full url here, with proto and host. For some browsers + // http://localhost:80/ is not in the same origin as http://localhost/ + // Remove explicit :80 or :443 in such cases. See #74 + var parts = url.split("/"); + if ((parts[0] === "http:" && /:80$/.test(parts[2])) || + (parts[0] === "https:" && /:443$/.test(parts[2]))) { + parts[2] = parts[2].replace(/:(80|443)$/, ""); + } + url = parts.join("/"); + return url; +}; + +// IE doesn't support [].indexOf. +utils.arrIndexOf = function(arr, obj){ + for(var i=0; i < arr.length; i++){ + if(arr[i] === obj){ + return i; + } + } + return -1; +}; + +utils.arrSkip = function(arr, obj) { + var idx = utils.arrIndexOf(arr, obj); + if (idx === -1) { + return arr.slice(); + } else { + var dst = arr.slice(0, idx); + return dst.concat(arr.slice(idx+1)); + } +}; + +// Via: https://gist.github.com/1133122/2121c601c5549155483f50be3da5305e83b8c5df +utils.isArray = Array.isArray || function(value) { + return {}.toString.call(value).indexOf('Array') >= 0 +}; + +utils.delay = function(t, fun) { + if(typeof t === 'function') { + fun = t; + t = 0; + } + return setTimeout(fun, t); +}; + + +// Chars worth escaping, as defined by Douglas Crockford: +// https://github.com/douglascrockford/JSON-js/blob/47a9882cddeb1e8529e07af9736218075372b8ac/json2.js#L196 +var json_escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, + json_lookup = { +"\u0000":"\\u0000","\u0001":"\\u0001","\u0002":"\\u0002","\u0003":"\\u0003", +"\u0004":"\\u0004","\u0005":"\\u0005","\u0006":"\\u0006","\u0007":"\\u0007", +"\b":"\\b","\t":"\\t","\n":"\\n","\u000b":"\\u000b","\f":"\\f","\r":"\\r", +"\u000e":"\\u000e","\u000f":"\\u000f","\u0010":"\\u0010","\u0011":"\\u0011", +"\u0012":"\\u0012","\u0013":"\\u0013","\u0014":"\\u0014","\u0015":"\\u0015", +"\u0016":"\\u0016","\u0017":"\\u0017","\u0018":"\\u0018","\u0019":"\\u0019", +"\u001a":"\\u001a","\u001b":"\\u001b","\u001c":"\\u001c","\u001d":"\\u001d", +"\u001e":"\\u001e","\u001f":"\\u001f","\"":"\\\"","\\":"\\\\", +"\u007f":"\\u007f","\u0080":"\\u0080","\u0081":"\\u0081","\u0082":"\\u0082", +"\u0083":"\\u0083","\u0084":"\\u0084","\u0085":"\\u0085","\u0086":"\\u0086", +"\u0087":"\\u0087","\u0088":"\\u0088","\u0089":"\\u0089","\u008a":"\\u008a", +"\u008b":"\\u008b","\u008c":"\\u008c","\u008d":"\\u008d","\u008e":"\\u008e", +"\u008f":"\\u008f","\u0090":"\\u0090","\u0091":"\\u0091","\u0092":"\\u0092", +"\u0093":"\\u0093","\u0094":"\\u0094","\u0095":"\\u0095","\u0096":"\\u0096", +"\u0097":"\\u0097","\u0098":"\\u0098","\u0099":"\\u0099","\u009a":"\\u009a", +"\u009b":"\\u009b","\u009c":"\\u009c","\u009d":"\\u009d","\u009e":"\\u009e", +"\u009f":"\\u009f","\u00ad":"\\u00ad","\u0600":"\\u0600","\u0601":"\\u0601", +"\u0602":"\\u0602","\u0603":"\\u0603","\u0604":"\\u0604","\u070f":"\\u070f", +"\u17b4":"\\u17b4","\u17b5":"\\u17b5","\u200c":"\\u200c","\u200d":"\\u200d", +"\u200e":"\\u200e","\u200f":"\\u200f","\u2028":"\\u2028","\u2029":"\\u2029", +"\u202a":"\\u202a","\u202b":"\\u202b","\u202c":"\\u202c","\u202d":"\\u202d", +"\u202e":"\\u202e","\u202f":"\\u202f","\u2060":"\\u2060","\u2061":"\\u2061", +"\u2062":"\\u2062","\u2063":"\\u2063","\u2064":"\\u2064","\u2065":"\\u2065", +"\u2066":"\\u2066","\u2067":"\\u2067","\u2068":"\\u2068","\u2069":"\\u2069", +"\u206a":"\\u206a","\u206b":"\\u206b","\u206c":"\\u206c","\u206d":"\\u206d", +"\u206e":"\\u206e","\u206f":"\\u206f","\ufeff":"\\ufeff","\ufff0":"\\ufff0", +"\ufff1":"\\ufff1","\ufff2":"\\ufff2","\ufff3":"\\ufff3","\ufff4":"\\ufff4", +"\ufff5":"\\ufff5","\ufff6":"\\ufff6","\ufff7":"\\ufff7","\ufff8":"\\ufff8", +"\ufff9":"\\ufff9","\ufffa":"\\ufffa","\ufffb":"\\ufffb","\ufffc":"\\ufffc", +"\ufffd":"\\ufffd","\ufffe":"\\ufffe","\uffff":"\\uffff"}; + +// Some extra characters that Chrome gets wrong, and substitutes with +// something else on the wire. +var extra_escapable = /[\x00-\x1f\ud800-\udfff\ufffe\uffff\u0300-\u0333\u033d-\u0346\u034a-\u034c\u0350-\u0352\u0357-\u0358\u035c-\u0362\u0374\u037e\u0387\u0591-\u05af\u05c4\u0610-\u0617\u0653-\u0654\u0657-\u065b\u065d-\u065e\u06df-\u06e2\u06eb-\u06ec\u0730\u0732-\u0733\u0735-\u0736\u073a\u073d\u073f-\u0741\u0743\u0745\u0747\u07eb-\u07f1\u0951\u0958-\u095f\u09dc-\u09dd\u09df\u0a33\u0a36\u0a59-\u0a5b\u0a5e\u0b5c-\u0b5d\u0e38-\u0e39\u0f43\u0f4d\u0f52\u0f57\u0f5c\u0f69\u0f72-\u0f76\u0f78\u0f80-\u0f83\u0f93\u0f9d\u0fa2\u0fa7\u0fac\u0fb9\u1939-\u193a\u1a17\u1b6b\u1cda-\u1cdb\u1dc0-\u1dcf\u1dfc\u1dfe\u1f71\u1f73\u1f75\u1f77\u1f79\u1f7b\u1f7d\u1fbb\u1fbe\u1fc9\u1fcb\u1fd3\u1fdb\u1fe3\u1feb\u1fee-\u1fef\u1ff9\u1ffb\u1ffd\u2000-\u2001\u20d0-\u20d1\u20d4-\u20d7\u20e7-\u20e9\u2126\u212a-\u212b\u2329-\u232a\u2adc\u302b-\u302c\uaab2-\uaab3\uf900-\ufa0d\ufa10\ufa12\ufa15-\ufa1e\ufa20\ufa22\ufa25-\ufa26\ufa2a-\ufa2d\ufa30-\ufa6d\ufa70-\ufad9\ufb1d\ufb1f\ufb2a-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufb4e\ufff0-\uffff]/g, + extra_lookup; + +// JSON Quote string. Use native implementation when possible. +var JSONQuote = (JSON && JSON.stringify) || function(string) { + json_escapable.lastIndex = 0; + if (json_escapable.test(string)) { + string = string.replace(json_escapable, function(a) { + return json_lookup[a]; + }); + } + return '"' + string + '"'; +}; + +// This may be quite slow, so let's delay until user actually uses bad +// characters. +var unroll_lookup = function(escapable) { + var i; + var unrolled = {} + var c = [] + for(i=0; i<65536; i++) { + c.push( String.fromCharCode(i) ); + } + escapable.lastIndex = 0; + c.join('').replace(escapable, function (a) { + unrolled[ a ] = '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); + return ''; + }); + escapable.lastIndex = 0; + return unrolled; +}; + +// Quote string, also taking care of unicode characters that browsers +// often break. Especially, take care of unicode surrogates: +// http://en.wikipedia.org/wiki/Mapping_of_Unicode_characters#Surrogates +utils.quote = function(string) { + var quoted = JSONQuote(string); + + // In most cases this should be very fast and good enough. + extra_escapable.lastIndex = 0; + if(!extra_escapable.test(quoted)) { + return quoted; + } + + if(!extra_lookup) extra_lookup = unroll_lookup(extra_escapable); + + return quoted.replace(extra_escapable, function(a) { + return extra_lookup[a]; + }); +} + +var _all_protocols = ['websocket', + 'xdr-streaming', + 'xhr-streaming', + 'iframe-eventsource', + 'iframe-htmlfile', + 'xdr-polling', + 'xhr-polling', + 'iframe-xhr-polling', + 'jsonp-polling']; + +utils.probeProtocols = function() { + var probed = {}; + for(var i=0; i<_all_protocols.length; i++) { + var protocol = _all_protocols[i]; + // User can have a typo in protocol name. + probed[protocol] = SockJS[protocol] && + SockJS[protocol].enabled(); + } + return probed; +}; + +utils.detectProtocols = function(probed, protocols_whitelist, info) { + var pe = {}, + protocols = []; + if (!protocols_whitelist) protocols_whitelist = _all_protocols; + for(var i=0; i 0) { + maybe_push(protos); + } + } + } + + // 1. Websocket + if (info.websocket !== false) { + maybe_push(['websocket']); + } + + // 2. Streaming + if (pe['xhr-streaming'] && !info.null_origin) { + protocols.push('xhr-streaming'); + } else { + if (pe['xdr-streaming'] && !info.cookie_needed && !info.null_origin) { + protocols.push('xdr-streaming'); + } else { + maybe_push(['iframe-eventsource', + 'iframe-htmlfile']); + } + } + + // 3. Polling + if (pe['xhr-polling'] && !info.null_origin) { + protocols.push('xhr-polling'); + } else { + if (pe['xdr-polling'] && !info.cookie_needed && !info.null_origin) { + protocols.push('xdr-polling'); + } else { + maybe_push(['iframe-xhr-polling', + 'jsonp-polling']); + } + } + return protocols; +} +// [*] End of lib/utils.js + + +// [*] Including lib/dom.js +/* + * ***** BEGIN LICENSE BLOCK ***** + * Copyright (c) 2011-2012 VMware, Inc. + * + * For the license see COPYING. + * ***** END LICENSE BLOCK ***** + */ + +// May be used by htmlfile jsonp and transports. +var MPrefix = '_sockjs_global'; +utils.createHook = function() { + var window_id = 'a' + utils.random_string(8); + if (!(MPrefix in _window)) { + var map = {}; + _window[MPrefix] = function(window_id) { + if (!(window_id in map)) { + map[window_id] = { + id: window_id, + del: function() {delete map[window_id];} + }; + } + return map[window_id]; + } + } + return _window[MPrefix](window_id); +}; + + + +utils.attachMessage = function(listener) { + utils.attachEvent('message', listener); +}; +utils.attachEvent = function(event, listener) { + if (typeof _window.addEventListener !== 'undefined') { + _window.addEventListener(event, listener, false); + } else { + // IE quirks. + // According to: http://stevesouders.com/misc/test-postmessage.php + // the message gets delivered only to 'document', not 'window'. + _document.attachEvent("on" + event, listener); + // I get 'window' for ie8. + _window.attachEvent("on" + event, listener); + } +}; + +utils.detachMessage = function(listener) { + utils.detachEvent('message', listener); +}; +utils.detachEvent = function(event, listener) { + if (typeof _window.addEventListener !== 'undefined') { + _window.removeEventListener(event, listener, false); + } else { + _document.detachEvent("on" + event, listener); + _window.detachEvent("on" + event, listener); + } +}; + + +var on_unload = {}; +// Things registered after beforeunload are to be called immediately. +var after_unload = false; + +var trigger_unload_callbacks = function() { + for(var ref in on_unload) { + on_unload[ref](); + delete on_unload[ref]; + }; +}; + +var unload_triggered = function() { + if(after_unload) return; + after_unload = true; + trigger_unload_callbacks(); +}; + +// 'unload' alone is not reliable in opera within an iframe, but we +// can't use `beforeunload` as IE fires it on javascript: links. +utils.attachEvent('unload', unload_triggered); + +utils.unload_add = function(listener) { + var ref = utils.random_string(8); + on_unload[ref] = listener; + if (after_unload) { + utils.delay(trigger_unload_callbacks); + } + return ref; +}; +utils.unload_del = function(ref) { + if (ref in on_unload) + delete on_unload[ref]; +}; + + +utils.createIframe = function (iframe_url, error_callback) { + var iframe = _document.createElement('iframe'); + var tref, unload_ref; + var unattach = function() { + clearTimeout(tref); + // Explorer had problems with that. + try {iframe.onload = null;} catch (x) {} + iframe.onerror = null; + }; + var cleanup = function() { + if (iframe) { + unattach(); + // This timeout makes chrome fire onbeforeunload event + // within iframe. Without the timeout it goes straight to + // onunload. + setTimeout(function() { + if(iframe) { + iframe.parentNode.removeChild(iframe); + } + iframe = null; + }, 0); + utils.unload_del(unload_ref); + } + }; + var onerror = function(r) { + if (iframe) { + cleanup(); + error_callback(r); + } + }; + var post = function(msg, origin) { + try { + // When the iframe is not loaded, IE raises an exception + // on 'contentWindow'. + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage(msg, origin); + } + } catch (x) {}; + }; + + iframe.src = iframe_url; + iframe.style.display = 'none'; + iframe.style.position = 'absolute'; + iframe.onerror = function(){onerror('onerror');}; + iframe.onload = function() { + // `onload` is triggered before scripts on the iframe are + // executed. Give it few seconds to actually load stuff. + clearTimeout(tref); + tref = setTimeout(function(){onerror('onload timeout');}, 2000); + }; + _document.body.appendChild(iframe); + tref = setTimeout(function(){onerror('timeout');}, 15000); + unload_ref = utils.unload_add(cleanup); + return { + post: post, + cleanup: cleanup, + loaded: unattach + }; +}; + +utils.createHtmlfile = function (iframe_url, error_callback) { + var doc = new ActiveXObject('htmlfile'); + var tref, unload_ref; + var iframe; + var unattach = function() { + clearTimeout(tref); + }; + var cleanup = function() { + if (doc) { + unattach(); + utils.unload_del(unload_ref); + iframe.parentNode.removeChild(iframe); + iframe = doc = null; + CollectGarbage(); + } + }; + var onerror = function(r) { + if (doc) { + cleanup(); + error_callback(r); + } + }; + var post = function(msg, origin) { + try { + // When the iframe is not loaded, IE raises an exception + // on 'contentWindow'. + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage(msg, origin); + } + } catch (x) {}; + }; + + doc.open(); + doc.write('' + + 'document.domain="' + document.domain + '";' + + ''); + doc.close(); + doc.parentWindow[WPrefix] = _window[WPrefix]; + var c = doc.createElement('div'); + doc.body.appendChild(c); + iframe = doc.createElement('iframe'); + c.appendChild(iframe); + iframe.src = iframe_url; + tref = setTimeout(function(){onerror('timeout');}, 15000); + unload_ref = utils.unload_add(cleanup); + return { + post: post, + cleanup: cleanup, + loaded: unattach + }; +}; +// [*] End of lib/dom.js + + +// [*] Including lib/dom2.js +/* + * ***** BEGIN LICENSE BLOCK ***** + * Copyright (c) 2011-2012 VMware, Inc. + * + * For the license see COPYING. + * ***** END LICENSE BLOCK ***** + */ + +var AbstractXHRObject = function(){}; +AbstractXHRObject.prototype = new EventEmitter(['chunk', 'finish']); + +AbstractXHRObject.prototype._start = function(method, url, payload, opts) { + var that = this; + + try { + that.xhr = new XMLHttpRequest(); + } catch(x) {}; + + if (!that.xhr) { + try { + that.xhr = new _window.ActiveXObject('Microsoft.XMLHTTP'); + } catch(x) {}; + } + if (_window.ActiveXObject || _window.XDomainRequest) { + // IE8 caches even POSTs + url += ((url.indexOf('?') === -1) ? '?' : '&') + 't='+(+new Date); + } + + // Explorer tends to keep connection open, even after the + // tab gets closed: http://bugs.jquery.com/ticket/5280 + that.unload_ref = utils.unload_add(function(){that._cleanup(true);}); + try { + that.xhr.open(method, url, true); + } catch(e) { + // IE raises an exception on wrong port. + that.emit('finish', 0, ''); + that._cleanup(); + return; + }; + + if (!opts || !opts.no_credentials) { + // Mozilla docs says https://developer.mozilla.org/en/XMLHttpRequest : + // "This never affects same-site requests." + that.xhr.withCredentials = 'true'; + } + if (opts && opts.headers) { + for(var key in opts.headers) { + that.xhr.setRequestHeader(key, opts.headers[key]); + } + } + + that.xhr.onreadystatechange = function() { + if (that.xhr) { + var x = that.xhr; + switch (x.readyState) { + case 3: + // IE doesn't like peeking into responseText or status + // on Microsoft.XMLHTTP and readystate=3 + try { + var status = x.status; + var text = x.responseText; + } catch (x) {}; + // IE returns 1223 for 204: http://bugs.jquery.com/ticket/1450 + if (status === 1223) status = 204; + + // IE does return readystate == 3 for 404 answers. + if (text && text.length > 0) { + that.emit('chunk', status, text); + } + break; + case 4: + var status = x.status; + // IE returns 1223 for 204: http://bugs.jquery.com/ticket/1450 + if (status === 1223) status = 204; + + that.emit('finish', status, x.responseText); + that._cleanup(false); + break; + } + } + }; + that.xhr.send(payload); +}; + +AbstractXHRObject.prototype._cleanup = function(abort) { + var that = this; + if (!that.xhr) return; + utils.unload_del(that.unload_ref); + + // IE needs this field to be a function + that.xhr.onreadystatechange = function(){}; + + if (abort) { + try { + that.xhr.abort(); + } catch(x) {}; + } + that.unload_ref = that.xhr = null; +}; + +AbstractXHRObject.prototype.close = function() { + var that = this; + that.nuke(); + that._cleanup(true); +}; + +var XHRCorsObject = utils.XHRCorsObject = function() { + var that = this, args = arguments; + utils.delay(function(){that._start.apply(that, args);}); +}; +XHRCorsObject.prototype = new AbstractXHRObject(); + +var XHRLocalObject = utils.XHRLocalObject = function(method, url, payload) { + var that = this; + utils.delay(function(){ + that._start(method, url, payload, { + no_credentials: true + }); + }); +}; +XHRLocalObject.prototype = new AbstractXHRObject(); + + + +// References: +// http://ajaxian.com/archives/100-line-ajax-wrapper +// http://msdn.microsoft.com/en-us/library/cc288060(v=VS.85).aspx +var XDRObject = utils.XDRObject = function(method, url, payload) { + var that = this; + utils.delay(function(){that._start(method, url, payload);}); +}; +XDRObject.prototype = new EventEmitter(['chunk', 'finish']); +XDRObject.prototype._start = function(method, url, payload) { + var that = this; + var xdr = new XDomainRequest(); + // IE caches even POSTs + url += ((url.indexOf('?') === -1) ? '?' : '&') + 't='+(+new Date); + + var onerror = xdr.ontimeout = xdr.onerror = function() { + that.emit('finish', 0, ''); + that._cleanup(false); + }; + xdr.onprogress = function() { + that.emit('chunk', 200, xdr.responseText); + }; + xdr.onload = function() { + that.emit('finish', 200, xdr.responseText); + that._cleanup(false); + }; + that.xdr = xdr; + that.unload_ref = utils.unload_add(function(){that._cleanup(true);}); + try { + // Fails with AccessDenied if port number is bogus + that.xdr.open(method, url); + that.xdr.send(payload); + } catch(x) { + onerror(); + } +}; + +XDRObject.prototype._cleanup = function(abort) { + var that = this; + if (!that.xdr) return; + utils.unload_del(that.unload_ref); + + that.xdr.ontimeout = that.xdr.onerror = that.xdr.onprogress = + that.xdr.onload = null; + if (abort) { + try { + that.xdr.abort(); + } catch(x) {}; + } + that.unload_ref = that.xdr = null; +}; + +XDRObject.prototype.close = function() { + var that = this; + that.nuke(); + that._cleanup(true); +}; + +// 1. Is natively via XHR +// 2. Is natively via XDR +// 3. Nope, but postMessage is there so it should work via the Iframe. +// 4. Nope, sorry. +utils.isXHRCorsCapable = function() { + if (_window.XMLHttpRequest && 'withCredentials' in new XMLHttpRequest()) { + return 1; + } + // XDomainRequest doesn't work if page is served from file:// + if (_window.XDomainRequest && _document.domain) { + return 2; + } + if (IframeTransport.enabled()) { + return 3; + } + return 4; +}; +// [*] End of lib/dom2.js + + +// [*] Including lib/sockjs.js +/* + * ***** BEGIN LICENSE BLOCK ***** + * Copyright (c) 2011-2012 VMware, Inc. + * + * For the license see COPYING. + * ***** END LICENSE BLOCK ***** + */ + +var SockJS = function(url, dep_protocols_whitelist, options) { + if (!(this instanceof SockJS)) { + // makes `new` optional + return new SockJS(url, dep_protocols_whitelist, options); + } + + var that = this, protocols_whitelist; + that._options = {devel: false, debug: false, protocols_whitelist: [], + info: undefined, rtt: undefined}; + if (options) { + utils.objectExtend(that._options, options); + } + that._base_url = utils.amendUrl(url); + that._server = that._options.server || utils.random_number_string(1000); + if (that._options.protocols_whitelist && + that._options.protocols_whitelist.length) { + protocols_whitelist = that._options.protocols_whitelist; + } else { + // Deprecated API + if (typeof dep_protocols_whitelist === 'string' && + dep_protocols_whitelist.length > 0) { + protocols_whitelist = [dep_protocols_whitelist]; + } else if (utils.isArray(dep_protocols_whitelist)) { + protocols_whitelist = dep_protocols_whitelist + } else { + protocols_whitelist = null; + } + if (protocols_whitelist) { + that._debug('Deprecated API: Use "protocols_whitelist" option ' + + 'instead of supplying protocol list as a second ' + + 'parameter to SockJS constructor.'); + } + } + that._protocols = []; + that.protocol = null; + that.readyState = SockJS.CONNECTING; + that._ir = createInfoReceiver(that._base_url); + that._ir.onfinish = function(info, rtt) { + that._ir = null; + if (info) { + if (that._options.info) { + // Override if user supplies the option + info = utils.objectExtend(info, that._options.info); + } + if (that._options.rtt) { + rtt = that._options.rtt; + } + that._applyInfo(info, rtt, protocols_whitelist); + that._didClose(); + } else { + that._didClose(1002, 'Can\'t connect to server', true); + } + }; +}; +// Inheritance +SockJS.prototype = new REventTarget(); + +SockJS.version = "0.3.4"; + +SockJS.CONNECTING = 0; +SockJS.OPEN = 1; +SockJS.CLOSING = 2; +SockJS.CLOSED = 3; + +SockJS.prototype._debug = function() { + if (this._options.debug) + utils.log.apply(utils, arguments); +}; + +SockJS.prototype._dispatchOpen = function() { + var that = this; + if (that.readyState === SockJS.CONNECTING) { + if (that._transport_tref) { + clearTimeout(that._transport_tref); + that._transport_tref = null; + } + that.readyState = SockJS.OPEN; + that.dispatchEvent(new SimpleEvent("open")); + } else { + // The server might have been restarted, and lost track of our + // connection. + that._didClose(1006, "Server lost session"); + } +}; + +SockJS.prototype._dispatchMessage = function(data) { + var that = this; + if (that.readyState !== SockJS.OPEN) + return; + that.dispatchEvent(new SimpleEvent("message", {data: data})); +}; + +SockJS.prototype._dispatchHeartbeat = function(data) { + var that = this; + if (that.readyState !== SockJS.OPEN) + return; + that.dispatchEvent(new SimpleEvent('heartbeat', {})); +}; + +SockJS.prototype._didClose = function(code, reason, force) { + var that = this; + if (that.readyState !== SockJS.CONNECTING && + that.readyState !== SockJS.OPEN && + that.readyState !== SockJS.CLOSING) { + utils.debug('INVALID_STATE_ERR', that.readyState); + return; + } + if (that._ir) { + that._ir.nuke(); + that._ir = null; + } + + if (that._transport) { + that._transport.doCleanup(); + that._transport = null; + } + + var close_event = new SimpleEvent("close", { + code: code, + reason: reason, + wasClean: utils.userSetCode(code)}); + + if (!utils.userSetCode(code) && + that.readyState === SockJS.CONNECTING && !force) { + if (that._try_next_protocol(close_event)) { + return; + } + close_event = new SimpleEvent("close", {code: 2000, + reason: "All transports failed", + wasClean: false, + last_event: close_event}); + } + that.readyState = SockJS.CLOSED; + + utils.delay(function() { + that.dispatchEvent(close_event); + }); +}; + +SockJS.prototype._didMessage = function(data) { + var that = this; + var type = data.slice(0, 1); + switch(type) { + case 'o': + that._dispatchOpen(); + break; + case 'a': + var payload = JSON.parse(data.slice(1) || '[]'); + for(var i=0; i < payload.length; i++){ + that._dispatchMessage(payload[i]); + } + break; + case 'm': + var payload = JSON.parse(data.slice(1) || 'null'); + that._dispatchMessage(payload); + break; + case 'c': + var payload = JSON.parse(data.slice(1) || '[]'); + that._didClose(payload[0], payload[1]); + break; + case 'h': + that._dispatchHeartbeat(); + break; + } +}; + +SockJS.prototype._try_next_protocol = function(close_event) { + var that = this; + if (that.protocol) { + that._debug('Closed transport:', that.protocol, ''+close_event); + that.protocol = null; + } + if (that._transport_tref) { + clearTimeout(that._transport_tref); + that._transport_tref = null; + } + + while(1) { + var protocol = that.protocol = that._protocols.shift(); + if (!protocol) { + return false; + } + // Some protocols require access to `body`, what if were in + // the `head`? + if (SockJS[protocol] && + SockJS[protocol].need_body === true && + (!_document.body || + (typeof _document.readyState !== 'undefined' + && _document.readyState !== 'complete'))) { + that._protocols.unshift(protocol); + that.protocol = 'waiting-for-load'; + utils.attachEvent('load', function(){ + that._try_next_protocol(); + }); + return true; + } + + if (!SockJS[protocol] || + !SockJS[protocol].enabled(that._options)) { + that._debug('Skipping transport:', protocol); + } else { + var roundTrips = SockJS[protocol].roundTrips || 1; + var to = ((that._options.rto || 0) * roundTrips) || 5000; + that._transport_tref = utils.delay(to, function() { + if (that.readyState === SockJS.CONNECTING) { + // I can't understand how it is possible to run + // this timer, when the state is CLOSED, but + // apparently in IE everythin is possible. + that._didClose(2007, "Transport timeouted"); + } + }); + + var connid = utils.random_string(8); + var trans_url = that._base_url + '/' + that._server + '/' + connid; + that._debug('Opening transport:', protocol, ' url:'+trans_url, + ' RTO:'+that._options.rto); + that._transport = new SockJS[protocol](that, trans_url, + that._base_url); + return true; + } + } +}; + +SockJS.prototype.close = function(code, reason) { + var that = this; + if (code && !utils.userSetCode(code)) + throw new Error("INVALID_ACCESS_ERR"); + if(that.readyState !== SockJS.CONNECTING && + that.readyState !== SockJS.OPEN) { + return false; + } + that.readyState = SockJS.CLOSING; + that._didClose(code || 1000, reason || "Normal closure"); + return true; +}; + +SockJS.prototype.send = function(data) { + var that = this; + if (that.readyState === SockJS.CONNECTING) + throw new Error('INVALID_STATE_ERR'); + if (that.readyState === SockJS.OPEN) { + that._transport.doSend(utils.quote('' + data)); + } + return true; +}; + +SockJS.prototype._applyInfo = function(info, rtt, protocols_whitelist) { + var that = this; + that._options.info = info; + that._options.rtt = rtt; + that._options.rto = utils.countRTO(rtt); + that._options.info.null_origin = !_document.domain; + // Servers can override base_url, eg to provide a randomized domain name and + // avoid browser per-domain connection limits. + if (info.base_url) + // + that._base_url = utils.amendUrl(info.base_url, that._base_url); + // + var probed = utils.probeProtocols(); + that._protocols = utils.detectProtocols(probed, protocols_whitelist, info); +// +// https://github.com/sockjs/sockjs-client/issues/79 + // Hack to avoid XDR when using different protocols + // We're on IE trying to do cross-protocol. jsonp only. + if (!utils.isSameOriginScheme(that._base_url) && + 2 === utils.isXHRCorsCapable()) { + that._protocols = ['jsonp-polling']; + } +// +}; +// [*] End of lib/sockjs.js + + +// [*] Including lib/trans-websocket.js +/* + * ***** BEGIN LICENSE BLOCK ***** + * Copyright (c) 2011-2012 VMware, Inc. + * + * For the license see COPYING. + * ***** END LICENSE BLOCK ***** + */ + +var WebSocketTransport = SockJS.websocket = function(ri, trans_url) { + var that = this; + var url = trans_url + '/websocket'; + if (url.slice(0, 5) === 'https') { + url = 'wss' + url.slice(5); + } else { + url = 'ws' + url.slice(4); + } + that.ri = ri; + that.url = url; + var Constructor = _window.WebSocket || _window.MozWebSocket; + + that.ws = new Constructor(that.url); + that.ws.onmessage = function(e) { + that.ri._didMessage(e.data); + }; + // Firefox has an interesting bug. If a websocket connection is + // created after onunload, it stays alive even when user + // navigates away from the page. In such situation let's lie - + // let's not open the ws connection at all. See: + // https://github.com/sockjs/sockjs-client/issues/28 + // https://bugzilla.mozilla.org/show_bug.cgi?id=696085 + that.unload_ref = utils.unload_add(function(){that.ws.close()}); + that.ws.onclose = function() { + that.ri._didMessage(utils.closeFrame(1006, "WebSocket connection broken")); + }; +}; + +WebSocketTransport.prototype.doSend = function(data) { + this.ws.send('[' + data + ']'); +}; + +WebSocketTransport.prototype.doCleanup = function() { + var that = this; + var ws = that.ws; + if (ws) { + ws.onmessage = ws.onclose = null; + ws.close(); + utils.unload_del(that.unload_ref); + that.unload_ref = that.ri = that.ws = null; + } +}; + +WebSocketTransport.enabled = function() { + return !!(_window.WebSocket || _window.MozWebSocket); +}; + +// In theory, ws should require 1 round trip. But in chrome, this is +// not very stable over SSL. Most likely a ws connection requires a +// separate SSL connection, in which case 2 round trips are an +// absolute minumum. +WebSocketTransport.roundTrips = 2; +// [*] End of lib/trans-websocket.js + + +// [*] Including lib/trans-sender.js +/* + * ***** BEGIN LICENSE BLOCK ***** + * Copyright (c) 2011-2012 VMware, Inc. + * + * For the license see COPYING. + * ***** END LICENSE BLOCK ***** + */ + +var BufferedSender = function() {}; +BufferedSender.prototype.send_constructor = function(sender) { + var that = this; + that.send_buffer = []; + that.sender = sender; +}; +BufferedSender.prototype.doSend = function(message) { + var that = this; + that.send_buffer.push(message); + if (!that.send_stop) { + that.send_schedule(); + } +}; + +// For polling transports in a situation when in the message callback, +// new message is being send. If the sending connection was started +// before receiving one, it is possible to saturate the network and +// timeout due to the lack of receiving socket. To avoid that we delay +// sending messages by some small time, in order to let receiving +// connection be started beforehand. This is only a halfmeasure and +// does not fix the big problem, but it does make the tests go more +// stable on slow networks. +BufferedSender.prototype.send_schedule_wait = function() { + var that = this; + var tref; + that.send_stop = function() { + that.send_stop = null; + clearTimeout(tref); + }; + tref = utils.delay(25, function() { + that.send_stop = null; + that.send_schedule(); + }); +}; + +BufferedSender.prototype.send_schedule = function() { + var that = this; + if (that.send_buffer.length > 0) { + var payload = '[' + that.send_buffer.join(',') + ']'; + that.send_stop = that.sender(that.trans_url, payload, function(success, abort_reason) { + that.send_stop = null; + if (success === false) { + that.ri._didClose(1006, 'Sending error ' + abort_reason); + } else { + that.send_schedule_wait(); + } + }); + that.send_buffer = []; + } +}; + +BufferedSender.prototype.send_destructor = function() { + var that = this; + if (that._send_stop) { + that._send_stop(); + } + that._send_stop = null; +}; + +var jsonPGenericSender = function(url, payload, callback) { + var that = this; + + if (!('_send_form' in that)) { + var form = that._send_form = _document.createElement('form'); + var area = that._send_area = _document.createElement('textarea'); + area.name = 'd'; + form.style.display = 'none'; + form.style.position = 'absolute'; + form.method = 'POST'; + form.enctype = 'application/x-www-form-urlencoded'; + form.acceptCharset = "UTF-8"; + form.appendChild(area); + _document.body.appendChild(form); + } + var form = that._send_form; + var area = that._send_area; + var id = 'a' + utils.random_string(8); + form.target = id; + form.action = url + '/jsonp_send?i=' + id; + + var iframe; + try { + // ie6 dynamic iframes with target="" support (thanks Chris Lambacher) + iframe = _document.createElement('