diff --git a/backend/package.json b/backend/package.json index 5ab21ba4..8707c867 100644 --- a/backend/package.json +++ b/backend/package.json @@ -103,6 +103,7 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "testTimeout": 30000 } } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9669ee9e..358b9e0f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -28,6 +28,13 @@ import { TextractService } from './textract/textract.service'; import { ResetPasswordModule } from './reset_password/reset_password.module'; import { ResetPasswordRequest } from './reset_password/entities/reset_password_request.entity'; import { MailService } from './mail/mail.service'; +import { DiffsService } from './diffs/diffs.service'; +import { SnapshotService } from './snapshots/snapshots.service'; +import { Diff } from './diffs/entities/diffs.entity'; +import { Snapshot } from './snapshots/entities/snapshots.entity'; +import { VersionControlModule } from './version_control/version_control.module'; +import { VersionControlService } from './version_control/version_control.service'; +import { VersionControlController } from './version_control/version_control.controller'; @Module({ imports: [ @@ -41,17 +48,21 @@ import { MailService } from './mail/mail.service'; TypeOrmModule.forFeature([ ResetPasswordRequest, ]), + TypeOrmModule.forFeature([Diff]), + TypeOrmModule.forFeature([Snapshot]), MarkdownFilesModule, FoldersModule, S3Module, FileManagerModule, AssetManagerModule, + VersionControlModule, ResetPasswordModule, ], controllers: [ AuthController, S3Controller, FileManagerController, + VersionControlController ], providers: [ FileManagerService, @@ -62,6 +73,9 @@ import { MailService } from './mail/mail.service'; ConversionService, TextractService, MailService, + DiffsService, + SnapshotService, + VersionControlService, ], }) export class AppModule {} diff --git a/backend/src/diffs/diffs.service.ts b/backend/src/diffs/diffs.service.ts index b6ccc3a5..dadc1264 100644 --- a/backend/src/diffs/diffs.service.ts +++ b/backend/src/diffs/diffs.service.ts @@ -1,7 +1,11 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; -import { Diff } from "./entities/diffs.entity"; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Diff } from './entities/diffs.entity'; +import 'dotenv/config'; +import * as CryptoJS from 'crypto-js'; +import { MarkdownFileDTO } from '../markdown_files/dto/markdown_file.dto'; +import { DiffDTO } from './dto/diffs.dto'; @Injectable() export class DiffsService { @@ -9,4 +13,160 @@ export class DiffsService { @InjectRepository(Diff) private diffRepository: Repository, ) {} + + ///===----------------------------------------------------- + + async getDiff( + diffDTO: DiffDTO, + nextDiffID: number, + ) { + const diff = + await this.diffRepository.findOne({ + where: { + MarkdownID: diffDTO.MarkdownID, + S3DiffID: nextDiffID, + }, + }); + + return diff; + } + + ///===----------------------------------------------------- + + async updateDiff( + diffDTO: DiffDTO, + nextDiffID: number, + ) { + // const diff = + // await this.diffRepository.findOne({ + // where: { + // MarkdownID: diffDTO.MarkdownID, + // S3DiffID: nextDiffID, + // }, + // }); + + const diff = await this.getDiff( + diffDTO, + nextDiffID, + ); + + diff.LastModified = new Date(); + diff.HasBeenUsed = true; + await this.diffRepository.save(diff); + } + + ///===----------------------------------------------------- + + async createDiffs( + markdownFileDTO: MarkdownFileDTO, + snapshotIDs: string[], + ) { + const diffRecords = []; + let snapshotIndex = 0; + for ( + let i = 0; + i < parseInt(process.env.MAX_DIFFS); + i++ + ) { + const diffID = CryptoJS.SHA256( + markdownFileDTO.UserID.toString() + + new Date().getTime().toString() + + i.toString(), + ).toString(); + + if ( + i % + parseInt( + process.env.DIFFS_PER_SNAPSHOT, + ) === + 0 && + i !== 0 + ) { + snapshotIndex++; + } + + diffRecords.push({ + DiffID: diffID, + MarkdownID: markdownFileDTO.MarkdownID, + UserID: markdownFileDTO.UserID, + S3DiffID: i, + HasBeenUsed: false, + SnapshotID: snapshotIDs[snapshotIndex], + }); + } + await this.diffRepository.insert(diffRecords); + } + + ///===----------------------------------------------------- + + async deleteDiffs( + markdownFileDTO: MarkdownFileDTO, + ) { + await this.diffRepository.delete({ + MarkdownID: markdownFileDTO.MarkdownID, + }); + } + + ///===----------------------------------------------------- + + async resetDiffs( + markdownID: string, + snapshotID: string, + ) { + await this.diffRepository.update( + { + MarkdownID: markdownID, + SnapshotID: snapshotID, + }, + { + HasBeenUsed: false, + }, + ); + } + + ///===----------------------------------------------------- + + async getAllDiffs(markdownID: string) { + return await this.diffRepository.find({ + where: { + MarkdownID: markdownID, + HasBeenUsed: true, + }, + }); + } + + ///===----------------------------------------------------- + + getLogicalIndex( + s3Index: number, + nextDiffID: number, + arr_len: number, + ): number { + return ( + (s3Index - nextDiffID + arr_len) % arr_len + ); + } + + ///===----------------------------------------------------- + + async getLogicalDiffOrder( + diffDTOs: DiffDTO[], + nextDiffID: number, + ) { + const arrLength = parseInt( + process.env.MAX_DIFFS, + ); + const logicalOrder: DiffDTO[] = new Array( + arrLength, + ).fill(0); + for (let idx = 0; idx < arrLength; idx++) { + const logicalIndex = this.getLogicalIndex( + diffDTOs[idx].S3DiffID, + nextDiffID, + arrLength, + ); + logicalOrder[logicalIndex] = diffDTOs[idx]; + } + return logicalOrder; + } } diff --git a/backend/src/diffs/dto/diffs.dto.ts b/backend/src/diffs/dto/diffs.dto.ts new file mode 100644 index 00000000..4cdad6c3 --- /dev/null +++ b/backend/src/diffs/dto/diffs.dto.ts @@ -0,0 +1,23 @@ +export class DiffDTO { + DiffID: string; + MarkdownID: string; + UserID: number; + DisplayID: number; + S3DiffID: number; + LastModified: Date; + Content: string; + SnapshotPayload: string; + SnapshotID: string; + + constructor() { + this.DiffID = undefined; + this.MarkdownID = undefined; + this.UserID = undefined; + this.DisplayID = undefined; + this.S3DiffID = undefined; + this.LastModified = undefined; + this.Content = undefined; + this.SnapshotPayload = undefined; + this.SnapshotID = undefined; + } +} diff --git a/backend/src/diffs/entities/diffs.entity.ts b/backend/src/diffs/entities/diffs.entity.ts index 274f9562..1524d838 100644 --- a/backend/src/diffs/entities/diffs.entity.ts +++ b/backend/src/diffs/entities/diffs.entity.ts @@ -13,10 +13,7 @@ export class Diff { MarkdownID: string; @Column() - UserID: string; - - @Column() - DisplayID: string; + UserID: number; @Column() S3DiffID: number; @@ -27,4 +24,10 @@ export class Diff { onUpdate: 'CURRENT_TIMESTAMP(3)', }) LastModified: Date; + + @Column() + HasBeenUsed: boolean; + + @Column() + SnapshotID: string; } diff --git a/backend/src/file_manager/__mocks__/file_manager.service.ts b/backend/src/file_manager/__mocks__/file_manager.service.ts index bfdf62a9..23268ab4 100644 --- a/backend/src/file_manager/__mocks__/file_manager.service.ts +++ b/backend/src/file_manager/__mocks__/file_manager.service.ts @@ -17,6 +17,8 @@ import { ExportDTO } from '../dto/export.dto'; import * as CryptoJS from 'crypto-js'; import { ConversionService } from '../../conversion/conversion.service'; import { ImportDTO } from '../dto/import.dto'; +import { DiffsService } from '../../diffs/diffs.service'; +import { SnapshotService } from '../../snapshots/snapshots.service'; @Injectable() export class FileManagerServiceMock { @@ -26,6 +28,8 @@ export class FileManagerServiceMock { private s3service: S3Service, private conversionService: ConversionService, private userService: UsersService, + private diffsService: DiffsService, + private snapshotService: SnapshotService, ) {} // File operations: ########################################################### @@ -233,6 +237,22 @@ export class FileManagerServiceMock { markdownFileDTO, ); + await this.s3service.deleteDiffObjectsForFile( + markdownFileDTO, + ); + + await this.s3service.deleteSnapshotObjectsForFile( + markdownFileDTO, + ); + + await this.diffsService.deleteDiffs( + markdownFileDTO, + ); + + await this.snapshotService.deleteSnapshots( + markdownFileDTO, + ); + return this.markdownFilesService.remove( markdownFileDTO, ); diff --git a/backend/src/file_manager/file_manager.controller.spec.ts b/backend/src/file_manager/file_manager.controller.spec.ts index 65aa1f72..7c6fa455 100644 --- a/backend/src/file_manager/file_manager.controller.spec.ts +++ b/backend/src/file_manager/file_manager.controller.spec.ts @@ -33,6 +33,10 @@ import { JwtService } from '@nestjs/jwt'; import { ResetPasswordService } from '../reset_password/reset_password.service'; import { ResetPasswordRequest } from '../reset_password/entities/reset_password_request.entity'; import { MailService } from '../mail/mail.service'; +import { DiffsService } from '../diffs/diffs.service'; +import { SnapshotService } from '../snapshots/snapshots.service'; +import { Diff } from '../diffs/entities/diffs.entity'; +import { Snapshot } from '../snapshots/entities/snapshots.entity'; describe('FileManagerController', () => { let controller: FileManagerController; @@ -54,6 +58,8 @@ describe('FileManagerController', () => { JwtService, ResetPasswordService, MailService, + DiffsService, + SnapshotService, { provide: 'FileManagerService', useValue: { @@ -74,6 +80,14 @@ describe('FileManagerController', () => { provide: getRepositoryToken(User), useClass: Repository, }, + { + provide: getRepositoryToken(Diff), + useClass: Repository, + }, + { + provide: getRepositoryToken(Snapshot), + useClass: Repository, + }, { provide: getRepositoryToken( ResetPasswordRequest, diff --git a/backend/src/file_manager/file_manager.module.ts b/backend/src/file_manager/file_manager.module.ts index bca97939..eef6af36 100644 --- a/backend/src/file_manager/file_manager.module.ts +++ b/backend/src/file_manager/file_manager.module.ts @@ -15,6 +15,10 @@ import { S3ServiceMock } from '../s3/__mocks__/s3.service'; import { ResetPasswordService } from '../reset_password/reset_password.service'; import { ResetPasswordRequest } from '../reset_password/entities/reset_password_request.entity'; import { MailService } from '../mail/mail.service'; +import { DiffsService } from '../diffs/diffs.service'; +import { SnapshotService } from '../snapshots/snapshots.service'; +import { Diff } from '../diffs/entities/diffs.entity'; +import { Snapshot } from '../snapshots/entities/snapshots.entity'; @Module({ imports: [ @@ -24,6 +28,8 @@ import { MailService } from '../mail/mail.service'; TypeOrmModule.forFeature([ ResetPasswordRequest, ]), + TypeOrmModule.forFeature([Diff]), + TypeOrmModule.forFeature([Snapshot]), ], controllers: [FileManagerController], providers: [ @@ -37,6 +43,8 @@ import { MailService } from '../mail/mail.service'; S3ServiceMock, ResetPasswordService, MailService, + DiffsService, + SnapshotService, ], }) export class FileManagerModule {} diff --git a/backend/src/file_manager/file_manager.service.spec.ts b/backend/src/file_manager/file_manager.service.spec.ts index 39194d7c..963c1dbb 100644 --- a/backend/src/file_manager/file_manager.service.spec.ts +++ b/backend/src/file_manager/file_manager.service.spec.ts @@ -36,6 +36,10 @@ import * as CryptoJS from 'crypto-js'; import { ResetPasswordService } from '../reset_password/reset_password.service'; import { ResetPasswordRequest } from '../reset_password/entities/reset_password_request.entity'; import { MailService } from '../mail/mail.service'; +import { DiffsService } from '../diffs/diffs.service'; +import { SnapshotService } from '../snapshots/snapshots.service'; +import { Diff } from '../diffs/entities/diffs.entity'; +import { Snapshot } from '../snapshots/entities/snapshots.entity'; jest.mock('crypto-js', () => { const mockedHash = jest.fn( @@ -67,6 +71,8 @@ describe('FileManagerService', () => { let s3ServiceMock: S3ServiceMock; let conversionService: ConversionService; let usersService: UsersService; + let snapshotService: SnapshotService; + let diffService: DiffsService; beforeEach(async () => { const module: TestingModule = @@ -82,6 +88,8 @@ describe('FileManagerService', () => { AuthService, JwtService, ResetPasswordService, + DiffsService, + SnapshotService, MailService, { provide: 'FileManagerService', @@ -92,6 +100,7 @@ describe('FileManagerService', () => { createFolder: jest.fn(), retrieveAllFolders: jest.fn(), convertFoldersToDTOs: jest.fn(), + deleteFile: jest.fn(), }, }, { @@ -137,6 +146,24 @@ describe('FileManagerService', () => { deleteFile: jest.fn(), createFile: jest.fn(), retrieveFile: jest.fn(), + createDiffObjectsForFile: jest.fn(), + createSnapshotObjectsForFile: + jest.fn(), + }, + }, + { + provide: 'DiffsService', + useValue: { + deleteFile: jest.fn(), + createDiffs: jest.fn(), + deleteDiffs: jest.fn(), + }, + }, + { + provide: 'SnapshotService', + useValue: { + deleteFile: jest.fn(), + createSnapshots: jest.fn(), }, }, { @@ -152,6 +179,14 @@ describe('FileManagerService', () => { provide: getRepositoryToken(User), useClass: Repository, }, + { + provide: getRepositoryToken(Diff), + useClass: Repository, + }, + { + provide: getRepositoryToken(Snapshot), + useClass: Repository, + }, { provide: getRepositoryToken( ResetPasswordRequest, @@ -182,6 +217,15 @@ describe('FileManagerService', () => { usersService = module.get(UsersService); module.close(); + + snapshotService = module.get( + SnapshotService, + ); + module.close(); + + diffService = + module.get(DiffsService); + module.close(); }); describe('create_folder', () => { @@ -629,7 +673,6 @@ describe('FileManagerService', () => { it('should throw an error if UserID is undefined', async () => { const markdownFileDTO = new MarkdownFileDTO(); - try { await service.createFile(markdownFileDTO); expect(true).toBe(false); @@ -656,6 +699,23 @@ describe('FileManagerService', () => { returnFile.Name = 'New Document'; returnFile.Size = 0; + jest.spyOn( + s3Service, + 'createSnapshotObjectsForFile', + ); + + jest.spyOn( + s3Service, + 'createDiffObjectsForFile', + ); + jest + .spyOn(snapshotService, 'createSnapshots') + .mockResolvedValue([] as number[]); + + jest + .spyOn(diffService, 'createDiffs') + .mockResolvedValue([] as any); + const createFileSpy = jest.spyOn( s3Service, 'createFile', @@ -712,6 +772,36 @@ describe('FileManagerService', () => { ) .mockResolvedValueOnce(markdownFileDTO); + jest.spyOn( + s3Service, + 'createSnapshotObjectsForFile', + ); + + jest.spyOn( + s3Service, + 'createDiffObjectsForFile', + ); + jest + .spyOn(snapshotService, 'createSnapshots') + .mockResolvedValue([] as number[]); + + jest + .spyOn(diffService, 'createDiffs') + .mockResolvedValue([] as any); + + jest + .spyOn(markdownFilesService, 'create') + .mockResolvedValueOnce(markdownFileDTO); + + jest + .spyOn(Repository.prototype, 'save') + .mockResolvedValueOnce(markdownFileDTO); + + const createFileSpy = jest.spyOn( + s3Service, + 'createFile', + ); + const response = await service.createFile( markdownFileDTO, true, @@ -745,6 +835,22 @@ describe('FileManagerService', () => { createSpy.mockResolvedValue( markdownFileDTO, ); + jest.spyOn( + s3Service, + 'createSnapshotObjectsForFile', + ).mockResolvedValue([] as any); + + jest.spyOn( + s3Service, + 'createDiffObjectsForFile', + ).mockResolvedValue([] as any); + jest + .spyOn(snapshotService, 'createSnapshots') + .mockResolvedValue([] as number[]); + + jest + .spyOn(diffService, 'createDiffs') + .mockResolvedValue([] as any); const response = await service.createFile( markdownFileDTO, @@ -1381,10 +1487,37 @@ describe('FileManagerService', () => { .spyOn(s3Service, 'deleteFile') .mockResolvedValue(new MarkdownFileDTO()); + jest + .spyOn( + s3Service, + 'deleteDiffObjectsForFile', + ) + .mockResolvedValue([] as any); + jest + .spyOn( + s3Service, + 'deleteSnapshotObjectsForFile', + ) + .mockResolvedValue([] as any); + + jest + .spyOn(diffService, 'deleteDiffs') + .mockResolvedValue([] as any); + + jest + .spyOn(snapshotService, 'deleteSnapshots') + .mockResolvedValue([] as any); + jest .spyOn(markdownFilesService, 'remove') .mockResolvedValue(new MarkdownFile()); + jest + .spyOn(Repository.prototype, 'delete') + .mockResolvedValueOnce( + markdownFileDTO as any, + ); + const response = await service.deleteFile( markdownFileDTO, ); @@ -1435,6 +1568,24 @@ describe('FileManagerService', () => { .spyOn(markdownFilesService, 'remove') .mockResolvedValue(new MarkdownFile()); + jest.spyOn(Repository.prototype, 'delete'); + + jest.spyOn( + s3Service, + 'deleteSnapshotObjectsForFile', + ); + jest.spyOn( + s3Service, + 'deleteDiffObjectsForFile', + ); + jest.spyOn( + snapshotService, + 'deleteSnapshots', + ); + jest + .spyOn(diffService, 'deleteDiffs') + .mockResolvedValue([] as any); + const response = await service.deleteFile( markdownFileDTO, ); diff --git a/backend/src/file_manager/file_manager.service.ts b/backend/src/file_manager/file_manager.service.ts index 5d1699e3..629867e5 100644 --- a/backend/src/file_manager/file_manager.service.ts +++ b/backend/src/file_manager/file_manager.service.ts @@ -18,6 +18,8 @@ import { ExportDTO } from './dto/export.dto'; import * as CryptoJS from 'crypto-js'; import { ConversionService } from '../conversion/conversion.service'; import { ImportDTO } from './dto/import.dto'; +import { DiffsService } from '../diffs/diffs.service'; +import { SnapshotService } from '../snapshots/snapshots.service'; @Injectable() export class FileManagerService { @@ -28,14 +30,11 @@ export class FileManagerService { private conversionService: ConversionService, private userService: UsersService, private s3ServiceMock: S3ServiceMock, + private diffsService: DiffsService, + private snapshotService: SnapshotService, ) {} // File operations: ########################################################### - - // DB Requires the following fields to be initialised in the DTO: - // Path: string; .. TO PLACE THE FILE IN S3 - // Name: string; .. THE NEW NAME OF THE FILE - // Size: number; .. THE SIZE OF THE FILE IN MEGABYTES async createFile( markdownFileDTO: MarkdownFileDTO, isTest = false, @@ -72,19 +71,45 @@ export class FileManagerService { await this.s3ServiceMock.createFile( markdownFileDTO, ); + const returnMD_DTO = new MarkdownFileDTO(); + returnMD_DTO.MarkdownID = 'testID'; + returnMD_DTO.Name = 'New Document'; + returnMD_DTO.Content = 'testContent'; + returnMD_DTO.Path = ''; + returnMD_DTO.DateCreated = new Date(); + returnMD_DTO.LastModified = new Date(); + returnMD_DTO.Size = 0; + returnMD_DTO.ParentFolderID = ''; + returnMD_DTO.UserID = 0; + return returnMD_DTO; } else { await this.s3service.createFile( markdownFileDTO, ); - } + await this.s3service.createDiffObjectsForFile( + markdownFileDTO, + ); + + await this.s3service.createSnapshotObjectsForFile( + markdownFileDTO, + ); + + const snapshotIDs: string[] = + await this.snapshotService.createSnapshots( + markdownFileDTO, + ); + + await this.diffsService.createDiffs( + markdownFileDTO, + snapshotIDs, + ); + } return await this.markdownFilesService.create( markdownFileDTO, - ); // return the file to know ID; + ); } - // This function will need to return the latest diffs for - // the specified file (at most, 10 diffs) async retrieveFile( markdownFileDTO: MarkdownFileDTO, isTest = false, @@ -236,8 +261,6 @@ export class FileManagerService { ); } - // Assuming frontend will send the NextDiffID - return await this.markdownFilesService.updateAfterModification( markdownFileDTO, ); @@ -257,10 +280,29 @@ export class FileManagerService { await this.s3ServiceMock.deleteFile( markdownFileDTO, ); + + // TODO: decide on a return value here, otherwise two calls to remove md file are made + // OR remove the mdservice.remove call from s3serviceMock.deleteFile } else { await this.s3service.deleteFile( markdownFileDTO, ); + + await this.s3service.deleteDiffObjectsForFile( + markdownFileDTO, + ); + + await this.s3service.deleteSnapshotObjectsForFile( + markdownFileDTO, + ); + + await this.diffsService.deleteDiffs( + markdownFileDTO, + ); + + await this.snapshotService.deleteSnapshots( + markdownFileDTO, + ); } return this.markdownFilesService.remove( diff --git a/backend/src/markdown_files/markdown_files.service.ts b/backend/src/markdown_files/markdown_files.service.ts index 4de622da..d54edc74 100644 --- a/backend/src/markdown_files/markdown_files.service.ts +++ b/backend/src/markdown_files/markdown_files.service.ts @@ -3,6 +3,7 @@ import { HttpStatus, Injectable, } from '@nestjs/common'; +import 'dotenv/config'; import { MarkdownFileDTO } from './dto/markdown_file.dto'; import { MarkdownFile } from './entities/markdown_file.entity'; import { InjectRepository } from '@nestjs/typeorm'; @@ -90,8 +91,6 @@ export class MarkdownFilesService { ); markdownToUpdate.LastModified = new Date(); markdownToUpdate.Size = markdownDTO.Size; - markdownToUpdate.NextDiffID = - (markdownToUpdate.NextDiffID + 1) % 10; return this.markdownFileRepository.save( markdownToUpdate, ); @@ -120,4 +119,64 @@ export class MarkdownFilesService { markdownToUpdate, ); } + + ///===---------------------------------------------------- + + async getNextDiffID(markdownID: string) { + const markdownFile = + await this.markdownFileRepository.findOneBy( + { + MarkdownID: markdownID, + }, + ); + return markdownFile.NextDiffID; + } + + ///===---------------------------------------------------- + + async getNextSnapshotID(markdownID: string) { + const markdownFile = + await this.markdownFileRepository.findOneBy( + { + MarkdownID: markdownID, + }, + ); + return markdownFile.NextSnapshotID; + } + + ///===---------------------------------------------------- + + async incrementNextDiffID(markdownID: string) { + const markdownFile = + await this.markdownFileRepository.findOneBy( + { + MarkdownID: markdownID, + }, + ); + markdownFile.NextDiffID = + (markdownFile.NextDiffID + 1) % + parseInt(process.env.MAX_DIFFS); + return await this.markdownFileRepository.save( + markdownFile, + ); + } + + ///===---------------------------------------------------- + + async incrementNextSnapshotID( + markdownID: string, + ) { + const markdownFile = + await this.markdownFileRepository.findOneBy( + { + MarkdownID: markdownID, + }, + ); + markdownFile.NextSnapshotID = + (markdownFile.NextSnapshotID + 1) % + parseInt(process.env.MAX_SNAPSHOTS); + return this.markdownFileRepository.save( + markdownFile, + ); + } } diff --git a/backend/src/s3/s3.service.ts b/backend/src/s3/s3.service.ts index 69d69e4d..26195152 100644 --- a/backend/src/s3/s3.service.ts +++ b/backend/src/s3/s3.service.ts @@ -7,9 +7,11 @@ import { PutObjectCommand, S3Client, } from '@aws-sdk/client-s3'; -import * as fs from 'fs/promises'; // for local storage +// import * as fs from 'fs/promises'; // for local storage import * as CryptoJS from 'crypto-js'; import { AssetDTO } from '../assets/dto/asset.dto'; +import { DiffDTO } from '../diffs/dto/diffs.dto'; +import { SnapshotDTO } from '../snapshots/dto/snapshot.dto'; @Injectable() export class S3Service { @@ -22,6 +24,8 @@ export class S3Service { awsS3SecretAccessKey = process.env.AWS_S3_SECRET_ACCESS_KEY; + ///===---------------------------------------------------- + private readonly s3Client = new S3Client({ credentials: { accessKeyId: this.awsS3AccessKeyId, @@ -30,6 +34,8 @@ export class S3Service { region: this.awsS3BucketRegion, }); + ///===---------------------------------------------------- + async deleteFile( markdownFileDTO: MarkdownFileDTO, ) { @@ -57,10 +63,11 @@ export class S3Service { console.log('Delete Error: ' + err); return undefined; } - return markdownFileDTO; } + ///===---------------------------------------------------- + async createFile( markdownFileDTO: MarkdownFileDTO, ) { @@ -103,28 +110,13 @@ export class S3Service { return undefined; } - for (let i = 0; i < 10; i++) { - try { - await this.s3Client.send( - new PutObjectCommand({ - Bucket: this.awsS3BucketName, - Key: `${filePath}/diff/${i}`, - Body: new Uint8Array(Buffer.from('')), - }), - ); - } catch (err) { - console.log( - `S3 diff ${i} file creation error: ` + - err, - ); - return undefined; - } - } markdownFileDTO.Content = ''; markdownFileDTO.Size = 0; return markdownFileDTO; } + ///===---------------------------------------------------- + async saveFile( markdownFileDTO: MarkdownFileDTO, ) { @@ -162,24 +154,26 @@ export class S3Service { return undefined; } - try { - // await fs.writeFile( - // `./storage/${filePath}/diff/${markdownFileDTO.NextDiffID}`, - // fileData, - // 'utf-8', - // ); - /*const response = */ await this.s3Client.send( - new PutObjectCommand({ - Bucket: this.awsS3BucketName, - Key: `${filePath}/diff/${markdownFileDTO.NextDiffID}`, - Body: markdownFileDTO.NewDiff, - }), - ); - } catch (err) {} + // try { + // // await fs.writeFile( + // // `./storage/${filePath}/diff/${markdownFileDTO.NextDiffID}`, + // // fileData, + // // 'utf-8', + // // ); + // /*const response = */ await this.s3Client.send( + // new PutObjectCommand({ + // Bucket: this.awsS3BucketName, + // Key: `${filePath}/diff/${markdownFileDTO.NextDiffID}`, + // Body: markdownFileDTO.NewDiff, + // }), + // ); + // } catch (err) {} return markdownFileDTO; } + ///===---------------------------------------------------- + async retrieveFile( markdownFileDTO: MarkdownFileDTO, ) { @@ -222,6 +216,393 @@ export class S3Service { return markdownFileDTO; } + ///===---------------------------------------------------- + + async createDiffObjectsForFile( + markdownFileDTO: MarkdownFileDTO, + ) { + const filePath = `${markdownFileDTO.UserID}/${markdownFileDTO.MarkdownID}`; + for ( + let i = 0; + i < parseInt(process.env.MAX_DIFFS); + i++ + ) { + try { + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.awsS3BucketName, + Key: `${filePath}/diff/${i}`, + Body: new Uint8Array(Buffer.from('')), + }), + ); + } catch (err) { + console.log( + `S3 diff ${i} object creation error: ` + + err, + ); + return undefined; + } + } + } + + ///===---------------------------------------------------- + + async saveDiff( + diffDTO: DiffDTO, + nextDiffID: number, + ) { + const filePath = `${diffDTO.UserID}/${diffDTO.MarkdownID}`; + + console.log( + 'diffDTO.Content: ', + diffDTO.Content, + ); + try { + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.awsS3BucketName, + Key: `${filePath}/diff/${nextDiffID}`, + Body: diffDTO.Content, + }), + ); + } catch (err) { + console.log('Write File Error: ' + err); + return undefined; + } + return diffDTO; + } + + ///===---------------------------------------------------- + + async getDiffSet( + S3DiffIDs: number[], + userID: number, + markdownID: string, + ) { + const filePath = `${userID}/${markdownID}`; + + const diffDTOs: DiffDTO[] = []; + + for (let i = 0; i < S3DiffIDs.length; i++) { + try { + const response = await this.s3Client.send( + new GetObjectCommand({ + Bucket: this.awsS3BucketName, + Key: `${filePath}/diff/${S3DiffIDs[i]}`, + }), + ); + + const diffDTO = new DiffDTO(); + diffDTO.Content = + await response.Body.transformToString(); + diffDTOs.push(diffDTO); + } catch (err) { + console.log( + `S3 diff ${i} read error: ` + err, + ); + return undefined; + } + } + return diffDTOs; + } + + ///===---------------------------------------------------- + + async getSnapshot( + S3SnapshotID: number, + userID: number, + markdownID: string, + ) { + const filePath = `${userID}/${markdownID}`; + + try { + const response = await this.s3Client.send( + new GetObjectCommand({ + Bucket: this.awsS3BucketName, + Key: `${filePath}/snapshot/${S3SnapshotID}`, + }), + ); + + const snapshotDTO = new SnapshotDTO(); + snapshotDTO.Content = + await response.Body.transformToString(); + return snapshotDTO; + } catch (err) { + console.log( + `S3 snapshot ${S3SnapshotID} read error: ` + + err, + ); + return undefined; + } + } + + ///===---------------------------------------------------- + + async saveOldestSnapshot(diffDTO: DiffDTO) { + const filePath = `${diffDTO.UserID}/${diffDTO.MarkdownID}`; + + const markdownFileDTO = new MarkdownFileDTO(); + markdownFileDTO.UserID = diffDTO.UserID; + markdownFileDTO.MarkdownID = + diffDTO.MarkdownID; + + const file = await this.retrieveFile( + markdownFileDTO, + ); + + try { + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.awsS3BucketName, + Key: `${filePath}/snapshot/${process.env.MAX_SNAPSHOTS}`, + Body: file.Content, + }), + ); + } catch (err) { + console.log('Write File Error: ' + err); + return undefined; + } + return diffDTO; + } + + ///===---------------------------------------------------- + + async retrieveOldestSnapshot( + markdownFileDTO: MarkdownFileDTO, + ) { + const filePath = `${markdownFileDTO.UserID}/${markdownFileDTO.MarkdownID}`; + + try { + const response = await this.s3Client.send( + new GetObjectCommand({ + Bucket: this.awsS3BucketName, + Key: `${filePath}/snapshot/${process.env.MAX_SNAPSHOTS}`, + }), + ); + + markdownFileDTO.Content = + await response.Body.transformToString(); + markdownFileDTO.Size = + response.ContentLength; + } catch (err) { + console.log('Read File Error: ' + err); + return undefined; + } + } + + ///===---------------------------------------------------- + + /** + * @notes every snapshot is associated with n previous diffs. + * The IDs of the associated diffs do not change unless + * a change is made to the relevant versioning env variables. + */ + + async getAllDiffsForSnapshot( + snapshotDTO: SnapshotDTO, + ) { + const filePath = `${snapshotDTO.UserID}/${snapshotDTO.MarkdownID}`; + + const diffDTOs: DiffDTO[] = []; + + const firstDiffID = + snapshotDTO.S3SnapshotID * + parseInt(process.env.DIFFS_PER_SNAPSHOT); + + for ( + let j = firstDiffID; + j < + firstDiffID + + parseInt(process.env.DIFFS_PER_SNAPSHOT); + j++ + ) { + try { + const response = await this.s3Client.send( + new GetObjectCommand({ + Bucket: this.awsS3BucketName, + Key: `${filePath}/diff/${j}`, + }), + ); + + const diffDTO = new DiffDTO(); + diffDTO.Content = + await response.Body.transformToString(); + diffDTOs.push(diffDTO); + } catch (err) { + console.log( + `S3 diff ${j} read error: ` + err, + ); + return undefined; + } + } + return diffDTOs; + } + + ///===---------------------------------------------------- + + async deleteDiffObjectsForFile( + markdownFileDTO: MarkdownFileDTO, + ) { + const filePath = `${markdownFileDTO.UserID}/${markdownFileDTO.MarkdownID}`; + for ( + let i = 0; + i < parseInt(process.env.MAX_DIFFS); + i++ + ) { + try { + await this.s3Client.send( + new DeleteObjectCommand({ + Bucket: this.awsS3BucketName, + Key: `${filePath}/diff/${i}`, + }), + ); + } catch (err) { + console.log( + 'Delete all diffs error: ' + err, + ); + return undefined; + } + } + } + + ///===---------------------------------------------------- + + async createSnapshotObjectsForFile( + markdownFileDTO: MarkdownFileDTO, + ) { + const filePath = `${markdownFileDTO.UserID}/${markdownFileDTO.MarkdownID}`; + + // Create snapshot objects + for ( + let j = 0; + j < parseInt(process.env.MAX_SNAPSHOTS); + j++ + ) { + try { + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.awsS3BucketName, + Key: `${filePath}/snapshot/${j}`, + Body: new Uint8Array(Buffer.from('')), + }), + ); + } catch (err) { + console.log( + `S3 snapshot ${j} object creation error`, + ); + return undefined; + } + } + } + + ///===---------------------------------------------------- + + async deleteSnapshotObjectsForFile( + markdownFileDTO: MarkdownFileDTO, + ) { + const filePath = `${markdownFileDTO.UserID}/${markdownFileDTO.MarkdownID}`; + for ( + let i = 0; + i < parseInt(process.env.MAX_SNAPSHOTS); + i++ + ) { + try { + await this.s3Client.send( + new DeleteObjectCommand({ + Bucket: this.awsS3BucketName, + Key: `${filePath}/snapshot/${i}`, + }), + ); + } catch (err) { + console.log( + 'Delete all snapshots error: ' + err, + ); + return undefined; + } + } + } + + ///===---------------------------------------------------- + + async saveSnapshot( + diffDTO: DiffDTO, + nextSnapshotID: number, + ) { + const markdownFileDTO: MarkdownFileDTO = + new MarkdownFileDTO(); + markdownFileDTO.UserID = diffDTO.UserID; + markdownFileDTO.MarkdownID = + diffDTO.MarkdownID; + const fileDTO = await this.retrieveFile( + markdownFileDTO, + ); + + const filePath = `${diffDTO.UserID}/${diffDTO.MarkdownID}`; + + console.log( + 'fileDTO.Content: ', + fileDTO.Content, + ); + + try { + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.awsS3BucketName, + Key: `${filePath}/snapshot/${nextSnapshotID}`, + Body: fileDTO.Content, + }), + ); + } catch (err) { + console.log('Write File Error: ' + err); + return undefined; + } + + return diffDTO; + } + + ///===---------------------------------------------------- + + async retrieveAllSnapshots( + logicalOrder: number[], + snapshotDTO: SnapshotDTO, + ) { + const filePath = `${snapshotDTO.UserID}/${snapshotDTO.MarkdownID}`; + + const snapshotDTOs: SnapshotDTO[] = []; + + for ( + let i = 0; + i < logicalOrder.length; + i++ + ) { + console.log( + 'Trying to retrieve snapshot on path ', + `${filePath}/snapshot/${logicalOrder[i]}`, + ); + try { + const response = await this.s3Client.send( + new GetObjectCommand({ + Bucket: this.awsS3BucketName, + Key: `${filePath}/snapshot/${logicalOrder[i]}`, + }), + ); + + const snapshotDTO = new SnapshotDTO(); + snapshotDTO.Content = + await response.Body.transformToString(); + snapshotDTOs.push(snapshotDTO); + } catch (err) { + console.log( + `S3 snapshot ${i} read error: ` + err, + ); + return undefined; + } + } + return snapshotDTOs; + } + + ///===---------------------------------------------------- + async createAsset(assetDTO: AssetDTO) { // Generate new AssetID const newAssetDTO = new AssetDTO(); @@ -251,6 +632,8 @@ export class S3Service { return newAssetDTO; } + ///===---------------------------------------------------- + async saveTextractResponse( saveAssetDTO: AssetDTO, textractResponse: any, @@ -299,6 +682,8 @@ export class S3Service { return saveAssetDTO; } + ///===---------------------------------------------------- + async saveImageAsset(saveAssetDTO: AssetDTO) { let filePath = `${saveAssetDTO.UserID}`; @@ -345,6 +730,8 @@ export class S3Service { return saveAssetDTO; } + ///===---------------------------------------------------- + async saveTextAssetImage( saveAssetDTO: AssetDTO, ) { @@ -393,6 +780,8 @@ export class S3Service { return saveAssetDTO; } + ///===---------------------------------------------------- + async retrieveAssetByID( assetID: string, userID: number, @@ -446,6 +835,8 @@ export class S3Service { } } + ///===---------------------------------------------------- + async retrieveAsset( retrieveAssetDTO: AssetDTO, ) { @@ -493,6 +884,8 @@ export class S3Service { return retrieveAssetDTO; } + ///===---------------------------------------------------- + async deleteAsset(assetDTO: AssetDTO) { console.log('Delete Asset (s3)'); let filePath = `${assetDTO.UserID}`; diff --git a/backend/src/snapshots/dto/snapshot.dto.ts b/backend/src/snapshots/dto/snapshot.dto.ts new file mode 100644 index 00000000..cd66fd73 --- /dev/null +++ b/backend/src/snapshots/dto/snapshot.dto.ts @@ -0,0 +1,21 @@ +export class SnapshotDTO { + SnapshotID: string; + MarkdownID: string; + UserID: number; + DisplayID: number; + S3SnapshotID: number; + LastModified: Date; + Content: string; + OldestSnapshot: boolean; + + constructor() { + this.SnapshotID = undefined; + this.MarkdownID = undefined; + this.UserID = undefined; + this.DisplayID = undefined; + this.S3SnapshotID = undefined; + this.LastModified = undefined; + this.Content = undefined; + this.OldestSnapshot = false; + } +} diff --git a/backend/src/snapshots/entities/snapshots.entity.ts b/backend/src/snapshots/entities/snapshots.entity.ts index 0b128556..47892d59 100644 --- a/backend/src/snapshots/entities/snapshots.entity.ts +++ b/backend/src/snapshots/entities/snapshots.entity.ts @@ -13,10 +13,7 @@ export class Snapshot { MarkdownID: string; @Column() - UserID: string; - - @Column() - DisplayID: string; + UserID: number; @Column() S3SnapshotID: number; @@ -27,4 +24,7 @@ export class Snapshot { onUpdate: 'CURRENT_TIMESTAMP(3)', }) LastModified: Date; + + @Column() + HasBeenUsed: boolean; } diff --git a/backend/src/snapshots/snapshots.service.ts b/backend/src/snapshots/snapshots.service.ts index f5841b30..ff23d5dc 100644 --- a/backend/src/snapshots/snapshots.service.ts +++ b/backend/src/snapshots/snapshots.service.ts @@ -1,7 +1,10 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; -import { Snapshot } from "./entities/snapshots.entity"; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Snapshot } from './entities/snapshots.entity'; +import { MarkdownFileDTO } from '../markdown_files/dto/markdown_file.dto'; +import * as CryptoJS from 'crypto-js'; +import { SnapshotDTO } from './dto/snapshot.dto'; @Injectable() export class SnapshotService { @@ -9,4 +12,178 @@ export class SnapshotService { @InjectRepository(Snapshot) private snapshotRepository: Repository, ) {} + + ///===----------------------------------------------------- + + async createSnapshots( + markdownFileDTO: MarkdownFileDTO, + ) { + const snapshotIDs = []; + const snapshotRecords = []; + for ( + let i = 0; + i < parseInt(process.env.MAX_SNAPSHOTS); + i++ + ) { + const snapshotID = CryptoJS.SHA256( + markdownFileDTO.UserID.toString() + + new Date().getTime().toString() + + i.toString(), + ).toString(); + + snapshotRecords.push({ + SnapshotID: snapshotID, + MarkdownID: markdownFileDTO.MarkdownID, + UserID: markdownFileDTO.UserID, + S3SnapshotID: i, + HasBeenUsed: false, + }); + + snapshotIDs.push(snapshotID); + } + await this.snapshotRepository.insert( + snapshotRecords, + ); + + return snapshotIDs; + } + + ///===----------------------------------------------------- + + async updateSnapshot( + markdownID: string, + nextSnapshotID: number, + ) { + const snapshot = + await this.snapshotRepository.findOne({ + where: { + MarkdownID: markdownID, + S3SnapshotID: nextSnapshotID, + }, + }); + + snapshot.LastModified = new Date(); + snapshot.HasBeenUsed = true; + await this.snapshotRepository.save(snapshot); + } + + ///===----------------------------------------------------- + + async getAllSnapshots( + markdownFileDTO: MarkdownFileDTO, + ) { + return await this.snapshotRepository.find({ + where: { + MarkdownID: markdownFileDTO.MarkdownID, + HasBeenUsed: true, + }, + }); + } + + ///===----------------------------------------------------- + + async resetSnapshot( + markdownID: string, + nextSnapshotID: number, + ) { + const snapshot = + await this.snapshotRepository.findOne({ + where: { + MarkdownID: markdownID, + S3SnapshotID: nextSnapshotID, + }, + }); + + snapshot.HasBeenUsed = false; + await this.snapshotRepository.save(snapshot); + return snapshot; + } + + ///===----------------------------------------------------- + + async getSnapshotByS3SnapshotID( + markdownID: string, + s3SnapshotID: number, + ) { + return await this.snapshotRepository.findOne({ + where: { + MarkdownID: markdownID, + S3SnapshotID: s3SnapshotID, + }, + }); + } + + // ///===----------------------------------------------------- + + // async getNextSnapshotID(markdownID: string) { + // const markdownFile = + // await this.snapshotRepository.findOne({ + // where: { + // MarkdownID: markdownID, + // }, + // }); + // return markdownFile.S3SnapshotID; + // } + + ///===----------------------------------------------------- + + async deleteSnapshots( + markdownFileDTO: MarkdownFileDTO, + ) { + await this.snapshotRepository.delete({ + MarkdownID: markdownFileDTO.MarkdownID, + HasBeenUsed: true, + }); + } + + ///===----------------------------------------------------- + + getLogicalIndex( + s3Index: number, + nextSnapshotID: number, + arr_len: number, + ): number { + return ( + (s3Index - nextSnapshotID + arr_len) % + arr_len + ); + } + + ///===----------------------------------------------------- + + async getLogicalSnapshotOrder( + snapshotDTOs: SnapshotDTO[], + nextDiffID: number, + ) { + console.log( + 'snapshots.service snapshotDTOS: ', + snapshotDTOs, + ); + const arrLength = parseInt( + process.env.MAX_DIFFS, + ); + const logicalOrder: SnapshotDTO[] = new Array( + arrLength, + ).fill(0); + for (let idx = 0; idx < arrLength; idx++) { + const logicalIndex = this.getLogicalIndex( + snapshotDTOs[idx].S3SnapshotID, + nextDiffID, + arrLength, + ); + logicalOrder[logicalIndex] = + snapshotDTOs[idx]; + } + return logicalOrder; + } + + ///===----------------------------------------------------- + + async getSnapshotByID(snapshotID: string) { + return await this.snapshotRepository.findOne({ + where: { + SnapshotID: snapshotID, + }, + }); + } } diff --git a/backend/src/version_control/dto/version_history.dto.ts b/backend/src/version_control/dto/version_history.dto.ts new file mode 100644 index 00000000..70d728d8 --- /dev/null +++ b/backend/src/version_control/dto/version_history.dto.ts @@ -0,0 +1,12 @@ +import { DiffDTO } from '../../diffs/dto/diffs.dto'; +import { SnapshotDTO } from '../../snapshots/dto/snapshot.dto'; + +export class VersionHistoryDTO { + DiffHistory: DiffDTO[]; + SnapshotHistory: SnapshotDTO[]; + + constructor() { + this.DiffHistory = undefined; + this.SnapshotHistory = undefined; + } +} diff --git a/backend/src/version_control/dto/version_set.dto.ts b/backend/src/version_control/dto/version_set.dto.ts new file mode 100644 index 00000000..ff47222e --- /dev/null +++ b/backend/src/version_control/dto/version_set.dto.ts @@ -0,0 +1,16 @@ +import { DiffDTO } from '../../diffs/dto/diffs.dto'; +import { SnapshotDTO } from '../../snapshots/dto/snapshot.dto'; + +export class VersionSetDTO { + UserID: number; + MarkdownID: string; + DiffHistory: string[]; + SnapshotID: string; + + constructor() { + this.UserID = undefined; + this.MarkdownID = undefined; + this.DiffHistory = undefined; + this.SnapshotID = undefined; + } +} diff --git a/backend/src/version_control/version_control.controller.ts b/backend/src/version_control/version_control.controller.ts new file mode 100644 index 00000000..33220e9c --- /dev/null +++ b/backend/src/version_control/version_control.controller.ts @@ -0,0 +1,108 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, +} from '@nestjs/common'; +import { VersionControlService } from './version_control.service'; +import { DiffDTO } from '../diffs/dto/diffs.dto'; +import { SnapshotDTO } from '../snapshots/dto/snapshot.dto'; +import { MarkdownFileDTO } from '../markdown_files/dto/markdown_file.dto'; +import { VersionHistoryDTO } from './dto/version_history.dto'; +import { SnapshotService } from '../snapshots/snapshots.service'; +import { DiffsService } from '../diffs/diffs.service'; +import { MarkdownFilesService } from '../markdown_files/markdown_files.service'; +import { VersionSetDTO } from './dto/version_set.dto'; + +@Controller('version_control') +export class VersionControlController { + constructor( + private readonly versionControlService: VersionControlService, + private readonly snapshotService: SnapshotService, + private readonly diffService: DiffsService, + private readonly markdownFileService: MarkdownFilesService, + ) {} + + ///===---------------------------------------------------- + + @Post('save_diff') + saveDiff(@Body() diffDTO: DiffDTO) { + this.versionControlService.saveDiff(diffDTO); + } + + ///===---------------------------------------------------- + + @Post('get_all_snapshots') + @HttpCode(HttpStatus.OK) + getAllSnapshots( + @Body() snapshotDTO: SnapshotDTO, + ) { + return this.versionControlService.getAllSnapshots( + snapshotDTO, + ); + } + + ///===---------------------------------------------------- + + @Post('get_history_set') + @HttpCode(HttpStatus.OK) + getHistorySet( + @Body() versionSetDTO: VersionSetDTO, + ) { + return this.versionControlService.getHistorySet( + versionSetDTO, + ); + } + + ///===---------------------------------------------------- + + @Post('load_history') + @HttpCode(HttpStatus.OK) + async loadHistory( + @Body() markdownFileDTO: MarkdownFileDTO, + ) { + const versionHistoryDTO = + new VersionHistoryDTO(); + + // Populate snapshot history + + // const nextSnapshotID = + // await this.markdownFileService.getNextSnapshotID( + // markdownFileDTO.MarkdownID, + // ); + + const snapshots = + await this.snapshotService.getAllSnapshots( + markdownFileDTO, + ); + + const snapshotDTOs = + await this.versionControlService.convertSnapshotsToSnapshotDTOs( + snapshots, + ); + + versionHistoryDTO.SnapshotHistory = + snapshotDTOs; + + // Populate diff history + + // const nextDiffID = + // await this.markdownFileService.getNextDiffID( + // markdownFileDTO.MarkdownID, + // ); + + const diffs = + await this.diffService.getAllDiffs( + markdownFileDTO.MarkdownID, + ); + + const diffDTOs = + await this.versionControlService.convertDiffsToDiffDTOs( + diffs, + ); + + versionHistoryDTO.DiffHistory = diffDTOs; + return versionHistoryDTO; + } +} diff --git a/backend/src/version_control/version_control.module.ts b/backend/src/version_control/version_control.module.ts new file mode 100644 index 00000000..b66a72c2 --- /dev/null +++ b/backend/src/version_control/version_control.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DiffsService } from '../diffs/diffs.service'; +import { SnapshotService } from '../snapshots/snapshots.service'; +import { Diff } from '../diffs/entities/diffs.entity'; +import { Snapshot } from '../snapshots/entities/snapshots.entity'; +import { VersionControlController } from './version_control.controller'; +import { VersionControlService } from './version_control.service'; +import { MarkdownFilesService } from '../markdown_files/markdown_files.service'; +import { S3Service } from '../s3/s3.service'; +import { S3ServiceMock } from '../s3/__mocks__/s3.service'; +import { MarkdownFile } from '../markdown_files/entities/markdown_file.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Diff]), + TypeOrmModule.forFeature([Snapshot]), + TypeOrmModule.forFeature([MarkdownFile]), + ], + controllers: [VersionControlController], + providers: [ + DiffsService, + SnapshotService, + VersionControlService, + MarkdownFilesService, + S3Service, + S3ServiceMock, + ], +}) +export class VersionControlModule {} diff --git a/backend/src/version_control/version_control.service.spec.ts b/backend/src/version_control/version_control.service.spec.ts new file mode 100644 index 00000000..3c66216d --- /dev/null +++ b/backend/src/version_control/version_control.service.spec.ts @@ -0,0 +1,72 @@ +import { + Test, + TestingModule, +} from '@nestjs/testing'; +import { VersionControlService } from './version_control.service'; +import { DiffsService } from '../diffs/diffs.service'; +import { MarkdownFilesService } from '../markdown_files/markdown_files.service'; +import { S3Service } from '../s3/s3.service'; +import { S3ServiceMock } from '../s3/__mocks__/s3.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Diff } from '../diffs/entities/diffs.entity'; +import { Repository } from 'typeorm'; +import { MarkdownFile } from '../markdown_files/entities/markdown_file.entity'; +import { SnapshotService } from '../snapshots/snapshots.service'; +import { Snapshot } from '../snapshots/entities/snapshots.entity'; + +describe('VersionControlService', () => { + let service: VersionControlService; + let diffsService: DiffsService; + let markdownFilesService: MarkdownFilesService; + let s3Service: S3Service; + let s3ServiceMock: S3ServiceMock; + + beforeEach(async () => { + const module: TestingModule = + await Test.createTestingModule({ + providers: [ + VersionControlService, + DiffsService, + MarkdownFilesService, + S3Service, + S3ServiceMock, + SnapshotService, + { + provide: getRepositoryToken(Diff), + useClass: Repository, + }, + { + provide: + getRepositoryToken(MarkdownFile), + useClass: Repository, + }, + { + provide: getRepositoryToken(Snapshot), + useClass: Repository, + }, + ], + }).compile(); + + service = module.get( + VersionControlService, + ); + + diffsService = + module.get(DiffsService); + + markdownFilesService = + module.get( + MarkdownFilesService, + ); + + s3Service = module.get(S3Service); + + s3ServiceMock = module.get( + S3ServiceMock, + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/version_control/version_control.service.ts b/backend/src/version_control/version_control.service.ts new file mode 100644 index 00000000..6d6f4c2d --- /dev/null +++ b/backend/src/version_control/version_control.service.ts @@ -0,0 +1,308 @@ +import { Injectable } from '@nestjs/common'; +import { DiffsService } from '../diffs/diffs.service'; +import { DiffDTO } from '../diffs/dto/diffs.dto'; +import { MarkdownFilesService } from '../markdown_files/markdown_files.service'; +import { S3ServiceMock } from '../s3/__mocks__/s3.service'; +import { S3Service } from '../s3/s3.service'; +import { SnapshotDTO } from '../snapshots/dto/snapshot.dto'; +import { SnapshotService } from '../snapshots/snapshots.service'; +import { Snapshot } from '../snapshots/entities/snapshots.entity'; +import { Diff } from '../diffs/entities/diffs.entity'; +import { VersionHistoryDTO } from './dto/version_history.dto'; +import { VersionSetDTO } from './dto/version_set.dto'; + +@Injectable() +export class VersionControlService { + constructor( + private diffService: DiffsService, + private snapshotService: SnapshotService, + private markdownFileService: MarkdownFilesService, + private s3Service: S3Service, + private s3ServiceMock: S3ServiceMock, + ) {} + + ///===----------------------------------------------------- + + async saveDiff(diffDTO: DiffDTO) { + const nextDiffID = + await this.markdownFileService.getNextDiffID( + diffDTO.MarkdownID, + ); + + const nextDiff = + await this.diffService.getDiff( + diffDTO, + nextDiffID, + ); + + await this.s3Service.saveDiff( + diffDTO, + nextDiffID, + ); + + if ( + nextDiffID !== 0 || + nextDiff.HasBeenUsed + ) { + if ( + (nextDiffID + 1) % + parseInt( + process.env.DIFFS_PER_SNAPSHOT, + ) === + 0 + ) { + await this.saveSnapshot(diffDTO); + + // Reset next snapshot and associated diffs + const nextSnapshotID = + await this.markdownFileService.getNextSnapshotID( + diffDTO.MarkdownID, + ); + + const nextSnapshot = + await this.snapshotService.resetSnapshot( + diffDTO.MarkdownID, + nextSnapshotID, + ); + + await this.diffService.resetDiffs( + diffDTO.MarkdownID, + nextSnapshot.SnapshotID, + ); + } + } + + await this.diffService.updateDiff( + diffDTO, + nextDiffID, + ); + + await this.markdownFileService.incrementNextDiffID( + diffDTO.MarkdownID, + ); + } + + ///===----------------------------------------------------- + + async saveSnapshot(diffDTO: DiffDTO) { + const nextSnapshotID = + await this.markdownFileService.getNextSnapshotID( + diffDTO.MarkdownID, + ); + + await this.s3Service.saveSnapshot( + diffDTO, + nextSnapshotID, + ); + + await this.snapshotService.updateSnapshot( + diffDTO.MarkdownID, + nextSnapshotID, + ); + + await this.markdownFileService.incrementNextSnapshotID( + diffDTO.MarkdownID, + ); + } + + ///===----------------------------------------------------- + + // getDiffSetForSnapshot(snapshot: SnapshotDTO) {} + + ///===----------------------------------------------------- + + /** + * @dev frontend calls retrieveOne for file opened by user + * @dev frontend needs to render all snapshots for the file + * as dropdown options + * + * @returns all snapshots for this file, in logical order + */ + async getAllSnapshots( + snapshotDTO: SnapshotDTO, + ) { + const snapshotRange = Array.from( + { + length: parseInt( + process.env.MAX_SNAPSHOTS, + ), + }, + (_, index) => index, + ); + + const nextSnapshotID = + await this.markdownFileService.getNextSnapshotID( + snapshotDTO.MarkdownID, + ); + + const logicalOrder = this.getLogicalOrder( + snapshotRange, + nextSnapshotID, + ); + + let snapshots = + await this.s3Service.retrieveAllSnapshots( + logicalOrder, + snapshotDTO, + ); + + snapshots = + this.pruneEmptySnapshots(snapshots); + + return snapshots; + } + + ///===---------------------------------------------------- + // Helpers + + getDiffSetIndices(snapshotID: number) { + const diffsPerSnap = parseInt( + process.env.DIFFS_PER_SNAPSHOT, + ); + const first = snapshotID * diffsPerSnap; + const last = first + diffsPerSnap; + const indices = Array.from( + { length: last - first }, + (_, index) => index + first, + ); + return indices; + } + + ///===---------------------------------------------------- + + getLogicalIndex( + s3Index: number, + head: number, + arr_len: number, + ): number { + return (s3Index - head + arr_len) % arr_len; + } + + ///===----------------------------------------------------- + + getIndexInS3( + logicalIndex: number, + arr_len: number, + head: number, + ) { + return ( + (logicalIndex + head - arr_len) % arr_len + ); + } + + ///===----------------------------------------------------- + + getLogicalOrder( + arr: number[], + head: number, + ): number[] { + const logicalOrder: number[] = new Array( + arr.length, + ).fill(0); + for (let idx = 0; idx < arr.length; idx++) { + const logicalIndex = this.getLogicalIndex( + idx, + head, + arr.length, + ); + logicalOrder[logicalIndex] = arr[idx]; + } + return logicalOrder; + } + + ///===----------------------------------------------------- + + pruneEmptySnapshots(snapshots: SnapshotDTO[]) { + return snapshots.filter((snapshot) => { + return ( + snapshot.Content !== undefined && + snapshot.Content !== null && + snapshot.Content !== '' + ); + }); + } + + ///===---------------------------------------------------- + + async convertSnapshotsToSnapshotDTOs( + snapshots: Snapshot[], + ) { + const snapshotDTOs = []; + for (let i = 0; i < snapshots.length; i++) { + const snapshot = snapshots[i]; + const snapshotDTO = new SnapshotDTO(); + snapshotDTO.SnapshotID = + snapshot.SnapshotID; + snapshotDTO.MarkdownID = + snapshot.MarkdownID; + snapshotDTO.UserID = snapshot.UserID; + snapshotDTO.S3SnapshotID = + snapshot.S3SnapshotID; + snapshotDTO.LastModified = + snapshot.LastModified; + snapshotDTOs.push(snapshotDTO); + } + return snapshotDTOs; + } + + ///===---------------------------------------------------- + + async convertDiffsToDiffDTOs(diffs: Diff[]) { + const diffDTOs: DiffDTO[] = []; + for (let i = 0; i < diffs.length; i++) { + const diff = diffs[i]; + const diffDTO = new DiffDTO(); + diffDTO.DiffID = diff.DiffID; + diffDTO.MarkdownID = diff.MarkdownID; + diffDTO.UserID = diff.UserID; + diffDTO.S3DiffID = diff.S3DiffID; + diffDTO.LastModified = diff.LastModified; + diffDTO.SnapshotID = diff.SnapshotID; + diffDTOs.push(diffDTO); + } + return diffDTOs; + } + + ///===---------------------------------------------------- + + async getHistorySet( + versionSetDTO: VersionSetDTO, + ) { + const diffs = + await this.diffService.getAllDiffs( + versionSetDTO.MarkdownID, + ); + + const S3DiffIDs: number[] = diffs.map( + (diff) => diff.S3DiffID, + ); + + const diffDTOs = + await this.s3Service.getDiffSet( + S3DiffIDs, + versionSetDTO.UserID, + versionSetDTO.MarkdownID, + ); + + const snapshot = + await this.snapshotService.getSnapshotByID( + versionSetDTO.SnapshotID, + ); + + const snapshotDTO = + await this.s3Service.getSnapshot( + snapshot.S3SnapshotID, + versionSetDTO.UserID, + versionSetDTO.MarkdownID, + ); + + const versionHistoryDTO = + new VersionHistoryDTO(); + + versionHistoryDTO.DiffHistory = diffDTOs; + versionHistoryDTO.SnapshotHistory = [ + snapshotDTO, + ]; + return versionHistoryDTO; + } +} diff --git a/backend/test/file_manager.e2e-spec.ts b/backend/test/file_manager.e2e-spec.ts index 6baa1b2a..106dd64a 100644 --- a/backend/test/file_manager.e2e-spec.ts +++ b/backend/test/file_manager.e2e-spec.ts @@ -18,6 +18,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { MarkdownFile } from '../src/markdown_files/entities/markdown_file.entity'; // import { S3Service } from '../src/s3/s3.service'; import { S3ServiceMock } from '../src/s3/__mocks__/s3.service'; +import { Snapshot } from '../src/snapshots/entities/snapshots.entity'; // import { FileDTO } from '../src/s3/dto/file.dto'; // let startTime: string; @@ -34,6 +35,7 @@ enum ResetScope { describe('FileManagerController (integration)', () => { let app: INestApplication; let markdownFileRepository: Repository; + let snapshotRepository: Repository; let s3Service: S3ServiceMock; beforeAll(async () => { @@ -47,6 +49,10 @@ describe('FileManagerController (integration)', () => { getRepositoryToken(MarkdownFile), useClass: Repository, }, + { + provide: getRepositoryToken(Snapshot), + useClass: Repository, + }, ], }).compile(); @@ -58,6 +64,9 @@ describe('FileManagerController (integration)', () => { >(getRepositoryToken(MarkdownFile)); s3Service = new S3ServiceMock(); + snapshotRepository = moduleFixture.get< + Repository + >(getRepositoryToken(Snapshot)); await resetUser(ResetScope.ALL); }); diff --git a/frontend/src/app/camera/camera.component.html b/frontend/src/app/camera/camera.component.html index 30c3b933..8bd9c151 100644 --- a/frontend/src/app/camera/camera.component.html +++ b/frontend/src/app/camera/camera.component.html @@ -6,7 +6,7 @@ + (click)="captured=false;resetFilters()">



Camera Not @@ -31,9 +31,20 @@

- +
+
+

Adjust Contrast

+ +

Adjust Brightness

+ +
+ + +
+
+