diff --git a/src/common/project/Project.ts b/src/common/project/Project.ts index e2bc145..27f9ab9 100644 --- a/src/common/project/Project.ts +++ b/src/common/project/Project.ts @@ -1,9 +1,35 @@ import Study from "@common/study/Study"; export default class Project { + private studies: Study[]; + + /** + * A project is the global entity that keeps track of all the studies and assays that belong together. + * + * @param name Name of the project. This can be used by the user to distinguish between different projects and their + * unique properties + * @param location Value used to identify where this project can be retrieved from. In most cases this is a + * directory somewhere on the local system, but it does not need to be. + */ constructor( public readonly name: string, public readonly location: string, - public readonly studies: Study[], - ) {} + ) { + this.studies = []; + } + + public addStudy(study: Study) { + this.studies.push(study); + } + + public getStudies(): Study[] { + return this.studies; + } + + public removeStudy(study: Study) { + const idx = this.studies.findIndex((val: Study) => val.getId() === study.getId()); + if (idx !== -1) { + this.studies.splice(idx, 1); + } + } } diff --git a/src/common/project/ProjectManager.ts b/src/common/project/ProjectManager.ts new file mode 100644 index 0000000..94e5fd4 --- /dev/null +++ b/src/common/project/ProjectManager.ts @@ -0,0 +1,25 @@ +import Project from "@common/project/Project"; + +export default interface ProjectManager { + /** + * Load an existing project. If the project files do not exist, this function will throw an error. + * + * @param projectLocation The main directory of the project on disk. + * @param addToRecents Should this project be added to the list of recent projects? Set to false for no. + */ + loadProject( + projectLocation: string, + addToRecents: boolean + ): Promise; + + /** + * Create a new project and correctly initialize all project files in the provided directory. + * + * @param projectLocation The main directory of the project on disk. + * @param addToRecents Should this project be added to the list of recent projects? Set to false for no. + */ + createProject( + projectLocation: string, + addToRecents: boolean + ): Promise; +} diff --git a/src/common/study/StudyManager.ts b/src/common/study/StudyManager.ts index a14face..119ba74 100644 --- a/src/common/study/StudyManager.ts +++ b/src/common/study/StudyManager.ts @@ -1,17 +1,23 @@ import Study from "@common/study/Study"; -import DatabaseManager from "@main/database/DatabaseManager"; export default interface StudyManager { /** - * Load a study from the given directory. The study's name is assumed to be the name of the directory. If the given - * directory is empty, a new study will be created. + * Load all studies that are associated to this study manager. Typically something to identify these studies (such + * as a project) is passed along the constructor of this class. + */ + loadStudies(): Promise; + + /** + * Write the given study object (that is associated with the given project) to disk. + * + * @param study + */ + writeStudy(study: Study): Promise; + + /** + * Remove the given study object from the underlying storage system. * - * @param directory The directory that contains all assays and required metadata for this study. - * @param dbManager A database manager connected to the project that this study belongs to and that can be used for - * retrieving / updating this study's metadata. + * @param study */ - loadStudy( - directory: string, - dbManager: DatabaseManager - ): Promise; + removeStudy(study: Study): Promise; } diff --git a/src/main/assay/FileSystemAssayManager.ts b/src/main/assay/FileSystemAssayManager.ts index bc9ffbc..2e5cba4 100644 --- a/src/main/assay/FileSystemAssayManager.ts +++ b/src/main/assay/FileSystemAssayManager.ts @@ -6,24 +6,28 @@ import createWorker from "./PeptideReaderWorker?nodeWorker"; import { Worker } from "worker_threads"; import { AssayTableRow } from "@main/database/schemas/Schema"; import { Database as DbType } from "better-sqlite3"; +import Project from "@common/project/Project"; +import path from "path"; +import Study from "@common/study/Study"; export default class FileSystemAssayManager implements AssayManager { private static inProgress: Promise | undefined private static worker: Worker; public constructor( - private readonly directoryPath: string, - private readonly dbManager: DatabaseManager + private readonly dbManager: DatabaseManager, + private readonly project: Project, + private readonly study: Study ) {} public async loadAssay( assayName: string, assayId: string ): Promise { - const path = `${this.directoryPath}${assayName}.pep`; + const assayPath = `${path.join(this.project.location, this.study.getName(), assayName)}.pep`; // First, try to read in all the peptides for this assay. - const peptidesString: string = await fs.readFile(path, { + const peptidesString: string = await fs.readFile(assayPath, { encoding: "utf-8" }); diff --git a/src/main/file-system/FileSystemManager.ts b/src/main/file-system/FileSystemManager.ts index 945b5c9..9766fe8 100644 --- a/src/main/file-system/FileSystemManager.ts +++ b/src/main/file-system/FileSystemManager.ts @@ -9,6 +9,10 @@ export default class FileSystemManager { return fs.writeFile(path, contents, { encoding: "utf-8" }); } + public removeFile(path: string): Promise { + return fs.rm(path, { recursive: true }); + } + public async mkdir(path: string): Promise { await fs.mkdir(path, { recursive: true }); } diff --git a/src/main/project/FileSystemProjectManager.ts b/src/main/project/FileSystemProjectManager.ts new file mode 100644 index 0000000..92a022b --- /dev/null +++ b/src/main/project/FileSystemProjectManager.ts @@ -0,0 +1,104 @@ +import { v4 as uuidv4 } from "uuid"; +import path from "path"; +import DatabaseManager from "@main/database/DatabaseManager"; +import VersionUtils from "@main/utils/VersionUtils"; +import Project from "@common/project/Project"; +import Study from "@common/study/Study"; +import ProjectManager from "@common/project/ProjectManager"; +import FileSystemManager from "@main/file-system/FileSystemManager"; +import AppManager from "@main/app/AppManager"; +import FileSystemStudyManager from "@main/study/FileSystemStudyManager"; +import FileSystemRecentProjectManager from "@main/project/FileSystemRecentProjectManager"; + +export default class FileSystemProjectManager implements ProjectManager { + private static readonly DB_FILE_NAME: string = "metadata.sqlite"; + + public async loadProject( + projectLocation: string, + addToRecents: boolean = true + ): Promise { + if (!projectLocation.endsWith("/")) { + projectLocation += "/"; + } + + const fsManager = new FileSystemManager(); + + // Check if a project is actually present in this directory. If there isn't, we cannot load the project. + if (!await fsManager.fileExists(projectLocation + FileSystemProjectManager.DB_FILE_NAME)) { + throw new Error("InvalidProjectException: Project metadata file was not found!"); + } + + const appManager = new AppManager(); + + const dbManager = new DatabaseManager( + projectLocation + FileSystemProjectManager.DB_FILE_NAME + ); + await dbManager.initializeDatabase(appManager.getAppVersion()); + const dbAppVersion = dbManager.getApplicationVersion(); + + if (VersionUtils.isVersionLargerThan(dbAppVersion, appManager.getAppVersion())) { + throw new Error("ProjectVersionMismatchException: Project was made with a more recent version of Unipept Desktop!"); + } else { + await dbManager.setApplicationVersion(appManager.getAppVersion()); + } + + const project = new Project( + path.basename(projectLocation), + projectLocation + ); + + const studyManager = new FileSystemStudyManager( + dbManager, + project + ); + + for (const study of await studyManager.loadStudies()) { + project.addStudy(study); + } + + if (addToRecents) { + const recentProjectsMng = new FileSystemRecentProjectManager(); + await recentProjectsMng.addRecentProject(projectLocation); + } + + return project; + } + + public async createProject( + projectLocation: string, + addToRecents: boolean = true + ): Promise { + if (!projectLocation.endsWith("/")) { + projectLocation += "/"; + } + + const fsManager = new FileSystemManager(); + + // Create the project directory + await fsManager.mkdir(projectLocation); + + const dbManager = new DatabaseManager( + path.join(projectLocation, FileSystemProjectManager.DB_FILE_NAME) + ); + + const appManager = new AppManager(); + await dbManager.initializeDatabase(appManager.getAppVersion()); + + // Create one dummy study for this project + const study = new Study(uuidv4()); + study.setName("Study name"); + + const project = new Project(path.basename(projectLocation), projectLocation); + project.addStudy(study); + + const studyManager = new FileSystemStudyManager(dbManager, project); + studyManager.writeStudy(study); + + if (addToRecents) { + const recentProjectsMng = new FileSystemRecentProjectManager(); + await recentProjectsMng.addRecentProject(projectLocation); + } + + return project; + } +} diff --git a/src/main/project/ProjectManager.ts b/src/main/project/ProjectManager.ts deleted file mode 100644 index 07ab6b6..0000000 --- a/src/main/project/ProjectManager.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { promises as fs } from "fs"; -import path from "path"; -import DatabaseManager from "@main/database/DatabaseManager"; -import VersionUtils from "@main/utils/VersionUtils"; -import Project from "@common/project/Project"; -import Study from "@common/study/Study"; -import { StudyTableRow } from "@main/database/schemas/Schema"; -import { Database as DbType } from "better-sqlite3"; - -export default class ProjectManager { - public static readonly DB_FILE_NAME: string = "metadata.sqlite"; - - public async loadExistingProject( - projectLocation: string, - currentAppVersion: string - ): Promise { - if (!projectLocation.endsWith("/")) { - projectLocation += "/"; - } - - try { - fs.stat(projectLocation + ProjectManager.DB_FILE_NAME); - } catch (err) { - throw new Error("InvalidProjectException: Project metadata file was not found!"); - } - - const dbManager = new DatabaseManager(projectLocation + ProjectManager.DB_FILE_NAME); - await dbManager.initializeDatabase(currentAppVersion); - const dbAppVersion = dbManager.getApplicationVersion(); - - if (VersionUtils.isVersionLargerThan(dbAppVersion, currentAppVersion)) { - throw new Error("ProjectVersionMismatchException: Project was made with a more recent version of Unipept Desktop!"); - } else { - await dbManager.setApplicationVersion(currentAppVersion); - } - - // Check all subdirectories of the given project and try to load the studies. - const subDirectories: string[] = (await fs.readdir(projectLocation, { withFileTypes: true })) - .filter(dirEntry => dirEntry.isDirectory() && dirEntry.name !== ".buffers") - .map(dirEntry => dirEntry.name); - - const studies = []; - - for (const directory of subDirectories) { - studies.push(await this.loadStudy(`${projectLocation}${directory}`, dbManager, projectLocation)); - } - - - } - - private async loadStudy( - directory: string, - dbManager: DatabaseManager, - projectLocation: string - ): Promise { - if (!directory.endsWith("/")) { - directory += "/"; - } - - const studyName: string = path.basename(directory); - - // Check if the given study name is present in the database. If not, add the study with a new ID. - const row = await dbManager.performQuery((db: DbType) => { - return db.prepare("SELECT * FROM studies WHERE `name`=?").get(studyName) as StudyTableRow | undefined; - }); - - - } -} diff --git a/src/main/study/FileSystemStudyManager.ts b/src/main/study/FileSystemStudyManager.ts index 6ecacf3..4780c17 100644 --- a/src/main/study/FileSystemStudyManager.ts +++ b/src/main/study/FileSystemStudyManager.ts @@ -4,23 +4,99 @@ import path from "path"; import { StudyTableRow } from "@main/database/schemas/Schema"; import { Database as DbType } from "better-sqlite3"; import StudyManager from "@common/study/StudyManager"; +import { v4 as uuidv4 } from "uuid"; +import FileSystemManager from "@main/file-system/FileSystemManager"; +import Project from "@common/project/Project"; +import { promises as fs } from "fs"; +import { Database } from "better-sqlite3"; + export default class FileSystemStudyManager implements StudyManager { - public async loadStudy( - directory: string, - dbManager: DatabaseManager + constructor( + private readonly dbManager: DatabaseManager, + private readonly project: Project + ) {} + + public async loadStudies(): Promise { + const subDirectories: string[] = (await fs.readdir(this.project.location, { withFileTypes: true })) + .filter(dirEntry => dirEntry.isDirectory() && dirEntry.name !== ".buffers") + .map(dirEntry => dirEntry.name); + + const studies: Study[] = []; + + for (const directory of subDirectories) { + studies.push(await this.loadStudy(directory)); + } + + return studies; + } + + public async writeStudy( + study: Study + ): Promise { + const studyPath = path.join(this.project.location, study.getName()); + const fsManager = new FileSystemManager(); + + await fsManager.mkdir(studyPath); + await this.dbManager.performQuery((db: Database) => { + db.prepare("REPLACE INTO studies (id, name) VALUES (?, ?)").run(study.getId(), study.getName()) + }); + } + + public async removeStudy( + study: Study + ) { + const studyPath = path.join(this.project.location, study.getName()); + const fsManager = new FileSystemManager(); + + if (!fsManager.fileExists(studyPath)) { + return; + } + + // First, remove the study directory from the file system. + await fsManager.removeFile(studyPath); + + // Then, also remove the study metadata from the database. + await this.dbManager.performQuery((db: Database) => { + db.prepare("DELETE FROM studies WHERE id = ?").run(study.getId()); + }); + } + + /** + * Read the properties and metadata of one study from disk and return a new study object. + * + * @param directory The root directory for this study object. + * @private + */ + private async loadStudy( + directory: string ): Promise { if (!directory.endsWith("/")) { directory += "/"; } const studyName: string = path.basename(directory); + let study: Study; // Check if the given study name is present in the database. If not, add the study with a new ID. - const row = await dbManager.performQuery((db: DbType) => { + const row = await this.dbManager.performQuery((db: DbType) => { return db.prepare("SELECT * FROM studies WHERE `name`=?").get(studyName) as StudyTableRow | undefined; }); + if (row) { + study = new Study(row.id); + } else { + study = new Study(uuidv4()); + } + + study.setName(studyName); + // Some changes might have occurred to this study while reading it (it's internal ID might have changed, so we + // need to update the project's database reflect these changes). + await this.writeStudy(study); + + // Read all assays that belong to this study. + // TODO: implement reading the assays in a new AssayManager and call it here + return study; } }