mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 05:13:32 +00:00
refactor: migrate project upload (#796)
* refactor: migrate project upload
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { MaybeNumber, MaybeString, Playback } from 'ontime-types';
|
||||
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
import { resolveRestoreFile } from '../setup.js';
|
||||
import { resolveRestoreFile } from '../setup/index.js';
|
||||
|
||||
export type RestorePoint = {
|
||||
playback: Playback;
|
||||
|
||||
+9
-10
@@ -1,24 +1,23 @@
|
||||
import { Low } from 'lowdb';
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
import { join } from 'path';
|
||||
|
||||
import { getAppDataPath, isTest } from '../setup.js';
|
||||
import { appStatePath, isTest } from '../../setup/index.js';
|
||||
|
||||
interface Config {
|
||||
lastLoadedProject: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service manages Ontime's runtime configuration
|
||||
* Service manages Ontime's runtime memory between boots
|
||||
*/
|
||||
|
||||
class ConfigService {
|
||||
class AppStateService {
|
||||
private config: Low<Config>;
|
||||
private configPath: string;
|
||||
private pathToFile: string;
|
||||
|
||||
constructor() {
|
||||
this.configPath = join(getAppDataPath(), 'config.json');
|
||||
const adapter = new JSONFile<Config>(this.configPath);
|
||||
constructor(appStatePath: string) {
|
||||
this.pathToFile = appStatePath;
|
||||
const adapter = new JSONFile<Config>(this.pathToFile);
|
||||
this.config = new Low<Config>(adapter, null);
|
||||
|
||||
this.init();
|
||||
@@ -29,7 +28,7 @@ class ConfigService {
|
||||
await this.config.write();
|
||||
}
|
||||
|
||||
async getConfig(): Promise<Config> {
|
||||
async get(): Promise<Config> {
|
||||
await this.config.read();
|
||||
return this.config.data;
|
||||
}
|
||||
@@ -42,4 +41,4 @@ class ConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
export const configService = new ConfigService();
|
||||
export const appStateService = new AppStateService(appStatePath);
|
||||
@@ -0,0 +1,235 @@
|
||||
import { DatabaseModel, GetInfo, ProjectData, ProjectFile, ProjectFileListResponse } from 'ontime-types';
|
||||
|
||||
import { copyFile, rename, stat, writeFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { basename, join } from 'path';
|
||||
|
||||
import { notifyChanges, setRundown } from '../rundown-service/RundownService.js';
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
||||
import { getNetworkInterfaces } from '../../utils/networkInterfaces.js';
|
||||
import { resolveProjectsDirectory, resolveStylesPath } from '../../setup/index.js';
|
||||
import { filterProjectFiles, parseProjectFile } from './projectFileUtils.js';
|
||||
import { appStateService } from '../app-state-service/AppStateService.js';
|
||||
import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
import { deleteFile } from '../../utils/parserUtils.js';
|
||||
|
||||
// init dependencies
|
||||
init();
|
||||
|
||||
/**
|
||||
* Ensure services has its dependencies initialized
|
||||
*/
|
||||
function init() {
|
||||
ensureDirectory(resolveProjectsDirectory);
|
||||
}
|
||||
|
||||
type Options = {
|
||||
onlyRundown?: 'true' | 'false';
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a file from the upload folder and applies its data
|
||||
*/
|
||||
export async function applyProjectFile(filePath: string, options?: Options) {
|
||||
const data = parseProjectFile(filePath);
|
||||
|
||||
// move file to project folder
|
||||
const filename = basename(filePath);
|
||||
const newFilePath = join(resolveProjectsDirectory, filename);
|
||||
await rename(filePath, newFilePath);
|
||||
|
||||
// apply data model
|
||||
await applyDataModel(data, options);
|
||||
|
||||
// persist the project selection
|
||||
await appStateService.updateDatabaseConfig(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves and returns an array of project files from the 'uploads' folder.
|
||||
* Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed.
|
||||
* For each qualifying file, its metadata is retrieved, including filename, creation time, and last modification time.
|
||||
*
|
||||
* @returns {Promise<Array<ProjectFile>>} A promise that resolves to an array of ProjectFile objects,
|
||||
* each representing a file in the 'uploads' folder with its metadata.
|
||||
* The metadata includes the filename, creation time (createdAt),
|
||||
* and last modification time (updatedAt) of each file.
|
||||
*
|
||||
* @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics.
|
||||
*/
|
||||
export async function getProjectFiles(): Promise<ProjectFile[]> {
|
||||
const allFiles = await getFilesFromFolder(resolveProjectsDirectory);
|
||||
const filteredFiles = filterProjectFiles(allFiles);
|
||||
|
||||
const projectFiles = [];
|
||||
for (const file of filteredFiles) {
|
||||
const filePath = join(resolveProjectsDirectory, file);
|
||||
const stats = await stat(filePath);
|
||||
|
||||
projectFiles.push({
|
||||
filename: removeFileExtension(file),
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return projectFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers data related to the project list
|
||||
*/
|
||||
export async function getProjectList(): Promise<ProjectFileListResponse> {
|
||||
const files = await getProjectFiles();
|
||||
const appState = await appStateService.get();
|
||||
const lastLoadedProject = removeFileExtension(appState.lastLoadedProject);
|
||||
|
||||
return {
|
||||
files,
|
||||
lastLoadedProject,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates an existing project file
|
||||
*/
|
||||
export async function duplicateProjectFile(existingProjectFile: string, newProjectFile: string) {
|
||||
const projectFilePath = join(resolveProjectsDirectory, existingProjectFile);
|
||||
const duplicateProjectFilePath = join(resolveProjectsDirectory, newProjectFile);
|
||||
|
||||
return copyFile(projectFilePath, duplicateProjectFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames an existing project file
|
||||
*/
|
||||
export async function renameProjectFile(existingProjectFile: string, newName: string) {
|
||||
const projectFilePath = join(resolveProjectsDirectory, existingProjectFile);
|
||||
const newProjectFilePath = join(resolveProjectsDirectory, newName);
|
||||
|
||||
await rename(projectFilePath, newProjectFilePath);
|
||||
|
||||
// Update the last loaded project config if current loaded project is the one being renamed
|
||||
const { lastLoadedProject } = await appStateService.get();
|
||||
|
||||
if (lastLoadedProject === existingProjectFile) {
|
||||
await appStateService.updateDatabaseConfig(newName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new project file and applies its result
|
||||
*/
|
||||
export async function createProjectFile(filename: string, projectData: ProjectData) {
|
||||
const data = {
|
||||
...dbModel,
|
||||
project: {
|
||||
...dbModel.project,
|
||||
...projectData,
|
||||
},
|
||||
};
|
||||
|
||||
// create new file
|
||||
const newFile = join(resolveProjectsDirectory, filename);
|
||||
await writeFile(newFile, JSON.stringify(data));
|
||||
|
||||
// apply its data
|
||||
await applyDataModel(data);
|
||||
|
||||
appStateService.updateDatabaseConfig(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a project file
|
||||
*/
|
||||
export async function deleteProjectFile(filename: string) {
|
||||
const projectFilePath = join(resolveProjectsDirectory, filename);
|
||||
await deleteFile(projectFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds business logic to gathering data for the info endpoint
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const { version, serverPort } = DataProvider.getSettings();
|
||||
const osc = DataProvider.getOsc();
|
||||
|
||||
// get nif and inject localhost
|
||||
const ni = getNetworkInterfaces();
|
||||
ni.unshift({ name: 'localhost', address: '127.0.0.1' });
|
||||
const cssOverride = resolveStylesPath;
|
||||
|
||||
return {
|
||||
networkInterfaces: ni,
|
||||
version,
|
||||
serverPort,
|
||||
osc,
|
||||
cssOverride,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Business logic for resolving a string
|
||||
*/
|
||||
export function extractPin(value: string | undefined | null, fallback: string | null): string | null {
|
||||
if (value === null) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* applies a partial database model
|
||||
*/
|
||||
export async function applyDataModel(data: Partial<DatabaseModel>, options?: Options) {
|
||||
runtimeService.stop();
|
||||
|
||||
const newRundown = data.rundown || [];
|
||||
const { rundown, ...rest } = data;
|
||||
if (options?.onlyRundown === 'true') {
|
||||
setRundown(newRundown ?? []);
|
||||
} else {
|
||||
await DataProvider.mergeIntoData(rest);
|
||||
setRundown(rundown ?? []);
|
||||
}
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Validates the existence of project files.
|
||||
* @param {object} projectFiles
|
||||
* @param {string} projectFiles.projectFilename
|
||||
* @param {string} projectFiles.newFilename
|
||||
*
|
||||
* @returns {Promise<Array<string>>} Array of errors
|
||||
*
|
||||
*/
|
||||
export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array<string> => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (projectFiles.filename) {
|
||||
const projectFilePath = join(resolveProjectsDirectory, projectFiles.filename);
|
||||
|
||||
if (!existsSync(projectFilePath)) {
|
||||
errors.push('Project file does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
if (projectFiles.newFilename) {
|
||||
const projectFilePath = join(resolveProjectsDirectory, projectFiles.newFilename);
|
||||
|
||||
if (existsSync(projectFilePath)) {
|
||||
errors.push('New project file already exists');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, vi } from 'vitest';
|
||||
|
||||
import { getProjectFiles } from '../ProjectService.js';
|
||||
|
||||
vi.mock('fs/promises', () => {
|
||||
const mockFiles = ['file1.json', 'file2.json', 'file3.json', 'document.txt', 'image.png'];
|
||||
const mockStats = {
|
||||
birthtime: new Date('2020-01-01'),
|
||||
mtime: new Date('2021-01-01'),
|
||||
};
|
||||
|
||||
return {
|
||||
readdir: vi.fn().mockResolvedValue(mockFiles),
|
||||
stat: vi.fn().mockResolvedValue(mockStats),
|
||||
};
|
||||
});
|
||||
|
||||
describe('getProjectFiles test', () => {
|
||||
it('should return a list of project .json files', async () => {
|
||||
const { readdir, stat } = await import('fs/promises');
|
||||
|
||||
const result = await getProjectFiles();
|
||||
|
||||
const expectedFiles = ['file1', 'file2', 'file3'].map((file) => ({
|
||||
filename: file,
|
||||
createdAt: new Date('2020-01-01').toISOString(),
|
||||
updatedAt: new Date('2021-01-01').toISOString(),
|
||||
}));
|
||||
|
||||
expect(result).toEqual(expectedFiles);
|
||||
expect(readdir).toHaveBeenCalled();
|
||||
expect(stat).toHaveBeenCalledTimes(expectedFiles.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { extname } from 'path';
|
||||
|
||||
/**
|
||||
* Given an array of file names, filters out any files that do not have a '.json' extension.
|
||||
* We assume these are project files
|
||||
* @param files
|
||||
* @returns
|
||||
*/
|
||||
export function filterProjectFiles(files: Array<string>): Array<string> {
|
||||
return files.filter((file) => {
|
||||
const ext = extname(file).toLowerCase();
|
||||
return ext === '.json';
|
||||
});
|
||||
}
|
||||
|
||||
export function parseProjectFile(filePath: string): object {
|
||||
if (!filePath.endsWith('.json')) {
|
||||
throw new Error('Invalid file type');
|
||||
}
|
||||
|
||||
const rawdata = readFileSync(filePath, 'utf-8');
|
||||
const uploadedJson = JSON.parse(rawdata);
|
||||
|
||||
// at this point, we think this is a DatabaseModel
|
||||
// verify by looking for the required fields
|
||||
if (uploadedJson?.settings?.app !== 'ontime') {
|
||||
throw new Error('Not a ontime project file');
|
||||
}
|
||||
return uploadedJson;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { sheets, sheets_v4 } from '@googleapis/sheets';
|
||||
import { Credentials, OAuth2Client } from 'google-auth-library';
|
||||
import got from 'got';
|
||||
|
||||
import { resolveSheetsDirectory } from '../../setup.js';
|
||||
import { resolveSheetsDirectory } from '../../setup/index.js';
|
||||
import { ensureDirectory } from '../../utils/fileManagement.js';
|
||||
import { type ClientSecret, cellRequestFromEvent, getA1Notation, validateClientSecret } from './sheetUtils.js';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
Reference in New Issue
Block a user