refactor: organise API around resources (#798)

This commit is contained in:
Carlos Valente
2024-03-02 23:08:08 +01:00
committed by GitHub
parent d87ba12b2e
commit 088927bbbb
104 changed files with 1741 additions and 1718 deletions
+21
View File
@@ -0,0 +1,21 @@
import axios from 'axios';
import { Alias } from 'ontime-types';
import { apiEntryUrl } from './constants';
const aliasesPath = `${apiEntryUrl}/aliases`;
/**
* HTTP request to retrieve aliases
*/
export async function getAliases(): Promise<Alias[]> {
const res = await axios.get(aliasesPath);
return res.data;
}
/**
* HTTP request to mutate aliases
*/
export async function postAliases(data: Alias[]): Promise<Alias[]> {
return axios.post(aliasesPath, data);
}
-46
View File
@@ -1,46 +0,0 @@
import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
export function maybeAxiosError(error: unknown) {
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
let data = (error as AxiosError).response?.data ?? '';
if (typeof data === 'object') {
if ('message' in data) {
data = JSON.stringify(data.message);
} else {
data = JSON.stringify(data);
}
}
return `${statusText}: ${data}`;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
export function logAxiosError(prepend: string, error: unknown) {
const message = `${prepend}: ${maybeAxiosError(error)}`;
addLog({
id: generateId(),
origin: 'SERVER',
time: millisToString(nowInMillis()),
level: LogLevel.Error,
text: message,
});
}
/**
* Utility function invalidates react-query caches
*/
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries();
}
@@ -1,4 +1,4 @@
// REST stuff
// keys in tanstack store
export const ALIASES = ['aliases'];
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
@@ -12,16 +12,20 @@ export const SHEET_STATE = ['sheetState'];
export const CUSTOM_FIELDS = ['customFields'];
export const VIEW_SETTINGS = ['viewSettings'];
// resolve location
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
// resolve port
const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const apiEntryUrl = `${serverURL}/data`;
export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
@@ -0,0 +1,38 @@
import axios from 'axios';
import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants';
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/**
* Requests list of known custom fields
*/
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(customFieldsPath);
return res.data;
}
/**
* Sets list of known custom fields
*/
export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post(customFieldsPath, { ...newField });
return res.data;
}
/**
* Edits single custom field
*/
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
return res.data;
}
/**
* Deletes single custom field
*/
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${label}`);
return res.data;
}
+155
View File
@@ -0,0 +1,155 @@
import axios, { AxiosResponse } from 'axios';
import {
CustomFields,
DatabaseModel,
GetInfo,
MessageResponse,
OntimeRundown,
ProjectData,
ProjectFileListResponse,
} from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import fileDownload from './utils';
const dbPath = `${apiEntryUrl}/db`;
/**
* HTTP request to download db in JSON format
*/
export async function downloadRundown(fileName?: string) {
return fileDownload(
dbPath,
{ name: fileName ?? 'rundown', type: 'json' },
{ type: 'application/json;charset=utf-8;' },
);
}
/**
* HTTP request to download db in CSV format
*/
export async function downloadCSV(fileName?: string) {
return fileDownload(dbPath, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
}
/**
* HTTP request to upload project file
*/
export async function uploadProjectFile(file: File): Promise<MessageResponse> {
const formData = new FormData();
formData.append('project', file);
const response = await axios.post(`${dbPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
}
/**
* Make patch changes to the objects in the db
*/
export async function patchData(patchDb: Partial<DatabaseModel>): Promise<AxiosResponse<DatabaseModel>> {
return await axios.patch(dbPath, patchDb);
}
/**
* HTTP request to create a project file
*/
export async function createProject(
project: Partial<
ProjectData & {
filename: string;
}
>,
): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/new`, project);
return res.data;
}
/**
* HTTP request to get the list of available project files
*/
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`);
return res.data;
}
/**
* HTTP request to load a project file
*/
export async function loadProject(filename: string): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/load`, {
filename,
});
return res.data;
}
/**
* HTTP request to duplicate a project file
*/
export async function duplicateProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${dbPath}/${filename}/duplicate`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* HTTP request to rename a project file
*/
export async function renameProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${dbPath}/${filename}/rename`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* HTTP request to delete a project file
*/
export async function deleteProject(filename: string): Promise<MessageResponse> {
const url = `${dbPath}/${filename}`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.delete(decodedUrl);
return res.data;
}
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${dbPath}/info`);
return res.data;
}
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/**
* Make patch changes to the objects in the db
*/
export async function importSpreadsheetPreview(file: File, options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const formData = new FormData();
formData.append('spreadsheet', file);
formData.append('options', JSON.stringify(options));
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
`${dbPath}/spreadsheet/preview`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
return response.data;
}
-103
View File
@@ -1,103 +0,0 @@
import axios from 'axios';
import { OntimeEvent, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { rundownURL } from './apiConstants';
/**
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchCachedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownURL}/cached`);
return res.data;
}
/**
* @deprecated use fetchCachedRundown instead
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchRundown(): Promise<OntimeRundown> {
const res = await axios.get(rundownURL);
return res.data;
}
/**
* @description HTTP request to post new event
* @return {Promise}
*/
export async function requestPostEvent(data: Partial<OntimeRundownEntry>) {
return axios.post(rundownURL, data);
}
/**
* @description HTTP request to put new event
* @return {Promise}
*/
export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
return axios.put(rundownURL, data);
}
type BatchEditEntry = {
data: Partial<OntimeEvent>;
ids: string[];
};
/**
* @description HTTP request to put multiple events
* @returns {Promise}
*/
export async function requestBatchPutEvents(data: BatchEditEntry) {
return axios.put(`${rundownURL}/batchEdit`, data);
}
export type ReorderEntry = {
eventId: string;
from: number;
to: number;
};
/**
* @description HTTP request to reorder events
* @return {Promise}
*/
export async function requestReorderEvent(data: ReorderEntry) {
return axios.patch(`${rundownURL}/reorder`, data);
}
/**
* @description HTTP request to request application of delay
* @return {Promise}
*/
export async function requestApplyDelay(eventId: string) {
return axios.patch(`${rundownURL}/applydelay/${eventId}`);
}
export type SwapEntry = {
from: string;
to: string;
};
/**
* @description HTTP request to swap two events
* @return {Promise}
*/
export async function requestEventSwap(data: SwapEntry) {
return axios.patch(`${rundownURL}/swap`, data);
}
/**
* @description HTTP request to delete given event
* @return {Promise}
*/
export async function requestDelete(eventId: string) {
return axios.delete(`${rundownURL}/${eventId}`);
}
/**
* @description HTTP request to delete all events
* @return {Promise}
*/
export async function requestDeleteAll() {
return axios.delete(`${rundownURL}/all`);
}
+19
View File
@@ -0,0 +1,19 @@
import axios from 'axios';
import { apiRepoLatest } from '../../externals';
export type HasUpdate = {
url: string;
version: string;
};
/**
* HTTP request to get the latest version and url from github
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(`${apiRepoLatest}`);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
};
}
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { HttpSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const httpPath = `${apiEntryUrl}/http`;
/**
* HTTP request to retrieve http settings
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(httpPath);
return res.data;
}
/**
* HTTP request to mutate http settings
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(httpPath, data);
}
-397
View File
@@ -1,397 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import {
Alias,
AuthenticationStatus,
CustomField,
CustomFieldLabel,
CustomFields,
DatabaseModel,
GetInfo,
HttpSettings,
MessageResponse,
OntimeRundown,
OSCSettings,
ProjectData,
ProjectFileListResponse,
Settings,
ViewSettings,
} from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiRepoLatest } from '../../externals';
import fileDownload from '../utils/fileDownload';
import { ontimeURL, projectDataURL } from './apiConstants';
/**
* @description HTTP request to retrieve application settings
* @return {Promise}
*/
export async function getSettings(): Promise<Settings> {
const res = await axios.get(`${ontimeURL}/settings`);
return res.data;
}
/**
* @description HTTP request to mutate application settings
* @return {Promise}
*/
export async function postSettings(data: Settings) {
return axios.post(`${ontimeURL}/settings`, data);
}
/**
* @description HTTP request to retrieve application info
* @return {Promise}
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${ontimeURL}/info`);
return res.data;
}
/**
* @description HTTP request to retrieve view settings
* @return {Promise}
*/
export async function getView(): Promise<ViewSettings> {
const res = await axios.get(`${ontimeURL}/views`);
return res.data;
}
/**
* @description HTTP request to mutate view settings
* @return {Promise}
*/
export async function postViewSettings(data: ViewSettings) {
return axios.post(`${ontimeURL}/views`, data);
}
/**
* @description HTTP request to retrieve aliases
* @return {Promise}
*/
export async function getAliases(): Promise<Alias[]> {
const res = await axios.get(`${ontimeURL}/aliases`);
return res.data;
}
/**
* @description HTTP request to mutate aliases
* @return {Promise}
*/
export async function postAliases(data: Alias[]) {
return axios.post(`${ontimeURL}/aliases`, data);
}
/**
* @description HTTP request to retrieve osc settings
* @return {Promise}
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(`${ontimeURL}/osc`);
return res.data;
}
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(`${ontimeURL}/osc`, data);
}
/**
* @description HTTP request to retrieve http settings
* @return {Promise}
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(`${ontimeURL}/http`);
return res.data;
}
/**
* @description HTTP request to mutate http settings
* @return {Promise}
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(`${ontimeURL}/http`, data);
}
/**
* @description HTTP request to download db in CSV format
*/
export const downloadCSV = (fileName?: string) => {
return fileDownload(ontimeURL, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
};
/**
* @description HTTP request to download db in JSON format
*/
export const downloadRundown = (fileName?: string) => {
return fileDownload(
ontimeURL,
{ name: fileName ?? 'rundown', type: 'json' },
{ type: 'application/json;charset=utf-8;' },
);
};
/**
* @description HTTP request to upload project file
* @return {Promise}
*/
export async function importProjectFile(file: File): Promise<void> {
const formData = new FormData();
formData.append('userFile', file);
const response = await axios.post(`${ontimeURL}/db`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
}
// TODO: should this be extracted to shared code?
export type ProjectFileImportOptions = {
onlyRundown: boolean;
};
/**
* @description HTTP request to upload events db
* @return {Promise}
*/
export const uploadProjectFile = async (
file: File,
setProgress: (value: number) => void,
options?: Partial<ProjectFileImportOptions>,
) => {
const formData = new FormData();
formData.append('userFile', file);
const onlyRundown = Boolean(options?.onlyRundown);
await axios
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
})
.then((response) => response.data.id);
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise}
*/
export async function patchData(patchDb: Partial<DatabaseModel>): Promise<void> {
return await axios.patch(`${ontimeURL}/db`, patchDb);
}
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise} - returns parsed rundown and customFields
*/
export async function importSpreadsheetPreview(file: File, options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const formData = new FormData();
formData.append('userFile', file);
formData.append('options', JSON.stringify(options));
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
`${ontimeURL}/spreadsheet/preview`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
return response.data;
}
export type HasUpdate = {
url: string;
version: string;
};
/**
* @description HTTP request to get the latest version and url from github
* @return {Promise}
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(`${apiRepoLatest}`);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
};
}
/**
* @description HTTP request to get the list of available project files
*/
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${ontimeURL}/projects`);
return res.data;
}
/**
* @description HTTP request to load a project file
*/
export async function loadProject(filename: string): Promise<MessageResponse> {
const res = await axios.post(`${ontimeURL}/load-project`, {
filename,
});
return res.data;
}
/**
* @description HTTP request to initiate the authentication service with google
*/
export const requestConnection = async (
file: File,
sheetId: string,
): Promise<{
verification_url: string;
user_code: string;
}> => {
const formData = new FormData();
formData.append('client_secret', file);
const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/connect`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
/**
* @description HTTP request to verify whether we are authenticated with Google Sheet service
*/
export const verifyAuthenticationStatus = async (): Promise<{ authenticated: AuthenticationStatus }> => {
const response = await axios.get(`${ontimeURL}/sheet/connect`);
return response.data;
};
/**
* @description HTTP request to revoke authentication to google sheet
*/
export const revokeAuthentication = async (): Promise<{ authenticated: AuthenticationStatus }> => {
const response = await axios.post(`${ontimeURL}/sheet/revoke`);
return response.data;
};
/**
* @description HTTP request to upload preview the contents of a google sheet as rundown
*/
export const previewRundown = async (
sheetId: string,
options: ImportMap,
): Promise<{
rundown: OntimeRundown;
customFields: CustomFields;
}> => {
const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/read`, { options });
return response.data;
};
/**
* @description HTTP request to upload the rundown to a google sheet
*/
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/write`, { options });
return response.data;
};
/**
* @description HTTP request to rename a project file
*/
export async function renameProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}/rename`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* @description HTTP request to duplicate a project file
*/
export async function duplicateProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}/duplicate`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* @description HTTP request to delete a project file
*/
export async function deleteProject(filename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.delete(decodedUrl);
return res.data;
}
/**
* @description HTTP request to create a project file
*/
export async function createProject(
project: Partial<
ProjectData & {
filename: string;
}
>,
): Promise<MessageResponse> {
const res = await axios.post(`${ontimeURL}/project`, project);
return res.data;
}
/**
* Requests list of known custom fields
*/
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(`${projectDataURL}/custom-field`);
return res.data;
}
/**
* Sets list of known custom fields
*/
export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post(`${projectDataURL}/custom-field`, {
...newField,
});
return res.data;
}
/**
* Edits single custom field
*/
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${projectDataURL}/custom-field/${label}`, {
...newField,
});
return res.data;
}
/**
* Deletes single custom field
*/
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
const res = await axios.delete(`${projectDataURL}/custom-field/${label}`);
return res.data;
}
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { OSCSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const oscPath = `${apiEntryUrl}/osc`;
/**
* HTTP request to retrieve osc settings
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(oscPath);
return res.data;
}
/**
* HTTP request to mutate osc settings
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(oscPath, data);
}
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { ProjectData } from 'ontime-types';
import { apiEntryUrl } from './constants';
const projectPath = `${apiEntryUrl}/project`;
/**
* HTTP request to fetch project data
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectPath);
return res.data;
}
/**
* HTTP request to mutate project data
*/
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
return axios.post(projectPath, data);
}
@@ -1,21 +0,0 @@
import axios from 'axios';
import { ProjectData } from 'ontime-types';
import { projectDataURL } from './apiConstants';
/**
* @description HTTP request to fetch project data
* @return {Promise}
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectDataURL);
return res.data;
}
/**
* @description HTTP request to mutate project data
* @return {Promise}
*/
export async function postProjectData(data: ProjectData) {
return axios.post(projectDataURL, data);
}
+86
View File
@@ -0,0 +1,86 @@
import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`;
/**
* HTTP request to fetch all events
*/
export async function fetchNormalisedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownPath}/normalised`);
return res.data;
}
/**
* HTTP request to post new event
*/
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data);
}
/**
* HTTP request to put new event
*/
export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.put(rundownPath, data);
}
type BatchEditEntry = {
data: Partial<OntimeEvent>;
ids: string[];
};
/**
* HTTP request to put multiple events
*/
export async function requestBatchPutEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.put(`${rundownPath}/batch`, data);
}
export type ReorderEntry = {
eventId: string;
from: number;
to: number;
};
/**
* HTTP request to reorder events
*/
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.patch(`${rundownPath}/reorder`, data);
}
export type SwapEntry = {
from: string;
to: string;
};
/**
* HTTP request to swap two events
*/
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/swap`, data);
}
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/applydelay/${eventId}`);
}
/**
* HTTP request to delete given event
*/
export async function requestDelete(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(`${rundownPath}/${eventId}`);
}
/**
* HTTP request to delete all events
*/
export async function requestDeleteAll(): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(`${rundownPath}/all`);
}
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { Settings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const settingsPath = `${apiEntryUrl}/settings`;
/**
* HTTP request to retrieve application settings
*/
export async function getSettings(): Promise<Settings> {
const res = await axios.get(settingsPath);
return res.data;
}
/**
* HTTP request to mutate application settings
*/
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data);
}
+67
View File
@@ -0,0 +1,67 @@
import axios from 'axios';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
const sheetsPath = `${apiEntryUrl}/sheets`;
/**
* HTTP request to verify whether we are authenticated with Google Sheet service
*/
export const verifyAuthenticationStatus = async (): Promise<{ authenticated: AuthenticationStatus }> => {
const response = await axios.get(`${apiEntryUrl}/connect`);
return response.data;
};
/**
* HTTP request to initiate the authentication service with google
*/
export const requestConnection = async (
file: File,
sheetId: string,
): Promise<{
verification_url: string;
user_code: string;
}> => {
const formData = new FormData();
formData.append('client_secret', file);
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
/**
* HTTP request to revoke authentication to google sheet
*/
export const revokeAuthentication = async (): Promise<{ authenticated: AuthenticationStatus }> => {
const response = await axios.post(`${sheetsPath}/revoke`);
return response.data;
};
/**
* HTTP request to upload preview the contents of a google sheet as rundown
*/
export const previewRundown = async (
sheetId: string,
options: ImportMap,
): Promise<{
rundown: OntimeRundown;
customFields: CustomFields;
}> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
return response.data;
};
/**
* HTTP request to upload the rundown to a google sheet
*/
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
return response.data;
};
@@ -1,6 +1,60 @@
import axios from 'axios';
import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
/**
* Utility unrwap a potential axios error
* @param error
* @returns
*/
export function maybeAxiosError(error: unknown) {
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
let data = (error as AxiosError).response?.data ?? '';
if (typeof data === 'object') {
if ('message' in data) {
data = JSON.stringify(data.message);
} else {
data = JSON.stringify(data);
}
}
return `${statusText}: ${data}`;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
/**
* Utility unrwaps a potential axios error and sends to logger
* @param prepend
* @param error
*/
export function logAxiosError(prepend: string, error: unknown) {
const message = `${prepend}: ${maybeAxiosError(error)}`;
addLog({
id: generateId(),
origin: 'SERVER',
time: millisToString(nowInMillis()),
level: LogLevel.Error,
text: message,
});
}
/**
* Utility function invalidates react-query caches
*/
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries();
}
type FileOptions = {
name: string;
@@ -61,3 +115,4 @@ export default async function fileDownload(url: string, fileOptions: FileOptions
URL.revokeObjectURL(downloadUrl);
return;
}
@@ -0,0 +1,21 @@
import axios from 'axios';
import { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
/**
* HTTP request to retrieve view settings
*/
export async function getView(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath);
return res.data;
}
/**
* HTTP request to mutate view settings
*/
export async function postViewSettings(data: ViewSettings) {
return axios.post(viewSettingsPath, data);
}
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { ALIASES } from '../api/apiConstants';
import { getAliases } from '../api/ontimeApi';
import { getAliases } from '../api/aliases';
import { ALIASES } from '../api/constants';
export default function useAliases() {
const { data, status, isFetching, isError, refetch } = useQuery({
@@ -2,8 +2,8 @@ import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/apiConstants';
import { getCustomFields } from '../api/ontimeApi';
import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields } from '../api/customFields';
const placeholder: CustomFields = {};
@@ -1,9 +1,9 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getHTTP, postHTTP } from '../api/ontimeApi';
import { HTTP_SETTINGS } from '../api/constants';
import { getHTTP, postHTTP } from '../api/http';
import { logAxiosError } from '../api/utils';
import { httpPlaceholder } from '../models/Http';
import { ontimeQueryClient } from '../queryClient';
@@ -2,8 +2,8 @@ import { useQuery } from '@tanstack/react-query';
import { GetInfo } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_INFO } from '../api/apiConstants';
import { getInfo } from '../api/ontimeApi';
import { APP_INFO } from '../api/constants';
import { getInfo } from '../api/db';
import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() {
@@ -1,9 +1,9 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getOSC, postOSC } from '../api/ontimeApi';
import { OSC_SETTINGS } from '../api/constants';
import { getOSC, postOSC } from '../api/osc';
import { logAxiosError } from '../api/utils';
import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient';
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_DATA } from '../api/apiConstants';
import { getProjectData } from '../api/projectDataApi';
import { PROJECT_DATA } from '../api/constants';
import { getProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
@@ -2,8 +2,8 @@ import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/apiConstants';
import { getProjects } from '../api/ontimeApi';
import { PROJECT_LIST } from '../api/constants';
import { getProjects } from '../api/db';
const placeholderProjectList: ProjectFileListResponse = {
files: [],
@@ -3,8 +3,8 @@ import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN } from '../api/apiConstants';
import { fetchCachedRundown } from '../api/eventsApi';
import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
@@ -12,7 +12,7 @@ const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as Normali
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
queryFn: fetchCachedRundown,
queryFn: fetchNormalisedRundown,
placeholderData: cachedRundownPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_SETTINGS } from '../api/apiConstants';
import { getSettings } from '../api/ontimeApi';
import { APP_SETTINGS } from '../api/constants';
import { getSettings } from '../api/settings';
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() {
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { VIEW_SETTINGS } from '../api/apiConstants';
import { getView } from '../api/ontimeApi';
import { VIEW_SETTINGS } from '../api/constants';
import { getView } from '../api/viewSettings';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
@@ -3,8 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { RUNDOWN } from '../api/constants';
import {
ReorderEntry,
requestApplyDelay,
@@ -16,7 +15,8 @@ import {
requestPutEvent,
requestReorderEvent,
SwapEntry,
} from '../api/eventsApi';
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
@@ -6,7 +6,7 @@
import { useMemo, useRef } from 'react';
import { isDev } from '../api/apiConstants';
import { isDev } from '../api/constants';
type noop = (this: any, ...args: any[]) => any;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Log, RuntimeStore } from 'ontime-types';
import { isProduction, RUNTIME, websocketUrl } from '../api/apiConstants';
import { isProduction, RUNTIME, websocketUrl } from '../api/constants';
import { ontimeQueryClient } from '../queryClient';
import { socketClientName } from '../stores/connectionName';
import { addLog } from '../stores/logger';
+1 -1
View File
@@ -2,7 +2,7 @@ import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/apiConstants';
import { APP_SETTINGS } from '../api/constants';
import { ontimeQueryClient } from '../queryClient';
/**
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { getLatestVersion, HasUpdate } from '../../../../common/api/ontimeApi';
import { getLatestVersion, HasUpdate } from '../../../../common/api/external';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import style from '../Panel.module.scss';
@@ -52,7 +52,13 @@ export default function CheckUpdatesButton(props: CheckUpdatesButtonProps) {
return (
<>
<Button onClick={versionCheck} variant='ontime-filled' isLoading={isFetching} isDisabled={disableButton} size='sm'>
<Button
onClick={versionCheck}
variant='ontime-filled'
isLoading={isFetching}
isDisabled={disableButton}
size='sm'
>
Check for updates
</Button>
<ResolveUpdateMessage updateMessage={updateMessage} />
@@ -5,7 +5,7 @@ import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { HttpSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import { maybeAxiosError } from '../../../../common/api/utils';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
@@ -5,7 +5,7 @@ import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { OSCSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
@@ -1,4 +1,4 @@
import { serverPort } from '../../../../common/api/apiConstants';
import { serverPort } from '../../../../common/api/constants';
import AppLink from '../../../../common/components/app-link/AppLink';
import useInfo from '../../../../common/hooks-query/useInfo';
@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import { createProject } from '../../../../common/api/ontimeApi';
import { PROJECT_LIST } from '../../../../common/api/constants';
import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import * as Panel from '../PanelUtils';
import style from './ProjectPanel.module.scss';
@@ -25,6 +27,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const { onClose } = props;
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const {
handleSubmit,
@@ -53,6 +56,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
...values,
filename,
});
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
onClose();
} catch (error) {
setError(maybeAxiosError(error));
@@ -2,7 +2,6 @@ import { useState } from 'react';
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/apiUtils';
import {
deleteProject,
downloadCSV,
@@ -10,7 +9,8 @@ import {
duplicateProject,
loadProject,
renameProject,
} from '../../../../common/api/ontimeApi';
} from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import ProjectForm, { ProjectFormValues } from './ProjectForm';
@@ -2,8 +2,8 @@ import { ChangeEvent, useRef, useState } from 'react';
import { Button, Input } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/apiUtils';
import { importProjectFile } from '../../../../common/api/ontimeApi';
import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
import * as Panel from '../PanelUtils';
@@ -37,7 +37,7 @@ export default function ProjectPanel() {
try {
validateProjectFile(selectedFile);
await importProjectFile(selectedFile);
await uploadProjectFile(selectedFile);
} catch (error) {
const errorMessage = maybeAxiosError(error);
setError(`Error uploading file: ${errorMessage}`);
@@ -48,6 +48,10 @@ export default function ProjectPanel() {
setLoading(null);
};
const handleCloseForm = () => {
setIsCreatingProject(false);
};
return (
<>
<Panel.Header>Project</Panel.Header>
@@ -85,7 +89,7 @@ export default function ProjectPanel() {
</div>
</Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>}
{isCreatingProject && <ProjectCreateForm onClose={() => setIsCreatingProject(false)} />}
{isCreatingProject && <ProjectCreateForm onClose={handleCloseForm} />}
<ProjectList />
</Panel.Card>
</Panel.Section>
@@ -4,7 +4,7 @@ import { Button, Input } from '@chakra-ui/react';
import { CustomField } from 'ontime-types';
import { isAlphanumeric } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import { maybeAxiosError } from '../../../../common/api/utils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import * as Panel from '../PanelUtils';
@@ -3,7 +3,7 @@ import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/ontimeApi';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/customFields';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import * as Panel from '../PanelUtils';
@@ -4,8 +4,8 @@ import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { ImportMap, unpackError } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import { importSpreadsheetPreview } from '../../../../common/api/ontimeApi';
import { importSpreadsheetPreview } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import { validateSpreadsheetImport } from '../../../../common/utils/uploadUtils';
import * as Panel from '../PanelUtils';
@@ -2,16 +2,16 @@ import { useQueryClient } from '@tanstack/react-query';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/apiConstants';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants';
import { patchData } from '../../../../common/api/db';
import {
patchData,
previewRundown,
requestConnection,
revokeAuthentication,
uploadRundown,
verifyAuthenticationStatus,
} from '../../../../common/api/ontimeApi';
} from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import { useSheetStore } from './useSheetStore';
@@ -5,8 +5,8 @@ import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { Alias } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postAliases } from '../../../common/api/ontimeApi';
import { postAliases } from '../../../common/api/aliases';
import { logAxiosError } from '../../../common/api/utils';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import useAliases from '../../../common/hooks-query/useAliases';
import { useEmitLog } from '../../../common/stores/logger';
@@ -3,8 +3,8 @@ import { useForm } from 'react-hook-form';
import { Input, Select } from '@chakra-ui/react';
import type { Settings } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postSettings } from '../../../common/api/ontimeApi';
import { postSettings } from '../../../common/api/settings';
import { logAxiosError } from '../../../common/api/utils';
import useSettings from '../../../common/hooks-query/useSettings';
import { isOnlyNumbers } from '../../../common/utils/regex';
import ModalLoader from '../modal-loader/ModalLoader';
@@ -3,8 +3,8 @@ import { useForm } from 'react-hook-form';
import { Input, Textarea } from '@chakra-ui/react';
import { ProjectData } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postProjectData } from '../../../common/api/projectDataApi';
import { postProjectData } from '../../../common/api/project';
import { logAxiosError } from '../../../common/api/utils';
import useProjectData from '../../../common/hooks-query/useProjectData';
import ModalLoader from '../modal-loader/ModalLoader';
import { inputProps } from '../modalHelper';
@@ -3,8 +3,8 @@ import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, AlertTitle, Input, Switch } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postViewSettings } from '../../../common/api/ontimeApi';
import { logAxiosError } from '../../../common/api/utils';
import { postViewSettings } from '../../../common/api/viewSettings';
import { PopoverPickerRHF } from '../../../common/components/input/popover-picker/PopoverPicker';
import useInfo from '../../../common/hooks-query/useInfo';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
@@ -2,7 +2,7 @@ import { MouseEvent } from 'react';
import { isOntimeEvent, OntimeEvent, RundownCached } from 'ontime-types';
import { create } from 'zustand';
import { RUNDOWN } from '../../common/api/apiConstants';
import { RUNDOWN } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils';
@@ -5,7 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { CustomFields, Message, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { millisToString, removeLeadingZero } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { overrideStylesURL } from '../../../common/api/constants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
import Schedule from '../../../common/components/schedule/Schedule';
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { overrideStylesURL } from '../../../common/api/constants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { getClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom';
import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { millisToString, removeLeadingZero } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { overrideStylesURL } from '../../../common/api/constants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { getCountdownOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { overrideStylesURL } from '../../../common/api/constants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { getLowerThirdOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom';
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { overrideStylesURL } from '../../../common/api/constants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
@@ -4,7 +4,7 @@ import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { CustomFields, Message, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { overrideStylesURL } from '../../../common/api/constants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
@@ -4,7 +4,7 @@ import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-
import { isOntimeEvent, Playback } from 'ontime-types';
import { millisToString, removeSeconds } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { overrideStylesURL } from '../../../common/api/constants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { getStudioClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
@@ -13,7 +13,7 @@ import {
} from 'ontime-types';
import { millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { overrideStylesURL } from '../../../common/api/constants';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import TitleCard from '../../../common/components/title-card/TitleCard';
+1 -1
View File
@@ -3,9 +3,9 @@ import { LogOrigin, OSCSettings } from 'ontime-types';
import { Server } from 'node-osc';
import { IAdapter } from './IAdapter.js';
import { dispatchFromAdapter, type ChangeOptions } from '../controllers/integrationController.js';
import { logger } from '../classes/Logger.js';
import { objectFromPath } from './utils/parse.js';
import { type ChangeOptions, dispatchFromAdapter } from '../api-integration/integration.controller.js';
export class OscServer implements IAdapter {
private readonly osc: Server;
+1 -1
View File
@@ -22,8 +22,8 @@ import type { Server } from 'http';
import getRandomName from '../utils/getRandomName.js';
import { IAdapter } from './IAdapter.js';
import { eventStore } from '../stores/EventStore.js';
import { dispatchFromAdapter } from '../controllers/integrationController.js';
import { logger } from '../classes/Logger.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
let instance: SocketServer | null = null;
@@ -0,0 +1,31 @@
import type { Alias, ErrorResponse } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failIsNotArray } from '../../utils/routerUtils.js';
export async function getAliases(_req: Request, res: Response<Alias[]>) {
const aliases = DataProvider.getAliases();
res.status(200).send(aliases);
}
export async function postAliases(req: Request, res: Response<Alias[] | ErrorResponse>) {
if (failIsNotArray(req.body, res)) {
return;
}
try {
const newAliases: Alias[] = [];
req.body.forEach((a) => {
newAliases.push({
enabled: a.enabled,
alias: a.alias,
pathAndParams: a.pathAndParams,
});
});
await DataProvider.setAliases(newAliases);
res.status(200).send(newAliases);
} catch (error) {
res.status(400).send({ message: String(error) });
}
}
@@ -0,0 +1,8 @@
import express from 'express';
import { getAliases, postAliases } from './aliases.controller.js';
import { validateAliases } from './aliases.validation.js';
export const router = express.Router();
router.get('/', getAliases);
router.post('/', validateAliases, postAliases);
@@ -0,0 +1,18 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/aliases
*/
export const validateAliases = [
body().isArray(),
body('*.enabled').isBoolean(),
body('*.alias').isString().trim(),
body('*.pathAndParams').isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,49 @@
import { CustomField, CustomFields } from 'ontime-types';
import type { Request, Response } from 'express';
import {
createCustomField,
editCustomField,
getCustomFields as getCustomFieldsFromCache,
removeCustomField,
} from '../../services/rundown-service/rundownCache.js';
export async function getCustomFields(_req: Request, res: Response<CustomFields>) {
const customFields = getCustomFieldsFromCache();
res.json(customFields);
}
// Expects { label: <label> type: 'string | ..' }
export async function postCustomField(req: Request, res: Response) {
try {
const newField = req.body as CustomField;
const allFields = await createCustomField(newField);
res.status(201).send(allFields);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
// Expects { label: <oldLabel>, field: { label: <newLabel> type: 'string | ..' } }
export async function putCustomField(req: Request, res: Response) {
try {
const oldLabel = req.params.label;
const { colour, type, label } = req.body;
const newFields = await editCustomField(oldLabel, { label, colour, type });
res.status(200).send(newFields);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
// Expects { label: <label> }
export async function deleteCustomField(req: Request, res: Response) {
try {
const fieldToDelete = req.params.label;
await removeCustomField(fieldToDelete);
res.sendStatus(204);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
@@ -0,0 +1,14 @@
import express from 'express';
import { deleteCustomField, getCustomFields, postCustomField, putCustomField } from './customFields.controller.js';
import { validateCustomField, validateDeleteCustomField, validateEditCustomField } from './customFields.validation.js';
export const router = express.Router();
router.get('/', getCustomFields);
router.post('/', validateCustomField, postCustomField);
router.put('/:label', validateEditCustomField, putCustomField);
router.delete('/:label', validateDeleteCustomField, deleteCustomField);
@@ -3,24 +3,6 @@ import { isAlphanumeric } from 'ontime-utils';
import { Request, Response, NextFunction } from 'express';
import { body, param, validationResult } from 'express-validator';
import { ensureJsonExtension } from '../utils/fileManagement.js';
export const projectSanitiser = [
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
body('publicInfo').optional().isString().trim(),
body('backstageUrl').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateCustomField = [
body('label')
.exists()
@@ -61,14 +43,3 @@ export const validateDeleteCustomField = [
next();
},
];
export const sanitizeProjectFilename = (req: Request, _res: Response, next: NextFunction) => {
const { filename, newFilename } = req.body;
const { filename: projectName } = req.params;
req.body.filename = ensureJsonExtension(filename);
req.body.newFilename = ensureJsonExtension(newFilename);
req.params.filename = ensureJsonExtension(projectName);
next();
};
@@ -0,0 +1,285 @@
import {
DatabaseModel,
ErrorResponse,
GetInfo,
MessageResponse,
ProjectData,
ProjectFileListResponse,
} from 'ontime-types';
import type { Request, Response } from 'express';
import fs from 'fs';
import { join } from 'path';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { resolveDbPath, resolveProjectsDirectory } from '../../setup/index.js';
import * as projectService from '../../services/project-service/ProjectService.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
import { setRundown } from '../../services/rundown-service/RundownService.js';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
import { appStateService } from '../../services/app-state-service/AppStateService.js';
import { handleMaybeExcel } from '../../utils/parser.js';
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
// all fields are optional in validation
if (failEmptyObjects(req.body, res)) {
res.status(400).send({ message: 'No field found to patch' });
return;
}
try {
const patchDb: Partial<DatabaseModel> = {
project: req.body?.project,
settings: req.body?.settings,
viewSettings: req.body?.viewSettings,
osc: req.body?.osc,
aliases: req.body?.aliases,
customFields: req.body?.customFields,
};
const maybeRundown = req.body?.rundown;
await DataProvider.mergeIntoData(patchDb);
if (maybeRundown !== undefined) {
// it is likely cheaper to invalidate cache than to calculate diff
runtimeService.stop();
await setRundown(maybeRundown);
}
const newData = DataProvider.getData();
res.status(200).send(newData);
} catch (error) {
res.status(400).send({ message: String(error) });
}
}
/**
* Creates a new project file.
* Receives the project filename (`filename`) from the request body.
*
* @param {Request} req - The express request object. Expects `filename` in the request body.
* @param {Response} res - The express response object. Sends a 200 status with a success message upon successful creation,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const originalFilename = ensureJsonExtension(req.body.title || 'Untitled');
const filename = generateUniqueFileName(resolveProjectsDirectory, originalFilename);
const errors = projectService.validateProjectFiles({ newFilename: filename });
if (errors.length) {
return res.status(409).send({ message: 'Project with title already exists' });
}
const newProjectData: ProjectData = {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
};
projectService.createProjectFile(filename, newProjectData);
res.status(200).send({
filename,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function projectDownload(_req: Request, res: Response) {
const { title } = DataProvider.getProjectData();
const fileTitle = title || 'ontime data';
res.download(resolveDbPath, `${fileTitle}.json`, (err) => {
if (err) {
res.status(500).send({
message: `Could not download the file: ${err}`,
});
}
});
}
/**
* uploads, parses and applies the data from a given file
*/
export async function postProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const options = req.query;
const filePath = req.file.path;
await projectService.applyProjectFile(filePath, options);
res.status(201).send({ message: 'ok' });
} catch (error) {
res.status(400).send({ message: `Failed parsing ${error}` });
}
}
/**
* Retrieves and lists all project files from the uploads directory.
*/
export async function listProjects(_req: Request, res: Response<ProjectFileListResponse | ErrorResponse>) {
try {
const data = await projectService.getProjectList();
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* Receives a `filename` from the request body and loads the project file from the uploads directory.
*/
export async function loadProject(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const filename = req.body.filename;
const filePath = join(resolveProjectsDirectory, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).send({ message: 'File not found' });
}
await projectService.applyProjectFile(filePath);
res.status(201).send({
message: `Loaded project ${filename}`,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* Duplicates a project file.
* Receives the original project filename (`filename`) from the request parameters
* and the filename for the duplicate (`newFilename`) from the request body.
*
* @param {Request} req - The express request object. Expects `filename` in the request parameters and `newFilename` in the request body.
* @param {Response} res - The express response object. Sends a 201 status with a success message upon successful duplication,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export async function duplicateProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { filename } = req.params;
const { newFilename } = req.body;
const errors = projectService.validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
await projectService.duplicateProjectFile(filename, newFilename);
res.status(201).send({
message: `Duplicated project ${filename} to ${newFilename}`,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* Renames a project file.
* Receives the current filename (`filename`) from the request parameters
* and the new filename (`newFilename`) from the request body.
*
* @param {Request} req - The express request object. Expects `filename` in the request parameters and `newFilename` in the request body.
* @param {Response} res - The express response object. Sends a 201 status with a success message upon successful renaming,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export async function renameProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { newFilename } = req.body;
const { filename } = req.params;
const errors = projectService.validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
// Rename the file
await projectService.renameProjectFile(filename, newFilename);
res.status(201).send({
message: `Renamed project ${filename} to ${newFilename}`,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* Deletes an existing project file.
* Receives the project filename (`filename`) from the request parameters.
*
* @param {Request} req - The express request object. Expects `filename` in the request parameters.
* @param {Response} res - The express response object. Sends a 204 status with a success message upon successful deletion,
* a 403 status if attempting to delete the currently loaded project,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export async function deleteProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { filename } = req.params;
const { lastLoadedProject } = await appStateService.get();
if (lastLoadedProject === filename) {
return res.status(403).send({ message: 'Cannot delete currently loaded project' });
}
const errors = projectService.validateProjectFiles({ filename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
await projectService.deleteProjectFile(filename);
res.status(204).send({
message: `Deleted project ${filename}`,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function getInfo(_req: Request, res: Response<GetInfo>) {
const info = await projectService.getInfo();
res.status(200).send(info);
}
/**
* uploads and parses an excel spreadsheet
* @returns parsed result
*/
export async function previewSpreadsheet(req: Request, res: Response) {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const filePath = req.file.path;
if (!fs.existsSync(filePath)) {
throw new Error('Upload failed');
}
const options = JSON.parse(req.body.options);
const data = handleMaybeExcel(filePath, options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
@@ -0,0 +1,32 @@
import { Request } from 'express';
import multer, { FileFilterCallback } from 'multer';
import { EXCEL_MIME, JSON_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(JSON_MIME)) {
cb(null, true);
} else {
cb(null, false);
}
};
const filterSpreadsheet = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(EXCEL_MIME)) {
cb(null, true);
} else {
cb(null, false);
}
};
// Build multer uploader for a single file
export const uploadProjectFile = multer({
storage,
fileFilter: filterProjectFile,
}).single('project');
export const uploadSpreadsheet = multer({
storage,
fileFilter: filterSpreadsheet,
}).single('spreadsheet');
+44
View File
@@ -0,0 +1,44 @@
import express from 'express';
import {
createProjectFile,
projectDownload,
deleteProjectFile,
getInfo,
listProjects,
patchPartialProjectFile,
previewSpreadsheet,
loadProject,
duplicateProjectFile,
renameProjectFile,
postProjectFile,
} from './db.controller.js';
import { uploadProjectFile, uploadSpreadsheet } from './db.middleware.js';
import {
projectSanitiser,
sanitizeProjectFilename,
validateLoadProjectFile,
validatePatchProjectFile,
validateProjectDuplicate,
validateProjectRename,
} from './db.validation.js';
export const router = express.Router();
router.get('/download', projectDownload);
router.post('/upload', uploadProjectFile, postProjectFile);
router.patch('/', validatePatchProjectFile, patchPartialProjectFile);
router.post('/new', projectSanitiser, createProjectFile);
router.get('/all', listProjects);
router.post('/load', validateLoadProjectFile, sanitizeProjectFilename, loadProject);
router.post('/:filename/duplicate', validateProjectDuplicate, sanitizeProjectFilename, duplicateProjectFile);
router.put('/:filename/rename', validateProjectRename, sanitizeProjectFilename, renameProjectFile);
router.delete('/:filename', sanitizeProjectFilename, deleteProjectFile);
router.get('/info', getInfo);
// TODO: validate import map
router.post('/spreadsheet/preview', uploadSpreadsheet, previewSpreadsheet);
@@ -0,0 +1,106 @@
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
export const projectSanitiser = [
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
body('publicInfo').optional().isString().trim(),
body('backstageUrl').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const sanitizeProjectFilename = (req: Request, _res: Response, next: NextFunction) => {
const { filename, newFilename } = req.body;
const { filename: projectName } = req.params;
req.body.filename = ensureJsonExtension(filename);
req.body.newFilename = ensureJsonExtension(newFilename);
req.params.filename = ensureJsonExtension(projectName);
next();
};
export const validatePatchProjectFile = [
body('rundown').isArray().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }),
body('viewSettings').isObject().optional({ nullable: false }),
body('aliases').isArray().optional({ nullable: false }),
body('customFields').isObject().optional({ nullable: false }),
body('osc').isObject().optional({ nullable: false }),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates the filename for loading a project file.
*/
export const validateLoadProjectFile = [
body('filename').exists().withMessage('Filename is required').isString().withMessage('Filename must be a string'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
* @description Validates the filenames for duplicating a project.
*/
export const validateProjectDuplicate = [
body('newFilename')
.exists()
.withMessage('New project filename is required')
.isString()
.withMessage('New project filename must be a string')
.isLength({ min: 1, max: 255 })
.withMessage('New project filename must be between 1 and 255 characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
* @description Validates the filenames for renaming a project.
*/
export const validateProjectRename = [
body('newFilename')
.exists()
.withMessage('Duplicate project filename is required')
.isString()
.withMessage('Duplicate project filename must be a string')
.isLength({ min: 1, max: 255 })
.withMessage('Duplicate project filename must be between 1 and 255 characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
@@ -0,0 +1,29 @@
import type { ErrorResponse, HttpSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
export async function getHTTP(_req: Request, res: Response<HttpSettings>) {
const http = DataProvider.getHttp();
res.status(200).send(http);
}
export async function postHTTP(req: Request, res: Response<HttpSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const httpSettings = req.body;
httpIntegration.init(httpSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setHttp(httpSettings);
res.send(result).status(200);
} catch (error) {
res.status(400).send({ message: String(error) });
}
}
@@ -0,0 +1,9 @@
import express from 'express';
import { validateHTTP } from './http.validation.js';
import { getHTTP, postHTTP } from './http.controller.js';
export const router = express.Router();
router.get('/', getHTTP);
router.post('/', validateHTTP, postHTTP);
@@ -0,0 +1,21 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
import { sanitiseHttpSubscriptions } from '../../utils/parserFunctions.js';
/**
* @description Validates object for POST /ontime/http
*/
export const validateHTTP = [
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.exists()
.isArray()
.custom((value) => sanitiseHttpSubscriptions(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
+25
View File
@@ -0,0 +1,25 @@
import express from 'express';
import { router as aliasesRouter } from './aliases/aliases.router.js';
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
import { router as dbRouter } from './db/db.router.js';
import { router as httpRouter } from './http/http.router.js';
import { router as oscRouter } from './osc/osc.router.js';
import { router as projectRouter } from './project/project.router.js';
import { router as rundownRouter } from './rundown/rundown.router.js';
import { router as settingsRouter } from './settings/settings.router.js';
import { router as sheetsRouter } from './sheets/sheets.router.js';
import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js';
export const appRouter = express.Router();
appRouter.use('/aliases', aliasesRouter);
appRouter.use('/custom-fields', customFieldsRouter);
appRouter.use('/db', dbRouter);
appRouter.use('/http', httpRouter);
appRouter.use('/osc', oscRouter);
appRouter.use('/project', projectRouter);
appRouter.use('/rundown', rundownRouter);
appRouter.use('/settings', settingsRouter);
appRouter.use('/sheets', sheetsRouter);
appRouter.use('/view-settings', viewSettingsRouter);
@@ -0,0 +1,29 @@
import type { ErrorResponse, OSCSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
export async function getOSC(_req: Request, res: Response<OSCSettings>) {
const osc = DataProvider.getOsc();
res.status(200).send(osc);
}
export async function postOSC(req: Request, res: Response<OSCSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const oscSettings = req.body;
oscIntegration.init(oscSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setOsc(oscSettings);
res.send(result).status(200);
} catch (error) {
res.status(400).send({ message: String(error) });
}
}
@@ -0,0 +1,8 @@
import express from 'express';
import { getOSC, postOSC } from './osc.controller.js';
import { validateOSC } from './osc.validation.js';
export const router = express.Router();
router.get('/', getOSC);
router.post('/', validateOSC, postOSC);
@@ -0,0 +1,25 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
import { sanitiseOscSubscriptions } from '../../utils/parserFunctions.js';
/**
* @description Validates object for POST /ontime/osc
*/
export const validateOSC = [
body('portIn').exists().isPort(),
body('portOut').exists().isPort(),
body('targetIP').exists().isIP(),
body('enabledIn').exists().isBoolean(),
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.exists()
.isArray()
.custom((value) => sanitiseOscSubscriptions(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,32 @@
import { ErrorResponse, ProjectData } from 'ontime-types';
import type { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
export async function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(DataProvider.getProjectData());
}
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newEvent: Partial<ProjectData> = removeUndefined({
title: req.body?.title,
description: req.body?.description,
publicUrl: req.body?.publicUrl,
publicInfo: req.body?.publicInfo,
backstageUrl: req.body?.backstageUrl,
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
});
const newData = await DataProvider.setProjectData(newEvent);
res.status(200).send(newData);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
@@ -0,0 +1,9 @@
import express from 'express';
import { getProjectData, postProjectData } from './project.controller.js';
import { projectSanitiser } from './project.validation.js';
export const router = express.Router();
router.get('/', getProjectData);
router.post('/', projectSanitiser, postProjectData);
@@ -0,0 +1,18 @@
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
export const projectSanitiser = [
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
body('publicInfo').optional().isString().trim(),
body('backstageUrl').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -1,8 +1,8 @@
import { RundownCached } from 'ontime-types';
import { ErrorResponse, MessageResponse, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { Request, Response, RequestHandler } from 'express';
import { Request, Response } from 'express';
import { failEmptyObjects } from '../utils/routerUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import {
addEvent,
applyDelay,
@@ -12,26 +12,20 @@ import {
editEvent,
reorderEvent,
swapEvents,
} from '../services/rundown-service/RundownService.js';
import { getNormalisedRundown, getRundown } from '../services/rundown-service/rundownUtils.js';
} from '../../services/rundown-service/RundownService.js';
import { getNormalisedRundown, getRundown } from '../../services/rundown-service/rundownUtils.js';
// Create controller for GET request to '/events'
// Returns -
export const rundownGetAll: RequestHandler = async (_req, res) => {
export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) {
const rundown = getRundown();
res.json(rundown);
};
}
// Create controller for GET request to '/events/cached'
// Returns -
export const rundownGetCached: RequestHandler = async (_req: Request, res: Response<RundownCached>) => {
export async function rundownGetNormalised(_req: Request, res: Response<RundownCached>) {
const cachedRundown = getNormalisedRundown();
res.json(cachedRundown);
};
}
// Create controller for POST request to '/events/'
// Returns -
export const rundownPost: RequestHandler = async (req, res) => {
export async function rundownPost(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
@@ -42,11 +36,9 @@ export const rundownPost: RequestHandler = async (req, res) => {
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
}
// Create controller for PUT request to '/events/'
// Returns -
export const rundownPut: RequestHandler = async (req, res) => {
export async function rundownPut(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
@@ -57,9 +49,9 @@ export const rundownPut: RequestHandler = async (req, res) => {
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
}
export const rundownBatchPut: RequestHandler = async (req, res) => {
export async function rundownBatchPut(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return res.status(404);
}
@@ -67,13 +59,13 @@ export const rundownBatchPut: RequestHandler = async (req, res) => {
try {
const { data, ids } = req.body;
await batchEditEvents(ids, data);
res.status(200);
res.status(200).send({ message: 'Batch edit successful' });
} catch (error) {
res.status(400).send(error);
}
};
}
export const rundownReorder: RequestHandler = async (req, res) => {
export async function rundownReorder(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
@@ -81,13 +73,13 @@ export const rundownReorder: RequestHandler = async (req, res) => {
try {
const { eventId, from, to } = req.body;
const event = await reorderEvent(eventId, from, to);
res.status(200).send(event);
res.status(200).send(event.newEvent);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
}
export const rundownSwap: RequestHandler = async (req, res) => {
export async function rundownSwap(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
@@ -95,41 +87,35 @@ export const rundownSwap: RequestHandler = async (req, res) => {
try {
const { from, to } = req.body;
await swapEvents(from, to);
res.sendStatus(200);
res.status(200).send({ message: 'Swap successful' });
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
}
// Create controller for PATCH request to '/events/applydelay/:eventId'
// Returns -
export const rundownApplyDelay: RequestHandler = async (req, res) => {
export async function rundownApplyDelay(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await applyDelay(req.params.eventId);
res.sendStatus(200);
res.status(200).send({ message: 'Delay applied' });
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
}
// Create controller for DELETE request to '/events/:eventId'
// Returns -
export const deleteEventById: RequestHandler = async (req, res) => {
try {
await deleteEvent(req.params.eventId);
res.sendStatus(204);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
// Create controller for DELETE request to '/events/'
// Returns -
export const rundownDelete: RequestHandler = async (req, res) => {
export async function rundownDelete(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await deleteAllEvents();
res.sendStatus(204);
res.status(204).send({ message: 'All events deleted' });
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
}
export async function deleteEventById(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await deleteEvent(req.params.eventId);
res.status(204).send({ message: 'Event deleted' });
} catch (error) {
res.status(400).send({ message: error.toString() });
}
}
@@ -1,16 +1,17 @@
import express from 'express';
import {
deleteEventById,
rundownApplyDelay,
rundownBatchPut,
rundownDelete,
rundownGetAll,
rundownGetCached,
rundownGetNormalised,
rundownPost,
rundownPut,
rundownReorder,
rundownSwap,
rundownBatchPut,
} from '../controllers/rundownController.js';
} from './rundown.controller.js';
import {
paramsMustHaveEventId,
rundownBatchPutValidator,
@@ -18,34 +19,21 @@ import {
rundownPutValidator,
rundownReorderValidator,
rundownSwapValidator,
} from '../controllers/rundownController.validate.js';
} from './rundown.validation.js';
export const router = express.Router();
// create route between controller and '/events/cached' endpoint
router.get('/cached', rundownGetCached);
router.get('/', rundownGetAll); // not used in Ontime frontend
router.get('/normalised', rundownGetNormalised);
// create route between controller and '/events/' endpoint
router.get('/', rundownGetAll);
// create route between controller and '/events/' endpoint
router.post('/', rundownPostValidator, rundownPost);
// create route between controller and '/events/' endpoint
router.put('/', rundownPutValidator, rundownPut);
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
router.put('/batchEdit', rundownBatchPutValidator, rundownBatchPut);
// create route between controller and '/events/reorder' endpoint
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
router.patch('/swap', rundownSwapValidator, rundownSwap);
// create route between controller and '/events/applydelay/:eventId' endpoint
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
// create route between controller and '/events/all' endpoint
router.delete('/all', rundownDelete);
// create route between controller and '/events/:eventId' endpoint
router.delete('/:eventId', paramsMustHaveEventId, deleteEventById);
@@ -0,0 +1,54 @@
import { ErrorResponse, Settings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { extractPin } from '../../services/project-service/ProjectService.js';
import { isDocker } from '../../setup/index.js';
export async function getSettings(_req: Request, res: Response<Settings>) {
const settings = DataProvider.getSettings();
res.status(200).send(settings);
}
export async function postSettings(req: Request, res: Response<Settings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const settings = DataProvider.getSettings();
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
const serverPort = Number(req.body?.serverPort);
if (isNaN(serverPort)) {
return res.status(400).send({ message: `Invalid value found for server port: ${req.body?.serverPort}` });
}
const hasChangedPort = settings.serverPort !== serverPort;
if (isDocker && hasChangedPort) {
return res.status(403).json({ message: 'Can`t change port when running inside docker' });
}
let timeFormat = settings.timeFormat;
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
timeFormat = req.body.timeFormat;
}
const language = req.body?.language || 'en';
const newData = {
...settings,
editorKey,
operatorKey,
timeFormat,
language,
serverPort,
};
await DataProvider.setSettings(newData);
res.status(200).send(newData);
} catch (error) {
res.status(400).send({ message: String(error) });
}
}
@@ -0,0 +1,8 @@
import express from 'express';
import { getSettings, postSettings } from './settings.controller.js';
import { validateSettings } from './settings.validation.js';
export const router = express.Router();
router.get('/', getSettings);
router.post('/', validateSettings, postSettings);
@@ -0,0 +1,19 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/settings
*/
export const validateSettings = [
body('editorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
body('operatorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
body('timeFormat').isString().isIn(['12', '24']),
body('language').isString(),
body('serverPort').isPort().optional(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -6,7 +6,7 @@
import { Request, Response } from 'express';
import { readFileSync } from 'fs';
import { deleteFile } from '../utils/parserUtils.js';
import { deleteFile } from '../../utils/parserUtils.js';
import {
revoke,
handleClientSecret,
@@ -14,7 +14,7 @@ import {
hasAuth,
download,
upload,
} from '../services/sheet-service/SheetService.js';
} from '../../services/sheet-service/SheetService.js';
export async function requestConnection(req: Request, res: Response) {
const { sheetId } = req.params;
@@ -0,0 +1,18 @@
import { Request } from 'express';
import multer, { FileFilterCallback } from 'multer';
import { JSON_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
const filterClientSecret = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(JSON_MIME)) {
cb(null, true);
} else {
cb(null, false);
}
};
export const uploadClientSecret = multer({
storage,
fileFilter: filterClientSecret,
}).single('client_secret');
@@ -0,0 +1,25 @@
/**
* This is a feature specific router for integration with google sheets
*/
import express from 'express';
import {
readFromSheet,
requestConnection,
revokeAuthentication,
verifyAuthentication,
writeToSheet,
} from './sheets.controller.js';
import { uploadClientSecret } from './sheets.middleware.js';
import { validateRequestConnection, validateSheetOptions } from './sheets.validation.js';
export const router = express.Router();
router.get('/connect', verifyAuthentication);
router.post('/:sheetId/connect', uploadClientSecret, validateRequestConnection, requestConnection);
router.post('/revoke', revokeAuthentication);
router.post('/:sheetId/read', validateSheetOptions, readFromSheet);
router.post('/:sheetId/write', validateSheetOptions, writeToSheet);
@@ -0,0 +1,33 @@
import type { ErrorResponse, ViewSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
export async function getViewSettings(_req: Request, res: Response<ViewSettings>) {
const views = DataProvider.getViewSettings();
res.status(200).send(views);
}
export async function postViewSettings(req: Request, res: Response<ViewSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newData = {
overrideStyles: req.body.overrideStyles,
endMessage: req.body?.endMessage || '',
normalColor: req.body.normalColor,
warningColor: req.body.warningColor,
warningThreshold: req.body.warningThreshold,
dangerColor: req.body.dangerColor,
dangerThreshold: req.body.dangerThreshold,
};
await DataProvider.setViewSettings(newData);
res.status(200).send(newData);
} catch (error) {
res.status(400).send({ message: String(error) });
}
}
@@ -0,0 +1,9 @@
import express from 'express';
import { validateViewSettings } from './viewSettings.validation.js';
import { getViewSettings, postViewSettings } from './viewSettings.controller.js';
export const router = express.Router();
router.get('/', getViewSettings);
router.post('/', validateViewSettings, postViewSettings);
@@ -0,0 +1,20 @@
import { check, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/views
*/
export const validateViewSettings = [
check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'),
check('endMessage').isString().trim().withMessage('endMessage value must be string'),
check('normalColor').isString().trim().withMessage('normalColor value must be string'),
check('warningColor').isString().trim().withMessage('warningColor value must be string'),
check('dangerColor').isString().trim().withMessage('dangerColor value must be string'),
check('warningThreshold').isNumeric().withMessage('warningThreshold value must be a number'),
check('dangerThreshold').isNumeric().withMessage('dangerThreshold value must a number'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -7,10 +7,11 @@ import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
import { messageService } from '../services/message-service/MessageService.js';
import { runtimeService } from '../services/runtime-service/RuntimeService.js';
import { eventStore } from '../stores/EventStore.js';
import { parse, updateEvent } from './integrationController.config.js';
import { extraTimerService } from '../services/extra-timer-service/ExtraTimerService.js';
import { validateMessage, validateTimerMessage } from '../services/message-service/messageUtils.js';
import { parse, updateEvent } from './integration.utils.js';
export type ChangeOptions = {
eventId: string;
property: string;
@@ -0,0 +1,65 @@
/**
* API Router
* User to handle all requests which affect runtime
* It is a mirror implementation of OSC and Websocket Adapters
*
*/
import { ErrorResponse, LogOrigin, RuntimeStore } from 'ontime-types';
import express, { type Request, type Response } from 'express';
import { logger } from '../classes/Logger.js';
import { objectFromPath } from '../adapters/utils/parse.js';
import { dispatchFromAdapter } from './integration.controller.js';
import { unpackError } from 'ontime-utils';
import { eventStore } from '../stores/EventStore.js';
export const integrationRouter = express.Router();
const helloMessage = 'You have reached Ontime API server';
integrationRouter.get('/', (_req: Request, res: Response<{ message: string }>) => {
res.status(200).json({ message: helloMessage });
});
/**
* All calls are sent to the dispatcher
*/
integrationRouter.get('/*', (req: Request, res: Response) => {
let action = req.path.substring(1);
if (!action) {
return res.status(400).json({ error: 'No action found' });
}
try {
const actionArray = action.split('/');
const params = { payload: req.query as object } as { payload: object | null };
if (actionArray.length > 1) {
action = actionArray.shift() || '';
params.payload = objectFromPath(actionArray, params.payload);
}
const reply = dispatchFromAdapter(action, params, 'http');
res.status(202).json(reply);
} catch (error) {
const errorMessage = unpackError(error);
logger.error(LogOrigin.Rx, `HTTP IN: ${errorMessage}`);
res.status(500).send({ message: errorMessage });
}
});
integrationRouter.get('/poll', (_req: Request, res: Response<Partial<RuntimeStore> | ErrorResponse>) => {
try {
const state = eventStore.poll();
res.status(200).send(state);
} catch (error) {
const message = unpackError(error);
res.status(500).send({
message: `Could not get sync data: ${message}`,
});
}
});
@@ -1,4 +1,5 @@
import { OntimeEvent, isKeyOfType, isOntimeEvent } from 'ontime-types';
import { editEvent } from '../services/rundown-service/RundownService.js';
import { getEventWithId } from '../services/rundown-service/rundownUtils.js';
import { coerceString, coerceNumber, coerceBoolean, coerceColour } from '../utils/coerceType.js';
+5 -9
View File
@@ -19,11 +19,9 @@ import {
} from './setup/index.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
// Import Routes
import { router as rundownRouter } from './routes/rundownRouter.js';
import { router as projectRouter } from './routes/projectRouter.js';
import { router as ontimeRouter } from './routes/ontimeRouter.js';
import { router as apiRouter } from './routes/apiRouter.js';
// Import Routers
import { appRouter } from './api-data/index.js';
import { integrationRouter } from './api-integration/integration.router.js';
// Import adapters
import { OscServer } from './adapters/OscAdapter.js';
@@ -70,10 +68,8 @@ app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: '1mb' }));
// Implement route endpoints
app.use('/events', rundownRouter);
app.use('/project', projectRouter);
app.use('/ontime', ontimeRouter);
app.use('/api', apiRouter);
app.use('/data', appRouter); // router for application data
app.use('/api', integrationRouter); // router for integrations
// serve static - css
app.use('/external/styles', express.static(resolveStylesDirectory));
@@ -1,477 +0,0 @@
import type {
Alias,
DatabaseModel,
GetInfo,
HttpSettings,
ProjectData,
ErrorResponse,
ProjectFileListResponse,
OSCSettings,
RuntimeStore,
Settings,
ViewSettings,
} from 'ontime-types';
import { RequestHandler, Request, Response } from 'express';
import fs from 'fs';
import { join } from 'path';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
import { runtimeService } from '../services/runtime-service/RuntimeService.js';
import { eventStore } from '../stores/EventStore.js';
import { isDocker, resolveDbPath, resolveProjectsDirectory, uploadsFolderPath } from '../setup/index.js';
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
import { setRundown } from '../services/rundown-service/RundownService.js';
import { appStateService } from '../services/app-state-service/AppStateService.js';
import type { OntimeError } from '../utils/backend.types.js';
import { generateUniqueFileName } from '../utils/generateUniqueFilename.js';
import * as projectService from '../services/project-service/ProjectService.js';
import { extractPin } from '../services/project-service/ProjectService.js';
import { handleMaybeExcel } from '../utils/parser.js';
import { ensureJsonExtension } from '../utils/fileManagement.js';
// Create controller for GET request to '/ontime/poll'
// Returns data for current state
export const poll = async (_req: Request, res: Response<Partial<RuntimeStore> | ErrorResponse>) => {
try {
const state = eventStore.poll();
res.status(200).send(state);
} catch (error) {
res.status(500).send({
message: `Could not get sync data: ${error}`,
});
}
};
// Create controller for GET request to '/ontime/db'
// Returns -
export const dbDownload = async (_req: Request, res: Response) => {
const { title } = DataProvider.getProjectData();
const fileTitle = title || 'ontime data';
res.download(resolveDbPath, `${fileTitle}.json`, (err) => {
if (err) {
res.status(500).send({
message: `Could not download the file: ${err}`,
});
}
});
};
// Create controller for GET request to '/ontime/info'
// Returns -
export const getInfo = async (_req: Request, res: Response<GetInfo>) => {
const info = await projectService.getInfo();
res.status(200).send(info);
};
// Create controller for GET request to '/ontime/aliases'
// Returns -
export const getAliases = async (_req: Request, res: Response<Alias[]>) => {
const aliases = DataProvider.getAliases();
res.status(200).send(aliases);
};
// Create controller for POST request to '/ontime/aliases'
// Returns ACK message
export const postAliases = async (req: Request, res: Response<Alias[] | ErrorResponse>) => {
if (failIsNotArray(req.body, res)) {
return;
}
try {
const newAliases: Alias[] = [];
req.body.forEach((a) => {
newAliases.push({
enabled: a.enabled,
alias: a.alias,
pathAndParams: a.pathAndParams,
});
});
await DataProvider.setAliases(newAliases);
res.status(200).send(newAliases);
} catch (error) {
res.status(400).send({ message: String(error) });
}
};
// Create controller for GET request to '/ontime/settings'
// Returns -
export const getSettings = async (_req: Request, res: Response<Settings>) => {
const settings = DataProvider.getSettings();
res.status(200).send(settings);
};
// Create controller for POST request to '/ontime/settings'
// Returns ACK message
export const postSettings = async (req: Request, res: Response) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const settings = DataProvider.getSettings();
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
const serverPort = Number(req.body?.serverPort);
if (isNaN(serverPort)) {
return res.status(400).send(`Invalid value found for server port: ${req.body?.serverPort}`);
}
const hasChangedPort = settings.serverPort !== serverPort;
if (isDocker && hasChangedPort) {
return res.status(403).json({ message: 'Can`t change port when running inside docker' });
}
let timeFormat = settings.timeFormat;
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
timeFormat = req.body.timeFormat;
}
const language = req.body?.language || 'en';
const newData = {
...settings,
editorKey,
operatorKey,
timeFormat,
language,
serverPort,
};
await DataProvider.setSettings(newData);
res.status(200).send(newData);
} catch (error) {
res.status(400).send({ message: String(error) });
}
};
/**
* @description Get view Settings
*/
export const getViewSettings = async (_req: Request, res: Response<ViewSettings>) => {
const views = DataProvider.getViewSettings();
res.status(200).send(views);
};
/**
* @description Change view Settings
*/
export const postViewSettings = async (req: Request, res: Response<ViewSettings | ErrorResponse>) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newData = {
overrideStyles: req.body.overrideStyles,
endMessage: req.body?.endMessage || '',
normalColor: req.body.normalColor,
warningColor: req.body.warningColor,
warningThreshold: req.body.warningThreshold,
dangerColor: req.body.dangerColor,
dangerThreshold: req.body.dangerThreshold,
};
await DataProvider.setViewSettings(newData);
res.status(200).send(newData);
} catch (error) {
res.status(400).send({ message: String(error) });
}
};
// Create controller for GET request to '/ontime/osc'
// Returns -
export const getOSC = async (_req: Request, res: Response<OSCSettings>) => {
const osc = DataProvider.getOsc();
res.status(200).send(osc);
};
// Create controller for POST request to '/ontime/osc'
// Returns ACK message
export const postOSC = async (req: Request, res: Response<OSCSettings | OntimeError>) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const oscSettings = req.body;
oscIntegration.init(oscSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setOsc(oscSettings);
res.send(result).status(200);
} catch (error) {
res.status(400).send({ message: String(error) });
}
};
// Create controller for GET request to '/ontime/http'
export const getHTTP = async (_req: Request, res: Response<HttpSettings>) => {
const http = DataProvider.getHttp();
res.status(200).send(http);
};
// Create controller for POST request to '/ontime/http'
export const postHTTP = async (req: Request, res: Response<HttpSettings | OntimeError>) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const httpSettings = req.body;
httpIntegration.init(httpSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setHttp(httpSettings);
res.send(result).status(200);
} catch (error) {
res.status(400).send({ message: String(error) });
}
};
export async function patchPartialProjectFile(req: Request, res: Response) {
// all fields are optional in validation
if (failEmptyObjects(req.body, res)) {
res.status(400).send({ message: 'No field found to patch' });
return;
}
try {
const patchDb: Partial<DatabaseModel> = {
project: req.body?.project,
settings: req.body?.settings,
viewSettings: req.body?.viewSettings,
osc: req.body?.osc,
aliases: req.body?.aliases,
customFields: req.body?.customFields,
};
const maybeRundown = req.body?.rundown;
await DataProvider.mergeIntoData(patchDb);
if (maybeRundown !== undefined) {
// it is likely cheaper to invalidate cache than to calculate diff
runtimeService.stop();
await setRundown(maybeRundown);
}
res.status(200).send();
} catch (error) {
res.status(400).send({ message: String(error) });
}
}
/**
* uploads, parses and applies the data from a given file
*/
export const uploadProjectFile = async (req: Request, res: Response) => {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const options = req.query;
const filePath = req.file.path;
await projectService.applyProjectFile(filePath, options);
res.status(200).send();
} catch (error) {
res.status(400).send({ message: `Failed parsing ${error}` });
}
};
/**
* uploads and parses an excel spreadsheet
* @returns parsed result
*/
export async function previewSpreadsheet(req: Request, res: Response) {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const filePath = req.file.path;
if (!fs.existsSync(filePath)) {
throw new Error('Upload failed');
}
const options = JSON.parse(req.body.options);
const data = handleMaybeExcel(filePath, options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* Retrieves and lists all project files from the uploads directory.
* @param _req
* @param res
*/
export const listProjects: RequestHandler = async (_req, res: Response<ProjectFileListResponse | ErrorResponse>) => {
try {
const data = await projectService.getProjectList();
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
};
/**
* Receives a `filename` from the request body and loads the project file from the uploads directory.
* @param req
* @param res
*/
export const loadProject: RequestHandler = async (req, res) => {
try {
const filename = req.body.filename;
const filePath = join(resolveProjectsDirectory, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).send({ message: 'File not found' });
}
await projectService.applyProjectFile(filePath);
res.status(200).send({
message: `Loaded project ${filename}`,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
};
/**
* Duplicates a project file.
* Receives the original project filename (`filename`) from the request parameters
* and the filename for the duplicate (`newFilename`) from the request body.
*
* @param {Request} req - The express request object. Expects `filename` in the request parameters and `newFilename` in the request body.
* @param {Response} res - The express response object. Sends a 200 status with a success message upon successful duplication,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export const duplicateProjectFile: RequestHandler = async (req: Request, res: Response) => {
try {
const { filename } = req.params;
const { newFilename } = req.body;
const errors = projectService.validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
await projectService.duplicateProjectFile(filename, newFilename);
res.status(200).send({
message: `Duplicated project ${filename} to ${newFilename}`,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
};
/**
* Renames a project file.
* Receives the current filename (`filename`) from the request parameters
* and the new filename (`newFilename`) from the request body.
*
* @param {Request} req - The express request object. Expects `filename` in the request parameters and `newFilename` in the request body.
* @param {Response} res - The express response object. Sends a 200 status with a success message upon successful renaming,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export const renameProjectFile: RequestHandler = async (req: Request, res: Response) => {
try {
const { newFilename } = req.body;
const { filename } = req.params;
const errors = projectService.validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
// Rename the file
await projectService.renameProjectFile(filename, newFilename);
res.status(200).send({
message: `Renamed project ${filename} to ${newFilename}`,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
};
/**
* Creates a new project file.
* Receives the project filename (`filename`) from the request body.
*
* @param {Request} req - The express request object. Expects `filename` in the request body.
* @param {Response} res - The express response object. Sends a 200 status with a success message upon successful creation,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export const createProjectFile: RequestHandler = async (req: Request, res: Response) => {
try {
const originalFilename = ensureJsonExtension(req.body.title || 'Untitled');
const filename = generateUniqueFileName(uploadsFolderPath, originalFilename);
const errors = projectService.validateProjectFiles({ newFilename: filename });
if (errors.length) {
return res.status(409).send({ message: 'Project with title already exists' });
}
const newProjectData: ProjectData = {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
};
projectService.createProjectFile(filename, newProjectData);
res.status(200).send({
filename,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
};
/**
* Deletes an existing project file.
* Receives the project filename (`filename`) from the request parameters.
*
* @param {Request} req - The express request object. Expects `filename` in the request parameters.
* @param {Response} res - The express response object. Sends a 200 status with a success message upon successful deletion,
* a 403 status if attempting to delete the currently loaded project,
* a 409 status if there are validation errors,
* or a 500 status with an error message in case of an exception.
*/
export const deleteProjectFile: RequestHandler = async (req: Request, res: Response) => {
try {
const { filename } = req.params;
const { lastLoadedProject } = await appStateService.get();
if (lastLoadedProject === filename) {
return res.status(403).send({ message: 'Cannot delete currently loaded project' });
}
const errors = projectService.validateProjectFiles({ filename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
await projectService.deleteProjectFile(filename);
res.status(200).send({
message: `Deleted project ${filename}`,
});
} catch (error) {
res.status(500).send({ message: String(error) });
}
};
@@ -1,168 +0,0 @@
import { body, check, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
import { sanitiseHttpSubscriptions, sanitiseOscSubscriptions } from '../utils/parserFunctions.js';
/**
* @description Validates object for POST /ontime/views
*/
export const viewValidator = [
check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'),
check('endMessage').isString().trim().withMessage('endMessage value must be string'),
check('normalColor').isString().trim().withMessage('normalColor value must be string'),
check('warningColor').isString().trim().withMessage('warningColor value must be string'),
check('dangerColor').isString().trim().withMessage('dangerColor value must be string'),
check('warningThreshold').isNumeric().withMessage('warningThreshold value must be a number'),
check('dangerThreshold').isNumeric().withMessage('dangerThreshold value must a number'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/aliases
*/
export const validateAliases = [
body().isArray(),
body('*.enabled').isBoolean(),
body('*.alias').isString().trim(),
body('*.pathAndParams').isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/settings
*/
export const validateSettings = [
body('editorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
body('operatorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
body('timeFormat').isString().isIn(['12', '24']),
body('language').isString(),
body('serverPort').isPort().optional(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/osc
*/
export const validateOSC = [
body('portIn').exists().isPort(),
body('portOut').exists().isPort(),
body('targetIP').exists().isIP(),
body('enabledIn').exists().isBoolean(),
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.exists()
.isArray()
.custom((value) => sanitiseOscSubscriptions(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/http
*/
export const validateHTTP = [
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.exists()
.isArray()
.custom((value) => sanitiseHttpSubscriptions(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validatePatchProjectFile = [
body('rundown').isArray().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }),
body('viewSettings').isObject().optional({ nullable: false }),
body('aliases').isArray().optional({ nullable: false }),
body('customFields').isObject().optional({ nullable: false }),
body('osc').isObject().optional({ nullable: false }),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates the filename for loading a project file.
*/
export const validateLoadProjectFile = [
body('filename').exists().withMessage('Filename is required').isString().withMessage('Filename must be a string'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
* @description Validates the filenames for duplicating a project.
*/
export const validateProjectDuplicate = [
body('newFilename')
.exists()
.withMessage('New project filename is required')
.isString()
.withMessage('New project filename must be a string')
.isLength({ min: 1, max: 255 })
.withMessage('New project filename must be between 1 and 255 characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
* @description Validates the filenames for renaming a project.
*/
export const validateProjectRename = [
body('newFilename')
.exists()
.withMessage('Duplicate project filename is required')
.isString()
.withMessage('Duplicate project filename must be a string')
.isLength({ min: 1, max: 255 })
.withMessage('Duplicate project filename must be between 1 and 255 characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
@@ -1,80 +0,0 @@
import type { Request, Response, RequestHandler } from 'express';
import { CustomField, CustomFields, ProjectData } from 'ontime-types';
import { removeUndefined } from '../utils/parserUtils.js';
import { failEmptyObjects } from '../utils/routerUtils.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import {
createCustomField,
editCustomField,
getCustomFields as getCustomFieldsFromCache,
removeCustomField,
} from '../services/rundown-service/rundownCache.js';
// Create controller for GET request to 'project'
export const getProject: RequestHandler = async (req, res) => {
res.json(DataProvider.getProjectData());
};
// Create controller for POST request to 'project'
export const postProject: RequestHandler = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newEvent: Partial<ProjectData> = removeUndefined({
title: req.body?.title,
description: req.body?.description,
publicUrl: req.body?.publicUrl,
publicInfo: req.body?.publicInfo,
backstageUrl: req.body?.backstageUrl,
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
});
const newData = await DataProvider.setProjectData(newEvent);
res.status(200).send(newData);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
export const getCustomFields: RequestHandler = async (_req: Request, res: Response<CustomFields>) => {
const customFields = getCustomFieldsFromCache();
res.json(customFields);
};
// Expects { label: <label> type: 'string | ..' }
export const postCustomField: RequestHandler = async (req: Request, res: Response) => {
try {
const newField = req.body as CustomField;
const allFields = await createCustomField(newField);
res.status(201).send(allFields);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
// Expects { label: <oldLabel>, field: { label: <newLabel> type: 'string | ..' } }
export const putCustomField: RequestHandler = async (req: Request, res: Response) => {
try {
const oldLabel = req.params.label;
const { colour, type, label } = req.body;
const newFields = await editCustomField(oldLabel, { label, colour, type });
res.status(200).send(newFields);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
// Expects { label: <label> }
export const deleteCustomField: RequestHandler = async (req: Request, res: Response) => {
try {
const fieldToDelete = req.params.label;
await removeCustomField(fieldToDelete);
res.sendStatus(204);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
-44
View File
@@ -1,44 +0,0 @@
/**
* API Router
* User to handle all requests which affect runtime
* It is a mirror implementation of OSC and Websocket Adapters
*
*/
import { LogOrigin } from 'ontime-types';
import express from 'express';
import { dispatchFromAdapter } from '../controllers/integrationController.js';
import { logger } from '../classes/Logger.js';
import { objectFromPath } from '../adapters/utils/parse.js';
export const router = express.Router();
const helloMessage = 'You have reached Ontime API server';
// create route between controller and '/api/' endpoint
router.get('/', (_req, res) => {
res.status(200).json({ message: helloMessage });
});
// any GET request in /api is sent to the integration controller
router.get('/*', (req, res) => {
let action = req.path.substring(1);
const actionArray = action.split('/');
const params = { payload: req.query as object };
if (actionArray.length > 1) {
action = actionArray.shift();
params.payload = objectFromPath(actionArray, params.payload);
}
try {
const reply = dispatchFromAdapter(action, params, 'http');
res.status(202).json(reply);
} catch (error) {
logger.error(LogOrigin.Rx, `HTTP IN: ${error}`);
res.status(500).json({ error: error.message });
}
});
-128
View File
@@ -1,128 +0,0 @@
import express from 'express';
import { uploadClientSecret, uploadFile } from '../utils/upload.js';
import {
dbDownload,
uploadProjectFile,
getAliases,
getInfo,
getOSC,
getHTTP,
getSettings,
getViewSettings,
patchPartialProjectFile,
poll,
postAliases,
postOSC,
postSettings,
postViewSettings,
previewSpreadsheet,
postHTTP,
duplicateProjectFile,
listProjects,
loadProject,
renameProjectFile,
createProjectFile,
deleteProjectFile,
} from '../controllers/ontimeController.js';
import {
validateAliases,
validateOSC,
validatePatchProjectFile,
validateSettings,
viewValidator,
validateHTTP,
validateProjectDuplicate,
validateLoadProjectFile,
validateProjectRename,
} from '../controllers/ontimeController.validate.js';
import { projectSanitiser, sanitizeProjectFilename } from '../controllers/projectController.validate.js';
import {
revokeAuthentication,
readFromSheet,
requestConnection,
verifyAuthentication,
writeToSheet,
} from '../controllers/sheetsController.js';
import { validateRequestConnection, validateSheetOptions } from '../controllers/sheetController.validate.js';
export const router = express.Router();
// create route between controller and '/ontime/sync' endpoint
router.get('/poll', poll);
// TODO: should db be the root endpoint for /ontime/data
// create route between controller and '/ontime/db' endpoint
router.get('/db', dbDownload);
// create route between controller and '/ontime/db' endpoint
router.post('/db', uploadFile, uploadProjectFile);
// create route between controller and '/ontime/db' endpoint
router.patch('/db', validatePatchProjectFile, patchPartialProjectFile);
// create route between controller and '/spreadsheet/preview' endpoint
// TODO: validate import map
router.post('/spreadsheet/preview', uploadFile, previewSpreadsheet);
// create route between controller and '/ontime/settings' endpoint
router.get('/settings', getSettings);
// create route between controller and '/ontime/settings' endpoint
router.post('/settings', validateSettings, postSettings);
// create route between controller and '/ontime/views' endpoint
router.get('/views', getViewSettings);
// create route between controller and '/ontime/views' endpoint
router.post('/views', viewValidator, postViewSettings);
// create route between controller and '/ontime/aliases' endpoint
router.get('/aliases', getAliases);
// create route between controller and '/ontime/aliases' endpoint
router.post('/aliases', validateAliases, postAliases);
// create route between controller and '/ontime/info' endpoint
router.get('/info', getInfo);
// create route between controller and '/ontime/osc' endpoint
router.get('/osc', getOSC);
// create route between controller and '/ontime/osc' endpoint
router.post('/osc', validateOSC, postOSC);
// create route between controller and '/ontime/http' endpoint
router.get('/http', getHTTP);
// create route between controller and '/ontime/http' endpoint
router.post('/http', validateHTTP, postHTTP);
// create route between controller and '/ontime/projects' endpoint
router.get('/projects', listProjects);
// create route between controller and '/ontime/load-project' endpoint
router.post('/load-project', validateLoadProjectFile, sanitizeProjectFilename, loadProject);
// create route between controller and '/ontime/project/:filename/duplicate' endpoint
router.post('/project/:filename/duplicate', validateProjectDuplicate, sanitizeProjectFilename, duplicateProjectFile);
// create route between controller and '/ontime/project/:filename/rename' endpoint
router.put('/project/:filename/rename', validateProjectRename, sanitizeProjectFilename, renameProjectFile);
// create route between controller and '/ontime/project' endpoint
router.post('/project', projectSanitiser, createProjectFile);
// create route between controller and '/ontime/project/:filename' endpoint
router.delete('/project/:filename', sanitizeProjectFilename, deleteProjectFile);
// create route between controller and '/sheet/:sheetId/connect' endpoint
router.post('/sheet/:sheetId/connect', uploadClientSecret, validateRequestConnection, requestConnection);
router.get('/sheet/connect', verifyAuthentication);
router.post('/sheet/revoke', revokeAuthentication);
router.post('/sheet/:sheetId/read', validateSheetOptions, readFromSheet);
router.post('/sheet/:sheetId/write', validateSheetOptions, writeToSheet);

Some files were not shown because too many files have changed in this diff Show More