bunsane 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-docs.yml +57 -0
- package/LICENSE.md +1 -1
- package/README.md +2 -28
- package/TODO.md +8 -1
- package/bun.lock +3 -0
- package/config/upload.config.ts +135 -0
- package/core/App.ts +168 -4
- package/core/ArcheType.ts +122 -0
- package/core/BatchLoader.ts +100 -0
- package/core/ComponentRegistry.ts +4 -3
- package/core/Components.ts +2 -2
- package/core/Decorators.ts +15 -8
- package/core/Entity.ts +193 -14
- package/core/EntityCache.ts +15 -0
- package/core/EntityHookManager.ts +855 -0
- package/core/EntityManager.ts +12 -2
- package/core/ErrorHandler.ts +64 -7
- package/core/FileValidator.ts +284 -0
- package/core/Query.ts +503 -85
- package/core/RequestContext.ts +24 -0
- package/core/RequestLoaders.ts +89 -0
- package/core/SchedulerManager.ts +710 -0
- package/core/UploadManager.ts +261 -0
- package/core/components/UploadComponent.ts +206 -0
- package/core/decorators/EntityHooks.ts +190 -0
- package/core/decorators/ScheduledTask.ts +83 -0
- package/core/events/EntityLifecycleEvents.ts +177 -0
- package/core/processors/ImageProcessor.ts +423 -0
- package/core/storage/LocalStorageProvider.ts +290 -0
- package/core/storage/StorageProvider.ts +112 -0
- package/database/DatabaseHelper.ts +183 -58
- package/database/index.ts +5 -5
- package/database/sqlHelpers.ts +7 -0
- package/docs/README.md +149 -0
- package/docs/_coverpage.md +36 -0
- package/docs/_sidebar.md +23 -0
- package/docs/api/core.md +568 -0
- package/docs/api/hooks.md +554 -0
- package/docs/api/index.md +222 -0
- package/docs/api/query.md +678 -0
- package/docs/api/service.md +744 -0
- package/docs/core-concepts/archetypes.md +512 -0
- package/docs/core-concepts/components.md +498 -0
- package/docs/core-concepts/entity.md +314 -0
- package/docs/core-concepts/hooks.md +683 -0
- package/docs/core-concepts/query.md +588 -0
- package/docs/core-concepts/services.md +647 -0
- package/docs/examples/code-examples.md +425 -0
- package/docs/getting-started.md +337 -0
- package/docs/index.html +97 -0
- package/gql/Generator.ts +58 -35
- package/gql/decorators/Upload.ts +176 -0
- package/gql/helpers.ts +67 -0
- package/gql/index.ts +65 -31
- package/gql/types.ts +1 -1
- package/index.ts +79 -11
- package/package.json +19 -10
- package/rest/Generator.ts +3 -0
- package/rest/index.ts +22 -0
- package/service/Service.ts +1 -1
- package/service/ServiceRegistry.ts +10 -6
- package/service/index.ts +12 -1
- package/tests/bench/insert.bench.ts +59 -0
- package/tests/bench/relations.bench.ts +269 -0
- package/tests/bench/sorting.bench.ts +415 -0
- package/tests/component-hooks.test.ts +1409 -0
- package/tests/component.test.ts +338 -0
- package/tests/errorHandling.test.ts +155 -0
- package/tests/hooks.test.ts +666 -0
- package/tests/query-sorting.test.ts +101 -0
- package/tests/relations.test.ts +169 -0
- package/tests/scheduler.test.ts +724 -0
- package/tsconfig.json +35 -34
- package/types/graphql.types.ts +87 -0
- package/types/hooks.types.ts +141 -0
- package/types/scheduler.types.ts +165 -0
- package/types/upload.types.ts +184 -0
- package/upload/index.ts +140 -0
- package/utils/UploadHelper.ts +305 -0
- package/utils/cronParser.ts +366 -0
- package/utils/errorMessages.ts +151 -0
- package/core/Events.ts +0 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ScheduleInterval } from "../../types/scheduler.types";
|
|
2
|
+
import type { ScheduledTaskOptions } from "../../types/scheduler.types";
|
|
3
|
+
import { SchedulerManager } from "../SchedulerManager";
|
|
4
|
+
import { logger } from "../Logger";
|
|
5
|
+
|
|
6
|
+
const loggerInstance = logger.child({ scope: "ScheduledTaskDecorator" });
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decorator for registering scheduled tasks
|
|
10
|
+
* @param options Task configuration options including interval and component target
|
|
11
|
+
*/
|
|
12
|
+
export function ScheduledTask(
|
|
13
|
+
options: ScheduledTaskOptions & {
|
|
14
|
+
interval: ScheduleInterval;
|
|
15
|
+
componentTarget?: new (...args: any[]) => any
|
|
16
|
+
}
|
|
17
|
+
) {
|
|
18
|
+
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
19
|
+
const originalMethod = descriptor.value;
|
|
20
|
+
|
|
21
|
+
// Generate task ID if not provided
|
|
22
|
+
const taskId = options.id || `${target.constructor.name}.${propertyKey}`;
|
|
23
|
+
|
|
24
|
+
// Store task info for later registration
|
|
25
|
+
if (!target.constructor.__scheduledTasks) {
|
|
26
|
+
target.constructor.__scheduledTasks = [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const taskInfo = {
|
|
30
|
+
id: taskId,
|
|
31
|
+
name: options.name || `${target.constructor.name}.${propertyKey}`,
|
|
32
|
+
componentTarget: options.componentTarget, // Legacy support
|
|
33
|
+
interval: options.interval,
|
|
34
|
+
options: {
|
|
35
|
+
runOnStart: options.runOnStart ?? false,
|
|
36
|
+
timeout: options.timeout ?? 30000,
|
|
37
|
+
enableLogging: options.enableLogging ?? true,
|
|
38
|
+
...options
|
|
39
|
+
},
|
|
40
|
+
service: null, // Will be set when service is instantiated
|
|
41
|
+
methodName: propertyKey,
|
|
42
|
+
nextExecution: new Date(),
|
|
43
|
+
executionCount: 0,
|
|
44
|
+
isRunning: false,
|
|
45
|
+
enabled: true
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
target.constructor.__scheduledTasks.push(taskInfo);
|
|
49
|
+
|
|
50
|
+
// Return the original descriptor to maintain method functionality
|
|
51
|
+
return descriptor;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Function to manually register decorated tasks for a service instance
|
|
57
|
+
* This is useful when services are instantiated outside the normal decorator flow
|
|
58
|
+
*/
|
|
59
|
+
export function registerScheduledTasks(service: any): void {
|
|
60
|
+
const constructor = service.constructor;
|
|
61
|
+
|
|
62
|
+
if (!constructor.__scheduledTasks) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const scheduler = SchedulerManager.getInstance();
|
|
67
|
+
|
|
68
|
+
for (const task of constructor.__scheduledTasks) {
|
|
69
|
+
const taskWithService = {
|
|
70
|
+
...task,
|
|
71
|
+
service: service
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
scheduler.registerTask(taskWithService);
|
|
76
|
+
if (loggerInstance.isLevelEnabled('info')) {
|
|
77
|
+
loggerInstance.info(`Manually registered scheduled task: ${task.name} (${task.id})`);
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
loggerInstance.error(`Failed to manually register scheduled task ${task.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { Entity } from "../Entity";
|
|
2
|
+
import type { BaseComponent } from "../Components";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Base class for all entity lifecycle events
|
|
6
|
+
* Provides common properties and methods for lifecycle events
|
|
7
|
+
*/
|
|
8
|
+
export abstract class EntityLifecycleEvent {
|
|
9
|
+
public readonly timestamp: Date;
|
|
10
|
+
public readonly entity: Entity;
|
|
11
|
+
public readonly eventType: string;
|
|
12
|
+
|
|
13
|
+
constructor(entity: Entity, eventType: string) {
|
|
14
|
+
this.timestamp = new Date();
|
|
15
|
+
this.entity = entity;
|
|
16
|
+
this.eventType = eventType;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get the event type identifier
|
|
21
|
+
*/
|
|
22
|
+
public getEventType(): string {
|
|
23
|
+
return this.eventType;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get the entity associated with this event
|
|
28
|
+
*/
|
|
29
|
+
public getEntity(): Entity {
|
|
30
|
+
return this.entity;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the timestamp when the event occurred
|
|
35
|
+
*/
|
|
36
|
+
public getTimestamp(): Date {
|
|
37
|
+
return this.timestamp;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Event fired when an entity is created (first time saved)
|
|
43
|
+
*/
|
|
44
|
+
export class EntityCreatedEvent extends EntityLifecycleEvent {
|
|
45
|
+
public readonly isNew: boolean = true;
|
|
46
|
+
|
|
47
|
+
constructor(entity: Entity) {
|
|
48
|
+
super(entity, "entity.created");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Event fired when an entity is updated (subsequent saves)
|
|
54
|
+
*/
|
|
55
|
+
export class EntityUpdatedEvent extends EntityLifecycleEvent {
|
|
56
|
+
public readonly isNew: boolean = false;
|
|
57
|
+
public readonly changedComponents: string[] = [];
|
|
58
|
+
|
|
59
|
+
constructor(entity: Entity, changedComponents: string[] = []) {
|
|
60
|
+
super(entity, "entity.updated");
|
|
61
|
+
this.changedComponents = changedComponents;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get the list of component types that were changed
|
|
66
|
+
*/
|
|
67
|
+
public getChangedComponents(): string[] {
|
|
68
|
+
return this.changedComponents;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Event fired when an entity is deleted
|
|
74
|
+
*/
|
|
75
|
+
export class EntityDeletedEvent extends EntityLifecycleEvent {
|
|
76
|
+
public readonly isSoftDelete: boolean = true;
|
|
77
|
+
|
|
78
|
+
constructor(entity: Entity, isSoftDelete: boolean = true) {
|
|
79
|
+
super(entity, "entity.deleted");
|
|
80
|
+
this.isSoftDelete = isSoftDelete;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Base class for component lifecycle events
|
|
86
|
+
*/
|
|
87
|
+
export abstract class ComponentLifecycleEvent extends EntityLifecycleEvent {
|
|
88
|
+
public readonly component: BaseComponent;
|
|
89
|
+
public readonly componentType: string;
|
|
90
|
+
|
|
91
|
+
constructor(entity: Entity, component: BaseComponent, eventType: string) {
|
|
92
|
+
super(entity, eventType);
|
|
93
|
+
this.component = component;
|
|
94
|
+
this.componentType = component.getTypeID();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get the component associated with this event
|
|
99
|
+
*/
|
|
100
|
+
public getComponent(): BaseComponent {
|
|
101
|
+
return this.component;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get the component type identifier
|
|
106
|
+
*/
|
|
107
|
+
public getComponentType(): string {
|
|
108
|
+
return this.componentType;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Event fired when a component is added to an entity
|
|
114
|
+
*/
|
|
115
|
+
export class ComponentAddedEvent extends ComponentLifecycleEvent {
|
|
116
|
+
constructor(entity: Entity, component: BaseComponent) {
|
|
117
|
+
super(entity, component, "component.added");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Event fired when a component is updated on an entity
|
|
123
|
+
*/
|
|
124
|
+
export class ComponentUpdatedEvent extends ComponentLifecycleEvent {
|
|
125
|
+
public readonly oldData?: any;
|
|
126
|
+
public readonly newData?: any;
|
|
127
|
+
|
|
128
|
+
constructor(entity: Entity, component: BaseComponent, oldData?: any, newData?: any) {
|
|
129
|
+
super(entity, component, "component.updated");
|
|
130
|
+
this.oldData = oldData;
|
|
131
|
+
this.newData = newData;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get the old component data before the update
|
|
136
|
+
*/
|
|
137
|
+
public getOldData(): any {
|
|
138
|
+
return this.oldData;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get the new component data after the update
|
|
143
|
+
*/
|
|
144
|
+
public getNewData(): any {
|
|
145
|
+
return this.newData;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Event fired when a component is removed from an entity
|
|
151
|
+
*/
|
|
152
|
+
export class ComponentRemovedEvent extends ComponentLifecycleEvent {
|
|
153
|
+
constructor(entity: Entity, component: BaseComponent) {
|
|
154
|
+
super(entity, component, "component.removed");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Union type for all entity lifecycle events
|
|
160
|
+
*/
|
|
161
|
+
export type EntityEvent =
|
|
162
|
+
| EntityCreatedEvent
|
|
163
|
+
| EntityUpdatedEvent
|
|
164
|
+
| EntityDeletedEvent;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Union type for all component lifecycle events
|
|
168
|
+
*/
|
|
169
|
+
export type ComponentEvent =
|
|
170
|
+
| ComponentAddedEvent
|
|
171
|
+
| ComponentUpdatedEvent
|
|
172
|
+
| ComponentRemovedEvent;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Union type for all lifecycle events
|
|
176
|
+
*/
|
|
177
|
+
export type LifecycleEvent = EntityEvent | ComponentEvent;
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import type { ImageProcessingOptions } from "../../types/upload.types";
|
|
2
|
+
import { logger as MainLogger } from "../Logger";
|
|
3
|
+
|
|
4
|
+
const logger = MainLogger.child({ scope: "ImageProcessor" });
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ImageProcessor - Handle image manipulation and processing
|
|
8
|
+
* Note: This is a basic implementation. For production use, consider integrating
|
|
9
|
+
* with Sharp, Jimp, or similar image processing libraries.
|
|
10
|
+
*/
|
|
11
|
+
export class ImageProcessor {
|
|
12
|
+
/**
|
|
13
|
+
* Process image according to configuration
|
|
14
|
+
*/
|
|
15
|
+
public static async processImage(
|
|
16
|
+
file: File,
|
|
17
|
+
options: ImageProcessingOptions
|
|
18
|
+
): Promise<{
|
|
19
|
+
processedFile: File;
|
|
20
|
+
metadata: {
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
format: string;
|
|
24
|
+
size: number;
|
|
25
|
+
};
|
|
26
|
+
thumbnails?: Array<{
|
|
27
|
+
size: string;
|
|
28
|
+
file: File;
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
}>;
|
|
32
|
+
}> {
|
|
33
|
+
logger.info(`Processing image: ${file.name}`);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// Get image metadata
|
|
37
|
+
const metadata = await this.getImageMetadata(file);
|
|
38
|
+
|
|
39
|
+
let processedFile = file;
|
|
40
|
+
|
|
41
|
+
// Resize if needed
|
|
42
|
+
if (options.maxDimensions) {
|
|
43
|
+
processedFile = await this.resizeImage(
|
|
44
|
+
processedFile,
|
|
45
|
+
options.maxDimensions.width,
|
|
46
|
+
options.maxDimensions.height
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Compress if needed
|
|
51
|
+
if (options.compress && options.quality) {
|
|
52
|
+
processedFile = await this.compressImage(processedFile, options.quality);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Convert format if needed
|
|
56
|
+
if (options.convertTo) {
|
|
57
|
+
processedFile = await this.convertFormat(processedFile, options.convertTo);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Generate thumbnails
|
|
61
|
+
let thumbnails: Array<{
|
|
62
|
+
size: string;
|
|
63
|
+
file: File;
|
|
64
|
+
width: number;
|
|
65
|
+
height: number;
|
|
66
|
+
}> | undefined;
|
|
67
|
+
|
|
68
|
+
if (options.generateThumbnails && options.thumbnailSizes) {
|
|
69
|
+
thumbnails = await this.generateThumbnails(processedFile, options.thumbnailSizes);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const finalMetadata = await this.getImageMetadata(processedFile);
|
|
73
|
+
|
|
74
|
+
logger.info(`Image processing completed for: ${file.name}`);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
processedFile,
|
|
78
|
+
metadata: finalMetadata,
|
|
79
|
+
thumbnails
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
} catch (error) {
|
|
83
|
+
logger.error(`Image processing failed for ${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get image metadata (basic implementation)
|
|
90
|
+
* In production, use a proper image processing library
|
|
91
|
+
*/
|
|
92
|
+
public static async getImageMetadata(file: File): Promise<{
|
|
93
|
+
width: number;
|
|
94
|
+
height: number;
|
|
95
|
+
format: string;
|
|
96
|
+
size: number;
|
|
97
|
+
}> {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const img = new Image();
|
|
100
|
+
const url = URL.createObjectURL(file);
|
|
101
|
+
|
|
102
|
+
img.onload = () => {
|
|
103
|
+
URL.revokeObjectURL(url);
|
|
104
|
+
resolve({
|
|
105
|
+
width: img.width,
|
|
106
|
+
height: img.height,
|
|
107
|
+
format: file.type.split('/')[1] || 'unknown',
|
|
108
|
+
size: file.size
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
img.onerror = () => {
|
|
113
|
+
URL.revokeObjectURL(url);
|
|
114
|
+
reject(new Error('Failed to load image for metadata extraction'));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
img.src = url;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resize image (basic canvas implementation)
|
|
123
|
+
* For production, use Sharp or similar library
|
|
124
|
+
*/
|
|
125
|
+
public static async resizeImage(
|
|
126
|
+
file: File,
|
|
127
|
+
maxWidth: number,
|
|
128
|
+
maxHeight: number
|
|
129
|
+
): Promise<File> {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const img = new Image();
|
|
132
|
+
const canvas = document.createElement('canvas');
|
|
133
|
+
const ctx = canvas.getContext('2d');
|
|
134
|
+
|
|
135
|
+
if (!ctx) {
|
|
136
|
+
reject(new Error('Canvas context not available'));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
img.onload = () => {
|
|
141
|
+
// Calculate new dimensions
|
|
142
|
+
const { width: newWidth, height: newHeight } = this.calculateDimensions(
|
|
143
|
+
img.width,
|
|
144
|
+
img.height,
|
|
145
|
+
maxWidth,
|
|
146
|
+
maxHeight
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
canvas.width = newWidth;
|
|
150
|
+
canvas.height = newHeight;
|
|
151
|
+
|
|
152
|
+
// Draw resized image
|
|
153
|
+
ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
|
154
|
+
|
|
155
|
+
// Convert to file
|
|
156
|
+
canvas.toBlob((blob) => {
|
|
157
|
+
if (blob) {
|
|
158
|
+
const resizedFile = new File([blob], file.name, {
|
|
159
|
+
type: file.type,
|
|
160
|
+
lastModified: Date.now()
|
|
161
|
+
});
|
|
162
|
+
resolve(resizedFile);
|
|
163
|
+
} else {
|
|
164
|
+
reject(new Error('Failed to create resized image blob'));
|
|
165
|
+
}
|
|
166
|
+
}, file.type);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
img.onerror = () => {
|
|
170
|
+
reject(new Error('Failed to load image for resizing'));
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
img.src = URL.createObjectURL(file);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Compress image (basic implementation)
|
|
179
|
+
*/
|
|
180
|
+
public static async compressImage(file: File, quality: number): Promise<File> {
|
|
181
|
+
if (!file.type.startsWith('image/')) {
|
|
182
|
+
return file;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
const img = new Image();
|
|
187
|
+
const canvas = document.createElement('canvas');
|
|
188
|
+
const ctx = canvas.getContext('2d');
|
|
189
|
+
|
|
190
|
+
if (!ctx) {
|
|
191
|
+
reject(new Error('Canvas context not available'));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
img.onload = () => {
|
|
196
|
+
canvas.width = img.width;
|
|
197
|
+
canvas.height = img.height;
|
|
198
|
+
|
|
199
|
+
ctx.drawImage(img, 0, 0);
|
|
200
|
+
|
|
201
|
+
// Convert with quality
|
|
202
|
+
canvas.toBlob((blob) => {
|
|
203
|
+
if (blob) {
|
|
204
|
+
const compressedFile = new File([blob], file.name, {
|
|
205
|
+
type: file.type,
|
|
206
|
+
lastModified: Date.now()
|
|
207
|
+
});
|
|
208
|
+
resolve(compressedFile);
|
|
209
|
+
} else {
|
|
210
|
+
reject(new Error('Failed to create compressed image blob'));
|
|
211
|
+
}
|
|
212
|
+
}, file.type, quality / 100);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
img.onerror = () => {
|
|
216
|
+
reject(new Error('Failed to load image for compression'));
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
img.src = URL.createObjectURL(file);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Convert image format
|
|
225
|
+
*/
|
|
226
|
+
public static async convertFormat(
|
|
227
|
+
file: File,
|
|
228
|
+
targetFormat: "jpeg" | "png" | "webp"
|
|
229
|
+
): Promise<File> {
|
|
230
|
+
const mimeType = `image/${targetFormat}`;
|
|
231
|
+
const extension = targetFormat === 'jpeg' ? 'jpg' : targetFormat;
|
|
232
|
+
const newName = file.name.replace(/\.[^/.]+$/, `.${extension}`);
|
|
233
|
+
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
const img = new Image();
|
|
236
|
+
const canvas = document.createElement('canvas');
|
|
237
|
+
const ctx = canvas.getContext('2d');
|
|
238
|
+
|
|
239
|
+
if (!ctx) {
|
|
240
|
+
reject(new Error('Canvas context not available'));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
img.onload = () => {
|
|
245
|
+
canvas.width = img.width;
|
|
246
|
+
canvas.height = img.height;
|
|
247
|
+
|
|
248
|
+
// Set white background for JPEG conversion
|
|
249
|
+
if (targetFormat === 'jpeg') {
|
|
250
|
+
ctx.fillStyle = '#FFFFFF';
|
|
251
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
ctx.drawImage(img, 0, 0);
|
|
255
|
+
|
|
256
|
+
canvas.toBlob((blob) => {
|
|
257
|
+
if (blob) {
|
|
258
|
+
const convertedFile = new File([blob], newName, {
|
|
259
|
+
type: mimeType,
|
|
260
|
+
lastModified: Date.now()
|
|
261
|
+
});
|
|
262
|
+
resolve(convertedFile);
|
|
263
|
+
} else {
|
|
264
|
+
reject(new Error('Failed to create converted image blob'));
|
|
265
|
+
}
|
|
266
|
+
}, mimeType);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
img.onerror = () => {
|
|
270
|
+
reject(new Error('Failed to load image for format conversion'));
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
img.src = URL.createObjectURL(file);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Generate thumbnails
|
|
279
|
+
*/
|
|
280
|
+
public static async generateThumbnails(
|
|
281
|
+
file: File,
|
|
282
|
+
sizes: Array<{ width: number; height: number; suffix: string }>
|
|
283
|
+
): Promise<Array<{
|
|
284
|
+
size: string;
|
|
285
|
+
file: File;
|
|
286
|
+
width: number;
|
|
287
|
+
height: number;
|
|
288
|
+
}>> {
|
|
289
|
+
const thumbnails: Array<{
|
|
290
|
+
size: string;
|
|
291
|
+
file: File;
|
|
292
|
+
width: number;
|
|
293
|
+
height: number;
|
|
294
|
+
}> = [];
|
|
295
|
+
|
|
296
|
+
for (const size of sizes) {
|
|
297
|
+
try {
|
|
298
|
+
const thumbnail = await this.createThumbnail(file, size.width, size.height, size.suffix);
|
|
299
|
+
thumbnails.push({
|
|
300
|
+
size: `${size.width}x${size.height}`,
|
|
301
|
+
file: thumbnail,
|
|
302
|
+
width: size.width,
|
|
303
|
+
height: size.height
|
|
304
|
+
});
|
|
305
|
+
} catch (error) {
|
|
306
|
+
logger.warn(`Failed to generate ${size.width}x${size.height} thumbnail: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return thumbnails;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Create a single thumbnail
|
|
315
|
+
*/
|
|
316
|
+
private static async createThumbnail(
|
|
317
|
+
file: File,
|
|
318
|
+
width: number,
|
|
319
|
+
height: number,
|
|
320
|
+
suffix: string
|
|
321
|
+
): Promise<File> {
|
|
322
|
+
return new Promise((resolve, reject) => {
|
|
323
|
+
const img = new Image();
|
|
324
|
+
const canvas = document.createElement('canvas');
|
|
325
|
+
const ctx = canvas.getContext('2d');
|
|
326
|
+
|
|
327
|
+
if (!ctx) {
|
|
328
|
+
reject(new Error('Canvas context not available'));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
img.onload = () => {
|
|
333
|
+
// Calculate dimensions maintaining aspect ratio
|
|
334
|
+
const { width: newWidth, height: newHeight } = this.calculateDimensions(
|
|
335
|
+
img.width,
|
|
336
|
+
img.height,
|
|
337
|
+
width,
|
|
338
|
+
height
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
canvas.width = newWidth;
|
|
342
|
+
canvas.height = newHeight;
|
|
343
|
+
|
|
344
|
+
ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
|
345
|
+
|
|
346
|
+
const fileName = file.name.replace(/(\.[^.]+)$/, `${suffix}$1`);
|
|
347
|
+
|
|
348
|
+
canvas.toBlob((blob) => {
|
|
349
|
+
if (blob) {
|
|
350
|
+
const thumbnailFile = new File([blob], fileName, {
|
|
351
|
+
type: file.type,
|
|
352
|
+
lastModified: Date.now()
|
|
353
|
+
});
|
|
354
|
+
resolve(thumbnailFile);
|
|
355
|
+
} else {
|
|
356
|
+
reject(new Error('Failed to create thumbnail blob'));
|
|
357
|
+
}
|
|
358
|
+
}, file.type);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
img.onerror = () => {
|
|
362
|
+
reject(new Error('Failed to load image for thumbnail creation'));
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
img.src = URL.createObjectURL(file);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Calculate new dimensions maintaining aspect ratio
|
|
371
|
+
*/
|
|
372
|
+
private static calculateDimensions(
|
|
373
|
+
originalWidth: number,
|
|
374
|
+
originalHeight: number,
|
|
375
|
+
maxWidth: number,
|
|
376
|
+
maxHeight: number
|
|
377
|
+
): { width: number; height: number } {
|
|
378
|
+
const aspectRatio = originalWidth / originalHeight;
|
|
379
|
+
|
|
380
|
+
let newWidth = originalWidth;
|
|
381
|
+
let newHeight = originalHeight;
|
|
382
|
+
|
|
383
|
+
if (originalWidth > maxWidth) {
|
|
384
|
+
newWidth = maxWidth;
|
|
385
|
+
newHeight = newWidth / aspectRatio;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (newHeight > maxHeight) {
|
|
389
|
+
newHeight = maxHeight;
|
|
390
|
+
newWidth = newHeight * aspectRatio;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
width: Math.round(newWidth),
|
|
395
|
+
height: Math.round(newHeight)
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Validate if file is a processable image
|
|
401
|
+
*/
|
|
402
|
+
public static isProcessableImage(file: File): boolean {
|
|
403
|
+
const processableTypes = [
|
|
404
|
+
'image/jpeg',
|
|
405
|
+
'image/png',
|
|
406
|
+
'image/gif',
|
|
407
|
+
'image/webp'
|
|
408
|
+
];
|
|
409
|
+
|
|
410
|
+
return processableTypes.includes(file.type);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Get optimal quality based on file size
|
|
415
|
+
*/
|
|
416
|
+
public static getOptimalQuality(fileSize: number): number {
|
|
417
|
+
// Reduce quality for larger files
|
|
418
|
+
if (fileSize > 5 * 1024 * 1024) return 70; // >5MB
|
|
419
|
+
if (fileSize > 2 * 1024 * 1024) return 80; // >2MB
|
|
420
|
+
if (fileSize > 1 * 1024 * 1024) return 85; // >1MB
|
|
421
|
+
return 90; // Default quality
|
|
422
|
+
}
|
|
423
|
+
}
|