mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
✨ feat(photos): add Photos app with mana-media EXIF integration
- Add Photos NestJS backend (port 3019) with albums, favorites, tags - Add Photos SvelteKit web app (port 5189) with gallery, upload, filters - Extend mana-media with EXIF extraction service using exifr - Add cross-app photo listing endpoint to mana-media - Add photo stats endpoint to mana-media - Add photos to setup-databases.sh Backend features: - Albums CRUD with cover image and items management - Favorites toggle with status check - Tags CRUD with photo-tag associations - Photo proxy to mana-media with local data enrichment Web features: - Photo grid with infinite scroll - Photo detail modal with EXIF display - Album grid and detail views - Upload dropzone with progress tracking - Filter bar (app, date range, location, sort) - i18n support (de/en) - Svelte 5 runes mode Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d3392f69a9
commit
90c2f8573e
80 changed files with 6891 additions and 503 deletions
|
|
@ -27,7 +27,8 @@
|
|||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"sharp": "^0.33.0",
|
||||
"uuid": "^11.0.0"
|
||||
"uuid": "^11.0.0",
|
||||
"exifr": "^7.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@manacore/shared-drizzle-config": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,17 @@ export const media = pgTable(
|
|||
height: integer('height'),
|
||||
format: text('format'),
|
||||
hasAlpha: boolean('has_alpha'),
|
||||
// EXIF metadata
|
||||
exifData: jsonb('exif_data'),
|
||||
dateTaken: timestamp('date_taken', { withTimezone: true }),
|
||||
cameraMake: text('camera_make'),
|
||||
cameraModel: text('camera_model'),
|
||||
focalLength: text('focal_length'),
|
||||
aperture: text('aperture'),
|
||||
iso: integer('iso'),
|
||||
exposureTime: text('exposure_time'),
|
||||
gpsLatitude: text('gps_latitude'),
|
||||
gpsLongitude: text('gps_longitude'),
|
||||
// Generated variants
|
||||
thumbnailKey: text('thumbnail_key'),
|
||||
mediumKey: text('medium_key'),
|
||||
|
|
@ -50,6 +61,8 @@ export const media = pgTable(
|
|||
index('media_content_hash_idx').on(table.contentHash),
|
||||
index('media_status_idx').on(table.status),
|
||||
index('media_created_at_idx').on(table.createdAt),
|
||||
index('media_date_taken_idx').on(table.dateTaken),
|
||||
index('media_camera_idx').on(table.cameraMake, table.cameraModel),
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ExifService } from './exif.service';
|
||||
|
||||
@Module({
|
||||
providers: [ExifService],
|
||||
exports: [ExifService],
|
||||
})
|
||||
export class ExifModule {}
|
||||
110
services/mana-media/apps/api/src/modules/exif/exif.service.ts
Normal file
110
services/mana-media/apps/api/src/modules/exif/exif.service.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import exifr from 'exifr';
|
||||
|
||||
export interface ExifData {
|
||||
// Camera info
|
||||
cameraMake?: string;
|
||||
cameraModel?: string;
|
||||
// Lens info
|
||||
focalLength?: string;
|
||||
aperture?: string;
|
||||
// Exposure
|
||||
iso?: number;
|
||||
exposureTime?: string;
|
||||
// Date/time
|
||||
dateTaken?: Date;
|
||||
// GPS
|
||||
gpsLatitude?: string;
|
||||
gpsLongitude?: string;
|
||||
// Full raw EXIF data
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExifService {
|
||||
private readonly logger = new Logger(ExifService.name);
|
||||
|
||||
/**
|
||||
* Extract EXIF data from an image buffer
|
||||
*/
|
||||
async extract(buffer: Buffer): Promise<ExifData | null> {
|
||||
try {
|
||||
const exif = await exifr.parse(buffer, {
|
||||
// Include GPS data
|
||||
gps: true,
|
||||
// Parse all EXIF data
|
||||
tiff: true,
|
||||
exif: true,
|
||||
});
|
||||
|
||||
if (!exif) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: ExifData = {
|
||||
raw: exif,
|
||||
};
|
||||
|
||||
// Camera info
|
||||
if (exif.Make) {
|
||||
result.cameraMake = String(exif.Make).trim();
|
||||
}
|
||||
if (exif.Model) {
|
||||
result.cameraModel = String(exif.Model).trim();
|
||||
}
|
||||
|
||||
// Lens/exposure settings
|
||||
if (exif.FocalLength) {
|
||||
result.focalLength = `${exif.FocalLength}mm`;
|
||||
}
|
||||
if (exif.FNumber) {
|
||||
result.aperture = String(exif.FNumber);
|
||||
}
|
||||
if (exif.ISO) {
|
||||
result.iso = Number(exif.ISO);
|
||||
}
|
||||
if (exif.ExposureTime) {
|
||||
// Format as fraction (e.g., "1/125")
|
||||
if (exif.ExposureTime < 1) {
|
||||
result.exposureTime = `1/${Math.round(1 / exif.ExposureTime)}`;
|
||||
} else {
|
||||
result.exposureTime = `${exif.ExposureTime}s`;
|
||||
}
|
||||
}
|
||||
|
||||
// Date taken
|
||||
if (exif.DateTimeOriginal) {
|
||||
result.dateTaken = new Date(exif.DateTimeOriginal);
|
||||
} else if (exif.CreateDate) {
|
||||
result.dateTaken = new Date(exif.CreateDate);
|
||||
}
|
||||
|
||||
// GPS coordinates
|
||||
if (exif.latitude !== undefined && exif.longitude !== undefined) {
|
||||
result.gpsLatitude = String(exif.latitude);
|
||||
result.gpsLongitude = String(exif.longitude);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Extracted EXIF: camera=${result.cameraMake} ${result.cameraModel}, date=${result.dateTaken}`
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to extract EXIF data: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the buffer likely contains EXIF data (quick check)
|
||||
*/
|
||||
hasExif(buffer: Buffer): boolean {
|
||||
// JPEG files with EXIF start with FFD8 and contain "Exif" marker
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
|
||||
const exifMarker = buffer.indexOf('Exif');
|
||||
return exifMarker !== -1 && exifMarker < 100;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ import { ProcessService } from './process.service';
|
|||
import { ProcessWorker } from './process.worker';
|
||||
import { PROCESS_QUEUE } from './process.constants';
|
||||
import { UploadModule } from '../upload/upload.module';
|
||||
import { ExifModule } from '../exif/exif.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -11,6 +13,8 @@ import { UploadModule } from '../upload/upload.module';
|
|||
name: PROCESS_QUEUE,
|
||||
}),
|
||||
forwardRef(() => UploadModule),
|
||||
ExifModule,
|
||||
StorageModule,
|
||||
],
|
||||
providers: [ProcessService, ProcessWorker],
|
||||
exports: [ProcessService],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import sharp from 'sharp';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { ExifService, type ExifData } from '../exif/exif.service';
|
||||
import { IMAGE_VARIANTS, SUPPORTED_IMAGE_TYPES } from './process.constants';
|
||||
|
||||
export interface ProcessResult {
|
||||
|
|
@ -13,11 +14,15 @@ export interface ProcessResult {
|
|||
format?: string;
|
||||
hasAlpha?: boolean;
|
||||
};
|
||||
exif?: ExifData;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProcessService {
|
||||
constructor(private storage: StorageService) {}
|
||||
constructor(
|
||||
private storage: StorageService,
|
||||
private exifService: ExifService
|
||||
) {}
|
||||
|
||||
async processImage(
|
||||
mediaId: string,
|
||||
|
|
@ -35,6 +40,9 @@ export class ProcessService {
|
|||
const image = sharp(originalBuffer);
|
||||
const metadata = await image.metadata();
|
||||
|
||||
// Extract EXIF data
|
||||
const exifData = await this.exifService.extract(originalBuffer);
|
||||
|
||||
const result: ProcessResult = {
|
||||
metadata: {
|
||||
width: metadata.width,
|
||||
|
|
@ -42,6 +50,7 @@ export class ProcessService {
|
|||
format: metadata.format,
|
||||
hasAlpha: metadata.hasAlpha,
|
||||
},
|
||||
exif: exifData || undefined,
|
||||
};
|
||||
|
||||
// Generate variants
|
||||
|
|
|
|||
|
|
@ -57,10 +57,21 @@ export class ProcessWorker extends WorkerHost {
|
|||
height: result.metadata?.height,
|
||||
format: result.metadata?.format,
|
||||
hasAlpha: result.metadata?.hasAlpha,
|
||||
// EXIF data
|
||||
exifData: result.exif?.raw,
|
||||
dateTaken: result.exif?.dateTaken,
|
||||
cameraMake: result.exif?.cameraMake,
|
||||
cameraModel: result.exif?.cameraModel,
|
||||
focalLength: result.exif?.focalLength,
|
||||
aperture: result.exif?.aperture,
|
||||
iso: result.exif?.iso,
|
||||
exposureTime: result.exif?.exposureTime,
|
||||
gpsLatitude: result.exif?.gpsLatitude,
|
||||
gpsLongitude: result.exif?.gpsLongitude,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}`
|
||||
`Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}, exif=${!!result.exif}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,38 @@ interface UploadResponse {
|
|||
medium?: string;
|
||||
large?: string;
|
||||
};
|
||||
metadata?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
};
|
||||
exif?: {
|
||||
cameraMake?: string;
|
||||
cameraModel?: string;
|
||||
dateTaken?: Date;
|
||||
focalLength?: string;
|
||||
aperture?: string;
|
||||
iso?: number;
|
||||
exposureTime?: string;
|
||||
gpsLatitude?: string;
|
||||
gpsLongitude?: string;
|
||||
};
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface ListAllResponse {
|
||||
items: UploadResponse[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
interface StatsResponse {
|
||||
totalCount: number;
|
||||
totalSize: number;
|
||||
byApp: Record<string, { count: number; size: number }>;
|
||||
byYear: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ImportFromMatrixDto {
|
||||
mxcUrl: string;
|
||||
app: string;
|
||||
|
|
@ -135,6 +164,58 @@ export class UploadController {
|
|||
return records.map((r) => this.toResponse(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* List media across all apps for a user with advanced filtering
|
||||
* Supports filtering by multiple apps, date range, MIME type, etc.
|
||||
*/
|
||||
@Get('list/all')
|
||||
async listAll(
|
||||
@Query('userId') userId: string,
|
||||
@Query('apps') apps?: string,
|
||||
@Query('mimeType') mimeType?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('hasLocation') hasLocation?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
@Query('sortBy') sortBy?: 'createdAt' | 'dateTaken' | 'size',
|
||||
@Query('sortOrder') sortOrder?: 'asc' | 'desc'
|
||||
): Promise<ListAllResponse> {
|
||||
if (!userId) {
|
||||
throw new BadRequestException('userId is required');
|
||||
}
|
||||
|
||||
const result = await this.uploadService.listAll({
|
||||
userId,
|
||||
apps: apps ? apps.split(',').map((a) => a.trim()) : undefined,
|
||||
mimeType,
|
||||
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
|
||||
dateTo: dateTo ? new Date(dateTo) : undefined,
|
||||
hasLocation: hasLocation === 'true',
|
||||
limit: limit ? parseInt(limit) : 50,
|
||||
offset: offset ? parseInt(offset) : 0,
|
||||
sortBy: sortBy || 'createdAt',
|
||||
sortOrder: sortOrder || 'desc',
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((r) => this.toResponse(r)),
|
||||
total: result.total,
|
||||
hasMore: result.hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media statistics for a user
|
||||
*/
|
||||
@Get('stats')
|
||||
async stats(@Query('userId') userId: string): Promise<StatsResponse> {
|
||||
if (!userId) {
|
||||
throw new BadRequestException('userId is required');
|
||||
}
|
||||
return this.uploadService.getStats(userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param('id') id: string): Promise<{ success: boolean }> {
|
||||
const deleted = await this.uploadService.delete(id);
|
||||
|
|
@ -160,6 +241,8 @@ export class UploadController {
|
|||
medium: record.keys.medium ? `${baseUrl}/media/${record.id}/file/medium` : undefined,
|
||||
large: record.keys.large ? `${baseUrl}/media/${record.id}/file/large` : undefined,
|
||||
},
|
||||
metadata: record.metadata,
|
||||
exif: record.exif,
|
||||
createdAt: record.createdAt,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { InjectQueue } from '@nestjs/bullmq';
|
|||
import { Queue } from 'bullmq';
|
||||
import * as mime from 'mime-types';
|
||||
import * as crypto from 'crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, and, or, gte, lte, like, isNotNull, sql, desc, asc, inArray } from 'drizzle-orm';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { MatrixService } from '../matrix/matrix.service';
|
||||
import { PROCESS_QUEUE } from '../process/process.constants';
|
||||
|
|
@ -38,10 +38,47 @@ export interface MediaRecord {
|
|||
format?: string;
|
||||
hasAlpha?: boolean;
|
||||
};
|
||||
exif?: {
|
||||
cameraMake?: string;
|
||||
cameraModel?: string;
|
||||
dateTaken?: Date;
|
||||
focalLength?: string;
|
||||
aperture?: string;
|
||||
iso?: number;
|
||||
exposureTime?: string;
|
||||
gpsLatitude?: string;
|
||||
gpsLongitude?: string;
|
||||
};
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ListAllOptions {
|
||||
userId: string;
|
||||
apps?: string[];
|
||||
mimeType?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
hasLocation?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'createdAt' | 'dateTaken' | 'size';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ListAllResult {
|
||||
items: MediaRecord[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface StatsResult {
|
||||
totalCount: number;
|
||||
totalSize: number;
|
||||
byApp: Record<string, { count: number; size: number }>;
|
||||
byYear: Record<string, number>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UploadService {
|
||||
constructor(
|
||||
|
|
@ -208,6 +245,16 @@ export class UploadService {
|
|||
| 'height'
|
||||
| 'format'
|
||||
| 'hasAlpha'
|
||||
| 'exifData'
|
||||
| 'dateTaken'
|
||||
| 'cameraMake'
|
||||
| 'cameraModel'
|
||||
| 'focalLength'
|
||||
| 'aperture'
|
||||
| 'iso'
|
||||
| 'exposureTime'
|
||||
| 'gpsLatitude'
|
||||
| 'gpsLongitude'
|
||||
>
|
||||
>
|
||||
): Promise<MediaRecord | null> {
|
||||
|
|
@ -277,6 +324,136 @@ export class UploadService {
|
|||
return results.map((r) => this.toMediaRecord(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* List media across all apps for a user with advanced filtering
|
||||
*/
|
||||
async listAll(options: ListAllOptions): Promise<ListAllResult> {
|
||||
const conditions = [eq(mediaReferences.userId, options.userId)];
|
||||
|
||||
// Filter by multiple apps
|
||||
if (options.apps && options.apps.length > 0) {
|
||||
conditions.push(inArray(mediaReferences.app, options.apps));
|
||||
}
|
||||
|
||||
// Filter by MIME type (supports wildcards like "image/*")
|
||||
if (options.mimeType) {
|
||||
if (options.mimeType.endsWith('/*')) {
|
||||
const prefix = options.mimeType.slice(0, -1);
|
||||
conditions.push(like(media.mimeType, `${prefix}%`));
|
||||
} else {
|
||||
conditions.push(eq(media.mimeType, options.mimeType));
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (options.dateFrom) {
|
||||
conditions.push(gte(media.createdAt, options.dateFrom));
|
||||
}
|
||||
if (options.dateTo) {
|
||||
conditions.push(lte(media.createdAt, options.dateTo));
|
||||
}
|
||||
|
||||
// Filter by location
|
||||
if (options.hasLocation) {
|
||||
conditions.push(isNotNull(media.gpsLatitude));
|
||||
conditions.push(isNotNull(media.gpsLongitude));
|
||||
}
|
||||
|
||||
// Only show ready media
|
||||
conditions.push(eq(media.status, 'ready'));
|
||||
|
||||
// Build order by
|
||||
const orderColumn =
|
||||
options.sortBy === 'dateTaken'
|
||||
? media.dateTaken
|
||||
: options.sortBy === 'size'
|
||||
? media.size
|
||||
: media.createdAt;
|
||||
const orderFn = options.sortOrder === 'asc' ? asc : desc;
|
||||
|
||||
// Get total count
|
||||
const countResult = await this.db
|
||||
.select({ count: sql<number>`count(distinct ${media.id})` })
|
||||
.from(media)
|
||||
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
|
||||
.where(and(...conditions));
|
||||
const total = Number(countResult[0]?.count || 0);
|
||||
|
||||
// Get paginated results
|
||||
const limit = options.limit || 50;
|
||||
const offset = options.offset || 0;
|
||||
|
||||
const results = await this.db
|
||||
.selectDistinct({ media: media })
|
||||
.from(media)
|
||||
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderFn(orderColumn))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items: results.map((r) => this.toMediaRecord(r.media)),
|
||||
total,
|
||||
hasMore: offset + results.length < total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media statistics for a user
|
||||
*/
|
||||
async getStats(userId: string): Promise<StatsResult> {
|
||||
// Total count and size
|
||||
const totalResult = await this.db
|
||||
.select({
|
||||
count: sql<number>`count(distinct ${media.id})`,
|
||||
size: sql<number>`sum(${media.size})`,
|
||||
})
|
||||
.from(media)
|
||||
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
|
||||
.where(eq(mediaReferences.userId, userId));
|
||||
|
||||
// By app
|
||||
const byAppResult = await this.db
|
||||
.select({
|
||||
app: mediaReferences.app,
|
||||
count: sql<number>`count(distinct ${media.id})`,
|
||||
size: sql<number>`sum(${media.size})`,
|
||||
})
|
||||
.from(media)
|
||||
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
|
||||
.where(eq(mediaReferences.userId, userId))
|
||||
.groupBy(mediaReferences.app);
|
||||
|
||||
// By year
|
||||
const byYearResult = await this.db
|
||||
.select({
|
||||
year: sql<string>`extract(year from ${media.createdAt})::text`,
|
||||
count: sql<number>`count(distinct ${media.id})`,
|
||||
})
|
||||
.from(media)
|
||||
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
|
||||
.where(eq(mediaReferences.userId, userId))
|
||||
.groupBy(sql`extract(year from ${media.createdAt})`);
|
||||
|
||||
const byApp: Record<string, { count: number; size: number }> = {};
|
||||
for (const row of byAppResult) {
|
||||
byApp[row.app] = { count: Number(row.count), size: Number(row.size) };
|
||||
}
|
||||
|
||||
const byYear: Record<string, number> = {};
|
||||
for (const row of byYearResult) {
|
||||
byYear[row.year] = Number(row.count);
|
||||
}
|
||||
|
||||
return {
|
||||
totalCount: Number(totalResult[0]?.count || 0),
|
||||
totalSize: Number(totalResult[0]?.size || 0),
|
||||
byApp,
|
||||
byYear,
|
||||
};
|
||||
}
|
||||
|
||||
private async findByHash(hash: string): Promise<Media | null> {
|
||||
const [result] = await this.db.select().from(media).where(eq(media.contentHash, hash)).limit(1);
|
||||
return result || null;
|
||||
|
|
@ -322,6 +499,20 @@ export class UploadService {
|
|||
hasAlpha: m.hasAlpha || undefined,
|
||||
}
|
||||
: undefined,
|
||||
exif:
|
||||
m.cameraMake || m.dateTaken || m.gpsLatitude
|
||||
? {
|
||||
cameraMake: m.cameraMake || undefined,
|
||||
cameraModel: m.cameraModel || undefined,
|
||||
dateTaken: m.dateTaken || undefined,
|
||||
focalLength: m.focalLength || undefined,
|
||||
aperture: m.aperture || undefined,
|
||||
iso: m.iso || undefined,
|
||||
exposureTime: m.exposureTime || undefined,
|
||||
gpsLatitude: m.gpsLatitude || undefined,
|
||||
gpsLongitude: m.gpsLongitude || undefined,
|
||||
}
|
||||
: undefined,
|
||||
createdAt: m.createdAt,
|
||||
updatedAt: m.updatedAt,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue