Skip to content

Commit

Permalink
feat: fallback text option and file conversion (#318)
Browse files Browse the repository at this point in the history
Additionally fixes up some validation views that were not appearing.
  • Loading branch information
mvdicarlo authored Nov 25, 2024
1 parent 64de273 commit 1fd51a6
Show file tree
Hide file tree
Showing 62 changed files with 1,908 additions and 412 deletions.
1 change: 0 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ module.exports = {
'react/jsx-props-no-spreading': 'off',
'react/react-in-jsx-scope': 'off',
'no-restricted-syntax': 'off',

'@typescript-eslint/ban-types': 'warn',
'import/no-extraneous-dependencies': [
'warn',
Expand Down
4 changes: 3 additions & 1 deletion apps/client-server/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AccountModule } from './account/account.module';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { DirectoryWatchersModule } from './directory-watchers/directory-watchers.module';
import { FileConverterModule } from './file-converter/file-converter.module';
import { FileModule } from './file/file.module';
import { FormGeneratorModule } from './form-generator/form-generator.module';
import { PostParsersModule } from './post-parsers/post-parsers.module';
Expand All @@ -17,10 +18,10 @@ import { TagConvertersModule } from './tag-converters/tag-converters.module';
import { TagGroupsModule } from './tag-groups/tag-groups.module';
import { UpdateModule } from './update/update.module';
import { UserSpecifiedWebsiteOptionsModule } from './user-specified-website-options/user-specified-website-options.module';
import { ValidationModule } from './validation/validation.module';
import { WebSocketModule } from './web-socket/web-socket.module';
import { WebsiteOptionsModule } from './website-options/website-options.module';
import { WebsitesModule } from './websites/websites.module';
import { ValidationModule } from './validation/validation.module';

@Module({
imports: [
Expand All @@ -45,6 +46,7 @@ import { ValidationModule } from './validation/validation.module';
PostModule,
PostParsersModule,
ValidationModule,
FileConverterModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class SubmissionFile extends PostyBirbEntity implements ISubmissionFile {
orphanRemoval: true,
lazy: false,
nullable: true,
serializer: (s) => s.id,
serializer: (s) => s?.id,
})
thumbnail: Rel<IFileBuffer>;

Expand All @@ -69,8 +69,9 @@ export class SubmissionFile extends PostyBirbEntity implements ISubmissionFile {
orphanRemoval: true,
lazy: false,
nullable: true,
serializer: (s) => s?.id,
})
altFile?: Rel<IFileBuffer>;
altFile: Rel<IFileBuffer>;

@Property({ type: 'integer', nullable: false, default: 0 })
size: number;
Expand All @@ -84,6 +85,9 @@ export class SubmissionFile extends PostyBirbEntity implements ISubmissionFile {
@Property({ type: 'boolean', nullable: false, default: false })
hasThumbnail: boolean;

@Property({ type: 'boolean', nullable: false, default: false })
hasAltFile: boolean;

@Property({
type: 'json',
nullable: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { IFileBuffer } from '@postybirb/types';

export interface IFileConverter {
/**
* Determines if the file can be converted to any of the allowable output mime types.
*
* @param {IFileBuffer} file
* @param {string[]} allowableOutputMimeTypes
* @return {*} {boolean}
*/
canConvert(file: IFileBuffer, allowableOutputMimeTypes: string[]): boolean;

/**
* Converts the file to one of the allowable output mime types.
*
* @param {IFileBuffer} file
* @param {string[]} allowableOutputMimeTypes
* @return {*} {Promise<IFileBuffer>}
*/
convert(
file: IFileBuffer,
allowableOutputMimeTypes: string[],
): Promise<IFileBuffer>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { IFileBuffer } from '@postybirb/types';
import { htmlToText } from 'html-to-text';
import { TurndownService } from 'turndown';
import { IFileConverter } from './file-converter';

const supportedInputMimeTypes = ['text/html'] as const;
const supportedOutputMimeTypes = [
'text/plain',
'text/html',
'text/markdown',
] as const;

type SupportedInputMimeTypes = (typeof supportedInputMimeTypes)[number];
type SupportedOutputMimeTypes = (typeof supportedOutputMimeTypes)[number];

type ConversionMap = {
[inputMimeType in SupportedInputMimeTypes]: {
[outputMimeType in SupportedOutputMimeTypes]: (
file: IFileBuffer,
) => Promise<IFileBuffer>;
};
};

type ConversionWeights = {
[outputMimeType in SupportedOutputMimeTypes]: number;
};

// TODO - use this within the post service to convert alt files to acceptable format
// TODO - use overall conversion check within the validator service to see if we can convert the file, this may be useful for the end user
/**
* A class that converts text files to other text formats.
* Largely for use when converting AltFiles (text/html) to other desirable formats.
* @class TextFileConverter
* @implements {IFileConverter}
*/
export class TextFileConverter implements IFileConverter {
private readonly supportConversionMappers: ConversionMap = {
'text/html': {
'text/html': this.passThrough,
'text/plain': this.convertHtmlToPlaintext,
'text/markdown': this.convertHtmlToMarkdown,
},
};

/**
* Defines the preference of conversion, trying to convert to the most preferred format first.
*/
private readonly conversionWeights: ConversionWeights = {
'text/plain': Number.MAX_SAFE_INTEGER,
'text/html': 1,
'text/markdown': 2,
};

canConvert(file: IFileBuffer, allowableOutputMimeTypes: string[]): boolean {
return (
supportedInputMimeTypes.includes(
file.mimeType as SupportedInputMimeTypes,
) &&
supportedOutputMimeTypes.some((m) => allowableOutputMimeTypes.includes(m))
);
}

async convert(
file: IFileBuffer,
allowableOutputMimeTypes: string[],
): Promise<IFileBuffer> {
const conversionMap =
this.supportConversionMappers[file.mimeType as SupportedInputMimeTypes];

const sortedOutputMimeTypes = allowableOutputMimeTypes
.filter((mimeType) => mimeType in conversionMap)
.sort(
(a, b) =>
this.conversionWeights[a as SupportedOutputMimeTypes] -
this.conversionWeights[b as SupportedOutputMimeTypes],
);

for (const outputMimeType of sortedOutputMimeTypes) {
const conversionFunction =
conversionMap[outputMimeType as SupportedOutputMimeTypes];
if (conversionFunction) {
return conversionFunction(file);
}
}

throw new Error(
`Cannot convert file ${file.fileName} with mime type: ${file.mimeType}`,
);
}

private async passThrough(file: IFileBuffer): Promise<IFileBuffer> {
return { ...file };
}

private async convertHtmlToPlaintext(
file: IFileBuffer,
): Promise<IFileBuffer> {
const text = htmlToText(file.buffer.toString(), {
wordwrap: 120,
});
return this.toMergedBuffer(file, text, 'text/plain');
}

private async convertHtmlToMarkdown(file: IFileBuffer): Promise<IFileBuffer> {
const turndownService = new TurndownService();
const markdown = turndownService.turndown(file.buffer.toString());
return this.toMergedBuffer(file, markdown, 'text/markdown');
}

private toMergedBuffer(
fb: IFileBuffer,
str: string,
mimeType: string,
): IFileBuffer {
return {
...fb,
buffer: Buffer.from(str),
mimeType,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { FileConverterService } from './file-converter.service';

@Module({
providers: [FileConverterService],
exports: [FileConverterService],
})
export class FileConverterModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileConverterService } from './file-converter.service';

describe('FileConverterService', () => {
let service: FileConverterService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [FileConverterService],
}).compile();

service = module.get<FileConverterService>(FileConverterService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { IFileBuffer } from '@postybirb/types';
import { IFileConverter } from './converters/file-converter';
import { TextFileConverter } from './converters/text-file-converter';

@Injectable()
export class FileConverterService {
private readonly converters: IFileConverter[] = [new TextFileConverter()];

public async convert(
file: IFileBuffer,
allowableOutputMimeTypes: string[],
): Promise<IFileBuffer> {
const converter = this.converters.find((c) =>
c.canConvert(file, allowableOutputMimeTypes),
);

if (!converter) {
throw new Error('No converter found for file');
}

return converter.convert(file, allowableOutputMimeTypes);
}

public async canConvert(
mimeType: string,
allowableOutputMimeTypes: string[],
): Promise<boolean> {
return this.converters.some((c) =>
c.canConvert({ mimeType } as IFileBuffer, allowableOutputMimeTypes),
);
}
}
12 changes: 10 additions & 2 deletions apps/client-server/src/app/file/file.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { readFileSync } from 'fs';
import { join } from 'path';
import { AccountService } from '../account/account.service';
import { DatabaseModule } from '../database/database.module';
import { FileConverterService } from '../file-converter/file-converter.service';
import { FormGeneratorService } from '../form-generator/form-generator.service';
import { DescriptionParserService } from '../post-parsers/parsers/description-parser.service';
import { TagParserService } from '../post-parsers/parsers/tag-parser.service';
Expand Down Expand Up @@ -92,6 +93,7 @@ describe('FileService', () => {
TagConvertersService,
SettingsService,
FormGeneratorService,
FileConverterService,
],
}).compile();

Expand All @@ -116,7 +118,10 @@ describe('FileService', () => {
const path = setup();
const submission = await createSubmission();
const fileInfo = createMulterData(path);
const file = await service.create(fileInfo, submission as FileSubmission);
const file = await service.create(
fileInfo,
submission as unknown as FileSubmission,
);
expect(file.file).toBeDefined();
expect(file.thumbnail).toBeDefined();
expect(file.fileName).toBe(fileInfo.originalname);
Expand All @@ -137,7 +142,10 @@ describe('FileService', () => {
const path = setup();
const submission = await createSubmission();
const fileInfo = createMulterData(path);
const file = await service.create(fileInfo, submission as FileSubmission);
const file = await service.create(
fileInfo,
submission as unknown as FileSubmission,
);
expect(file.file).toBeDefined();

const path2 = setup();
Expand Down
31 changes: 29 additions & 2 deletions apps/client-server/src/app/file/file.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { InjectRepository } from '@mikro-orm/nestjs';
import { BadRequestException, Injectable } from '@nestjs/common';
import { read } from '@postybirb/fs';
import { Logger } from '@postybirb/logger';
import { FileSubmission } from '@postybirb/types';
import { EntityId, FileSubmission } from '@postybirb/types';
import type { queueAsPromised } from 'fastq';
import fastq from 'fastq';
import { readFile } from 'fs/promises';
import { cpus } from 'os';
import { SubmissionFile } from '../database/entities';
import { AltFile, SubmissionFile } from '../database/entities';
import { PostyBirbRepository } from '../database/repositories/postybirb-repository';
import { UpdateAltFileDto } from '../submission/dtos/update-alt-file.dto';
import { MulterFileInfo, TaskOrigin } from './models/multer-file-info';
import { CreateTask, Task, UpdateTask } from './models/task';
import { TaskType } from './models/task-type.enum';
Expand All @@ -34,6 +35,8 @@ export class FileService {
private readonly updateFileService: UpdateFileService,
@InjectRepository(SubmissionFile)
private readonly fileRepository: PostyBirbRepository<SubmissionFile>,
@InjectRepository(AltFile)
private readonly altFileRepository: PostyBirbRepository<AltFile>,
) {}

/**
Expand Down Expand Up @@ -137,4 +140,28 @@ export class FileService {
public async findFile(id: string): Promise<SubmissionFile> {
return this.fileRepository.findById(id, { failOnMissing: true });
}

/**
* Gets the raw text of an alt text file.
* @param {string} id
*/
async getAltText(id: EntityId): Promise<string> {
const altFile = await this.altFileRepository.findOneOrFail({ id });
if (altFile.size) {
return altFile.buffer.toString();
}

return '';
}

/**
* Updates the raw text of an alt text file.
* @param {string} id
* @param {UpdateAltFileDto} update
*/
async updateAltText(id: string, update: UpdateAltFileDto) {
const altFile = await this.altFileRepository.findOneOrFail({ id });
altFile.buffer = Buffer.from(update.html ?? '');
await this.altFileRepository.persistAndFlush(altFile);
}
}
Loading

0 comments on commit 1fd51a6

Please sign in to comment.