refactor: migrate project upload (#796)

* refactor: migrate project upload
This commit is contained in:
Carlos Valente
2024-03-01 22:59:00 +01:00
committed by GitHub
parent 8df419d835
commit 5b01329c37
54 changed files with 698 additions and 1134 deletions
+1 -1
View File
@@ -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;
@@ -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';