@omnifyjp/omnify 2.0.0 → 2.1.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/omnify",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Schema-driven code generation for Laravel, TypeScript, and SQL",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,10 +36,10 @@
36
36
  "zod": "^3.24.0"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@omnifyjp/omnify-darwin-arm64": "2.0.0",
40
- "@omnifyjp/omnify-darwin-x64": "2.0.0",
41
- "@omnifyjp/omnify-linux-x64": "2.0.0",
42
- "@omnifyjp/omnify-linux-arm64": "2.0.0",
43
- "@omnifyjp/omnify-win32-x64": "2.0.0"
39
+ "@omnifyjp/omnify-darwin-arm64": "2.1.0",
40
+ "@omnifyjp/omnify-darwin-x64": "2.1.0",
41
+ "@omnifyjp/omnify-linux-x64": "2.1.0",
42
+ "@omnifyjp/omnify-linux-arm64": "2.1.0",
43
+ "@omnifyjp/omnify-win32-x64": "2.1.0"
44
44
  }
45
45
  }
@@ -46,16 +46,26 @@ function generateBaseInterfaceFile(schemaName, schemas, options) {
46
46
  const parts = [generateBaseHeader()];
47
47
  // Zod import
48
48
  parts.push(`import { z } from 'zod';\n`);
49
- // DateTimeString / DateString imports
49
+ // DateTimeString / DateString / OmnifyFile imports
50
50
  const dateImports = needsDateTimeImports(iface);
51
51
  const commonImports = [];
52
+ const commonValueImports = [];
52
53
  if (dateImports.dateTime)
53
54
  commonImports.push('DateTimeString');
54
55
  if (dateImports.date)
55
56
  commonImports.push('DateString');
57
+ // Check if any property uses OmnifyFile
58
+ const usesFile = iface.properties.some(p => p.type.includes('OmnifyFile'));
59
+ if (usesFile) {
60
+ commonImports.push('OmnifyFile');
61
+ commonValueImports.push('OmnifyFileSchema');
62
+ }
56
63
  if (commonImports.length > 0) {
57
64
  parts.push(`import type { ${commonImports.join(', ')} } from '../common';\n`);
58
65
  }
66
+ if (commonValueImports.length > 0) {
67
+ parts.push(`import { ${commonValueImports.join(', ')} } from '../common';\n`);
68
+ }
59
69
  // Enum imports (schema enums + plugin enums are all in ../enum/)
60
70
  if (iface.enumDependencies && iface.enumDependencies.length > 0) {
61
71
  for (const enumName of iface.enumDependencies) {
@@ -125,8 +135,54 @@ function generateModelFile(schemaName) {
125
135
  };
126
136
  }
127
137
  /** Generate common.ts with shared types. */
128
- function generateCommonFile(options) {
138
+ function generateCommonFile(options, hasFiles) {
129
139
  const localeUnion = options.locales.map(l => `'${l}'`).join(' | ');
140
+ const types = ['LocaleMap', 'Locale', 'DateTimeString', 'DateString'];
141
+ let fileSection = '';
142
+ if (hasFiles) {
143
+ types.push('OmnifyFile', 'OmnifyFileSchema');
144
+ fileSection = `
145
+ import { z } from 'zod';
146
+
147
+ /**
148
+ * Omnify file attachment.
149
+ */
150
+ export interface OmnifyFile {
151
+ readonly id: string;
152
+ readonly collection: string;
153
+ readonly disk: string;
154
+ readonly path: string;
155
+ readonly original_name: string;
156
+ readonly mime_type: string;
157
+ readonly size: number;
158
+ readonly status: 'temporary' | 'permanent';
159
+ readonly url: string | null;
160
+ readonly expires_at: DateTimeString | null;
161
+ readonly sort_order: number;
162
+ readonly created_at: DateTimeString;
163
+ readonly updated_at: DateTimeString;
164
+ }
165
+
166
+ /**
167
+ * Zod schema for OmnifyFile.
168
+ */
169
+ export const OmnifyFileSchema = z.object({
170
+ id: z.string(),
171
+ collection: z.string(),
172
+ disk: z.string(),
173
+ path: z.string(),
174
+ original_name: z.string(),
175
+ mime_type: z.string(),
176
+ size: z.number(),
177
+ status: z.enum(['temporary', 'permanent']),
178
+ url: z.string().nullable(),
179
+ expires_at: z.string().nullable(),
180
+ sort_order: z.number(),
181
+ created_at: z.string(),
182
+ updated_at: z.string(),
183
+ });
184
+ `;
185
+ }
130
186
  const content = `${generateBaseHeader()}/**
131
187
  * Locale map for multi-language support.
132
188
  */
@@ -148,11 +204,11 @@ export type DateTimeString = string;
148
204
  * ISO 8601 date string (YYYY-MM-DD).
149
205
  */
150
206
  export type DateString = string;
151
- `;
207
+ ${fileSection}`;
152
208
  return {
153
209
  filePath: 'common.ts',
154
210
  content,
155
- types: ['LocaleMap', 'Locale', 'DateTimeString', 'DateString'],
211
+ types,
156
212
  overwrite: true,
157
213
  };
158
214
  }
@@ -166,11 +222,17 @@ function generateI18nFile(options) {
166
222
  };
167
223
  }
168
224
  /** Generate index.ts re-exports. */
169
- function generateIndexFile(schemas, schemaEnums, pluginEnums, typeAliases) {
225
+ function generateIndexFile(schemas, schemaEnums, pluginEnums, typeAliases, hasFiles = false) {
170
226
  const parts = [generateBaseHeader()];
171
227
  // Common types
172
228
  parts.push(`// Common Types\n`);
173
- parts.push(`export type { LocaleMap, Locale, DateTimeString, DateString } from './common';\n\n`);
229
+ if (hasFiles) {
230
+ parts.push(`export type { LocaleMap, Locale, DateTimeString, DateString, OmnifyFile } from './common';\n`);
231
+ parts.push(`export { OmnifyFileSchema } from './common';\n`);
232
+ }
233
+ else {
234
+ parts.push(`export type { LocaleMap, Locale, DateTimeString, DateString } from './common';\n`);
235
+ }
174
236
  // I18n
175
237
  parts.push(`// i18n (Internationalization)\n`);
176
238
  parts.push(`export {\n`);
@@ -320,11 +382,18 @@ export function generateTypeScript(input) {
320
382
  continue;
321
383
  files.push(generateModelFile(schema.name));
322
384
  }
385
+ // Detect if any schema has File properties
386
+ const hasFiles = Object.values(schemas).some(s => {
387
+ if (s.kind === 'enum')
388
+ return false;
389
+ const props = s.properties ?? {};
390
+ return Object.values(props).some(p => p.type === 'File');
391
+ });
323
392
  // Common types
324
- files.push(generateCommonFile(options));
393
+ files.push(generateCommonFile(options, hasFiles));
325
394
  // I18n
326
395
  files.push(generateI18nFile(options));
327
396
  // Index re-exports
328
- files.push(generateIndexFile(schemas, schemaEnums, pluginEnums, inlineTypeAliases));
397
+ files.push(generateIndexFile(schemas, schemaEnums, pluginEnums, inlineTypeAliases, hasFiles));
329
398
  return files;
330
399
  }
@@ -53,7 +53,7 @@ function getIdType(options) {
53
53
  /** Gets TypeScript type for a property. */
54
54
  export function getPropertyType(property, allSchemas) {
55
55
  if (property.type === 'File') {
56
- return 'File | null';
56
+ return property.multiple ? 'OmnifyFile[]' : 'OmnifyFile | null';
57
57
  }
58
58
  if (property.type === 'Association') {
59
59
  const targetName = property.target ?? 'unknown';
@@ -5,3 +5,5 @@ import { SchemaReader } from './schema-reader.js';
5
5
  import type { GeneratedFile, PhpConfig } from './types.js';
6
6
  /** Generate Factory classes for all project-owned visible object schemas. */
7
7
  export declare function generateFactories(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
8
+ /** Generate the FileFactory class (only when schemas use File type). */
9
+ export declare function generateFileFactory(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
@@ -42,6 +42,9 @@ function generateFactory(name, schema, reader, schemas, config) {
42
42
  }
43
43
  continue;
44
44
  }
45
+ // Skip File type (files are relational, not direct columns)
46
+ if (type === 'File')
47
+ continue;
45
48
  // Handle compound types
46
49
  if (expandedProperties[propName]) {
47
50
  const expansion = expandedProperties[propName];
@@ -96,3 +99,97 @@ ${attributesStr}
96
99
  `;
97
100
  return userFile(`${config.factories.path}/${modelName}Factory.php`, content);
98
101
  }
102
+ /** Generate the FileFactory class (only when schemas use File type). */
103
+ export function generateFileFactory(reader, config) {
104
+ if (!reader.hasFileProperties())
105
+ return [];
106
+ const modelNamespace = config.models.namespace;
107
+ const factoryNamespace = config.factories.namespace;
108
+ const content = `<?php
109
+
110
+ /**
111
+ * File Factory
112
+ *
113
+ * SAFE TO EDIT - This file is never overwritten by Omnify.
114
+ */
115
+
116
+ namespace ${factoryNamespace};
117
+
118
+ use ${modelNamespace}\\File;
119
+ use ${modelNamespace}\\FileStatusEnum;
120
+ use Illuminate\\Database\\Eloquent\\Factories\\Factory;
121
+ use Illuminate\\Support\\Str;
122
+
123
+ /**
124
+ * @extends Factory<File>
125
+ */
126
+ class FileFactory extends Factory
127
+ {
128
+ protected $model = File::class;
129
+
130
+ /**
131
+ * Define the model's default state.
132
+ *
133
+ * @return array<string, mixed>
134
+ */
135
+ public function definition(): array
136
+ {
137
+ $extension = fake()->randomElement(['jpg', 'png', 'pdf', 'docx']);
138
+ $originalName = fake()->word() . '.' . $extension;
139
+ $mimeTypes = [
140
+ 'jpg' => 'image/jpeg',
141
+ 'png' => 'image/png',
142
+ 'pdf' => 'application/pdf',
143
+ 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
144
+ ];
145
+
146
+ return [
147
+ 'collection' => 'default',
148
+ 'disk' => 'public',
149
+ 'path' => 'files/' . now()->format('Y/m') . '/' . Str::uuid() . '.' . $extension,
150
+ 'original_name' => $originalName,
151
+ 'mime_type' => $mimeTypes[$extension] ?? 'application/octet-stream',
152
+ 'size' => fake()->numberBetween(1024, 10485760),
153
+ 'status' => FileStatusEnum::Temporary,
154
+ 'expires_at' => now()->addHours(24),
155
+ 'sort_order' => 0,
156
+ ];
157
+ }
158
+
159
+ /**
160
+ * Indicate that the file is permanent.
161
+ */
162
+ public function permanent(): static
163
+ {
164
+ return $this->state(fn (array $attributes) => [
165
+ 'status' => FileStatusEnum::Permanent,
166
+ 'expires_at' => null,
167
+ ]);
168
+ }
169
+
170
+ /**
171
+ * Indicate that the file has expired.
172
+ */
173
+ public function expired(): static
174
+ {
175
+ return $this->state(fn (array $attributes) => [
176
+ 'status' => FileStatusEnum::Temporary,
177
+ 'expires_at' => now()->subHour(),
178
+ ]);
179
+ }
180
+
181
+ /**
182
+ * Set a specific collection.
183
+ */
184
+ public function inCollection(string $collection): static
185
+ {
186
+ return $this->state(fn (array $attributes) => [
187
+ 'collection' => $collection,
188
+ ]);
189
+ }
190
+ }
191
+ `;
192
+ return [
193
+ userFile(`${config.factories.path}/FileFactory.php`, content),
194
+ ];
195
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Generates the CleanupExpiredFiles artisan command.
3
+ * Only generated when fileConfig.tempFlow is enabled.
4
+ */
5
+ import { SchemaReader } from './schema-reader.js';
6
+ import type { GeneratedFile, PhpConfig } from './types.js';
7
+ /** Generate the cleanup command (only when tempFlow is enabled). */
8
+ export declare function generateFileCleanup(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Generates the CleanupExpiredFiles artisan command.
3
+ * Only generated when fileConfig.tempFlow is enabled.
4
+ */
5
+ import { baseFile } from './types.js';
6
+ /** Generate the cleanup command (only when tempFlow is enabled). */
7
+ export function generateFileCleanup(reader, config) {
8
+ const fileConfig = reader.getFileConfig();
9
+ if (!fileConfig?.tempFlow)
10
+ return [];
11
+ const modelNamespace = config.models.namespace;
12
+ const content = `<?php
13
+
14
+ namespace App\\Console\\Commands;
15
+
16
+ /**
17
+ * DO NOT EDIT - This file is auto-generated by Omnify.
18
+ * Any changes will be overwritten on next generation.
19
+ *
20
+ * @generated by omnify
21
+ */
22
+
23
+ use Illuminate\\Console\\Command;
24
+ use Illuminate\\Support\\Facades\\Storage;
25
+ use ${modelNamespace}\\File;
26
+
27
+ class CleanupExpiredFiles extends Command
28
+ {
29
+ /**
30
+ * The name and signature of the console command.
31
+ */
32
+ protected $signature = 'omnify:cleanup-expired-files
33
+ {--dry-run : Show what would be deleted without actually deleting}';
34
+
35
+ /**
36
+ * The console command description.
37
+ */
38
+ protected $description = 'Delete expired temporary files';
39
+
40
+ /**
41
+ * Execute the console command.
42
+ */
43
+ public function handle(): int
44
+ {
45
+ $query = File::expired();
46
+ $count = $query->count();
47
+
48
+ if ($count === 0) {
49
+ $this->info('No expired files found.');
50
+ return self::SUCCESS;
51
+ }
52
+
53
+ if ($this->option('dry-run')) {
54
+ $this->info("Would delete {$count} expired file(s).");
55
+ return self::SUCCESS;
56
+ }
57
+
58
+ $deleted = 0;
59
+ $query->chunkById(100, function ($files) use (&$deleted) {
60
+ foreach ($files as $file) {
61
+ // Delete from storage
62
+ if ($file->path && Storage::disk($file->disk)->exists($file->path)) {
63
+ Storage::disk($file->disk)->delete($file->path);
64
+ }
65
+ // Soft delete the record
66
+ $file->delete();
67
+ $deleted++;
68
+ }
69
+ });
70
+
71
+ $this->info("Deleted {$deleted} expired file(s).");
72
+ return self::SUCCESS;
73
+ }
74
+ }
75
+ `;
76
+ return [
77
+ baseFile('app/Console/Commands/CleanupExpiredFiles.php', content),
78
+ ];
79
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Generates File model infrastructure:
3
+ * - FileBaseModel.php (base, always overwritten)
4
+ * - File.php (user, created once)
5
+ * - FileStatusEnum.php (base, always overwritten)
6
+ * - FileLocales.php (base, always overwritten)
7
+ */
8
+ import { SchemaReader } from './schema-reader.js';
9
+ import type { GeneratedFile, PhpConfig } from './types.js';
10
+ /** Generate all File model infrastructure files. */
11
+ export declare function generateFileModels(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Generates File model infrastructure:
3
+ * - FileBaseModel.php (base, always overwritten)
4
+ * - File.php (user, created once)
5
+ * - FileStatusEnum.php (base, always overwritten)
6
+ * - FileLocales.php (base, always overwritten)
7
+ */
8
+ import { baseFile, userFile } from './types.js';
9
+ /** Generate all File model infrastructure files. */
10
+ export function generateFileModels(reader, config) {
11
+ if (!reader.hasFileProperties())
12
+ return [];
13
+ const files = [];
14
+ const locale = reader.getLocale();
15
+ const defaultDisk = reader.getFileConfig()?.defaultDisk ?? 'public';
16
+ files.push(generateFileBaseModel(config, defaultDisk));
17
+ files.push(generateFileUserModel(config));
18
+ files.push(generateFileStatusEnum(config));
19
+ if (locale) {
20
+ files.push(generateFileLocales(config, locale.locales));
21
+ }
22
+ return files;
23
+ }
24
+ function generateFileBaseModel(config, defaultDisk) {
25
+ const baseNamespace = config.models.baseNamespace;
26
+ const modelNamespace = config.models.namespace;
27
+ const content = `<?php
28
+
29
+ namespace ${baseNamespace};
30
+
31
+ /**
32
+ * DO NOT EDIT - This file is auto-generated by Omnify.
33
+ * Any changes will be overwritten on next generation.
34
+ *
35
+ * @generated by omnify
36
+ */
37
+
38
+ use Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;
39
+ use Illuminate\\Database\\Eloquent\\Relations\\MorphTo;
40
+ use Illuminate\\Database\\Eloquent\\SoftDeletes;
41
+ use Illuminate\\Database\\Eloquent\\Builder;
42
+ use ${baseNamespace}\\Locales\\FileLocales;
43
+ use ${baseNamespace}\\Traits\\HasLocalizedDisplayName;
44
+
45
+ /**
46
+ * FileBaseModel
47
+ *
48
+ * @property string $id
49
+ * @property string|null $organization_id
50
+ * @property string|null $fileable_type
51
+ * @property string|null $fileable_id
52
+ * @property string $collection
53
+ * @property string $disk
54
+ * @property string $path
55
+ * @property string $original_name
56
+ * @property string $mime_type
57
+ * @property int $size
58
+ * @property \\${modelNamespace}\\FileStatusEnum $status
59
+ * @property \\Illuminate\\Support\\Carbon|null $expires_at
60
+ * @property int $sort_order
61
+ */
62
+ class FileBaseModel extends BaseModel
63
+ {
64
+ use HasUuids;
65
+ use SoftDeletes;
66
+ use HasLocalizedDisplayName;
67
+
68
+ /**
69
+ * The table associated with the model.
70
+ */
71
+ protected $table = 'files';
72
+
73
+ /**
74
+ * The primary key for the model.
75
+ */
76
+ protected $primaryKey = 'id';
77
+
78
+ /**
79
+ * The type of the primary key.
80
+ */
81
+ protected $keyType = 'string';
82
+
83
+ /**
84
+ * Indicates if the IDs are auto-incrementing.
85
+ */
86
+ public $incrementing = false;
87
+
88
+ /**
89
+ * Indicates if the model should be timestamped.
90
+ */
91
+ public $timestamps = true;
92
+
93
+ /**
94
+ * Localized display names for this model.
95
+ */
96
+ protected static array $localizedDisplayNames = FileLocales::DISPLAY_NAMES;
97
+
98
+ /**
99
+ * Localized display names for properties.
100
+ */
101
+ protected static array $localizedPropertyDisplayNames = FileLocales::PROPERTY_DISPLAY_NAMES;
102
+
103
+ /**
104
+ * The attributes that are mass assignable.
105
+ */
106
+ protected $fillable = [
107
+ 'organization_id',
108
+ 'fileable_type',
109
+ 'fileable_id',
110
+ 'collection',
111
+ 'disk',
112
+ 'path',
113
+ 'original_name',
114
+ 'mime_type',
115
+ 'size',
116
+ 'status',
117
+ 'expires_at',
118
+ 'sort_order',
119
+ ];
120
+
121
+ /**
122
+ * Get the attributes that should be cast.
123
+ */
124
+ protected function casts(): array
125
+ {
126
+ return [
127
+ 'size' => 'integer',
128
+ 'sort_order' => 'integer',
129
+ 'status' => \\${modelNamespace}\\FileStatusEnum::class,
130
+ 'expires_at' => 'datetime',
131
+ ];
132
+ }
133
+
134
+ // -----------------------------------------------------------------------
135
+ // Relations
136
+ // -----------------------------------------------------------------------
137
+
138
+ /**
139
+ * Get the parent fileable model.
140
+ */
141
+ public function fileable(): MorphTo
142
+ {
143
+ return $this->morphTo();
144
+ }
145
+
146
+ // -----------------------------------------------------------------------
147
+ // Scopes
148
+ // -----------------------------------------------------------------------
149
+
150
+ /**
151
+ * Scope to a specific collection.
152
+ */
153
+ public function scopeInCollection(Builder $query, string $collection): Builder
154
+ {
155
+ return $query->where('collection', $collection);
156
+ }
157
+
158
+ /**
159
+ * Scope to permanent files.
160
+ */
161
+ public function scopePermanent(Builder $query): Builder
162
+ {
163
+ return $query->where('status', 'permanent');
164
+ }
165
+
166
+ /**
167
+ * Scope to temporary files.
168
+ */
169
+ public function scopeTemporary(Builder $query): Builder
170
+ {
171
+ return $query->where('status', 'temporary');
172
+ }
173
+
174
+ /**
175
+ * Scope to expired files (temporary + past expires_at).
176
+ */
177
+ public function scopeExpired(Builder $query): Builder
178
+ {
179
+ return $query->where('status', 'temporary')
180
+ ->whereNotNull('expires_at')
181
+ ->where('expires_at', '<', now());
182
+ }
183
+
184
+ // -----------------------------------------------------------------------
185
+ // Helpers
186
+ // -----------------------------------------------------------------------
187
+
188
+ /**
189
+ * Check if the file is permanent.
190
+ */
191
+ public function isPermanent(): bool
192
+ {
193
+ return $this->status === \\${modelNamespace}\\FileStatusEnum::Permanent;
194
+ }
195
+
196
+ /**
197
+ * Check if the file has expired.
198
+ */
199
+ public function isExpired(): bool
200
+ {
201
+ return $this->status === \\${modelNamespace}\\FileStatusEnum::Temporary
202
+ && $this->expires_at !== null
203
+ && $this->expires_at->isPast();
204
+ }
205
+
206
+ /**
207
+ * Make the file permanent.
208
+ */
209
+ public function makePermanent(): static
210
+ {
211
+ $this->update([
212
+ 'status' => \\${modelNamespace}\\FileStatusEnum::Permanent,
213
+ 'expires_at' => null,
214
+ ]);
215
+
216
+ return $this;
217
+ }
218
+ }
219
+ `;
220
+ return baseFile(`${config.models.basePath}/FileBaseModel.php`, content);
221
+ }
222
+ function generateFileUserModel(config) {
223
+ const modelNamespace = config.models.namespace;
224
+ const baseNamespace = config.models.baseNamespace;
225
+ const factoryNamespace = config.factories.namespace;
226
+ const content = `<?php
227
+
228
+ /**
229
+ * File Model
230
+ *
231
+ * SAFE TO EDIT - This file is never overwritten by Omnify.
232
+ */
233
+
234
+ namespace ${modelNamespace};
235
+
236
+ use ${baseNamespace}\\FileBaseModel;
237
+ use ${factoryNamespace}\\FileFactory;
238
+ use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
239
+
240
+ /**
241
+ * File — add project-specific model logic here.
242
+ */
243
+ class File extends FileBaseModel
244
+ {
245
+ use HasFactory;
246
+
247
+ /**
248
+ * Create a new factory instance for the model.
249
+ */
250
+ protected static function newFactory(): FileFactory
251
+ {
252
+ return FileFactory::new();
253
+ }
254
+
255
+ //
256
+ }
257
+ `;
258
+ return userFile(`${config.models.path}/File.php`, content);
259
+ }
260
+ function generateFileStatusEnum(config) {
261
+ const modelNamespace = config.models.namespace;
262
+ const content = `<?php
263
+
264
+ namespace ${modelNamespace};
265
+
266
+ /**
267
+ * DO NOT EDIT - This file is auto-generated by Omnify.
268
+ * Any changes will be overwritten on next generation.
269
+ *
270
+ * @generated by omnify
271
+ */
272
+ enum FileStatusEnum: string
273
+ {
274
+ case Temporary = 'temporary';
275
+ case Permanent = 'permanent';
276
+
277
+ /**
278
+ * Get human-readable label.
279
+ */
280
+ public function label(): string
281
+ {
282
+ return match ($this) {
283
+ self::Temporary => __('Temporary'),
284
+ self::Permanent => __('Permanent'),
285
+ };
286
+ }
287
+
288
+ /**
289
+ * Get all possible values.
290
+ *
291
+ * @return array<string>
292
+ */
293
+ public static function values(): array
294
+ {
295
+ return array_column(self::cases(), 'value');
296
+ }
297
+ }
298
+ `;
299
+ return baseFile(`${config.models.path}/FileStatusEnum.php`, content);
300
+ }
301
+ function generateFileLocales(config, locales) {
302
+ const baseNamespace = config.models.baseNamespace;
303
+ // Display names for the File model
304
+ const displayNames = {
305
+ ja: 'ファイル',
306
+ en: 'File',
307
+ vi: 'Tệp',
308
+ };
309
+ // Property display names
310
+ const propertyDisplayNames = {
311
+ collection: { ja: 'コレクション', en: 'Collection', vi: 'Bộ sưu tập' },
312
+ disk: { ja: 'ディスク', en: 'Disk', vi: 'Đĩa' },
313
+ path: { ja: 'パス', en: 'Path', vi: 'Đường dẫn' },
314
+ original_name: { ja: 'ファイル名', en: 'File Name', vi: 'Tên tệp' },
315
+ mime_type: { ja: 'MIMEタイプ', en: 'MIME Type', vi: 'Loại MIME' },
316
+ size: { ja: 'サイズ', en: 'Size', vi: 'Kích thước' },
317
+ status: { ja: 'ステータス', en: 'Status', vi: 'Trạng thái' },
318
+ expires_at: { ja: '有効期限', en: 'Expires At', vi: 'Hết hạn' },
319
+ sort_order: { ja: '並び順', en: 'Sort Order', vi: 'Thứ tự sắp xếp' },
320
+ };
321
+ // Filter to configured locales
322
+ const filteredDisplayNames = Object.fromEntries(locales.filter(l => displayNames[l]).map(l => [l, displayNames[l]]));
323
+ const displayNamesPhp = formatPhpArray(filteredDisplayNames);
324
+ const propertyNamesPhp = Object.entries(propertyDisplayNames)
325
+ .map(([prop, names]) => {
326
+ const filtered = Object.fromEntries(locales.filter(l => names[l]).map(l => [l, names[l]]));
327
+ return ` '${prop}' => ${formatPhpArray(filtered)}`;
328
+ })
329
+ .join(',\n');
330
+ const content = `<?php
331
+
332
+ namespace ${baseNamespace}\\Locales;
333
+
334
+ /**
335
+ * DO NOT EDIT - This file is auto-generated by Omnify.
336
+ * Any changes will be overwritten on next generation.
337
+ *
338
+ * @generated by omnify
339
+ */
340
+ class FileLocales
341
+ {
342
+ public const DISPLAY_NAMES = ${displayNamesPhp};
343
+
344
+ public const PROPERTY_DISPLAY_NAMES = [
345
+ ${propertyNamesPhp},
346
+ ];
347
+ }
348
+ `;
349
+ return baseFile(`${config.models.basePath}/Locales/FileLocales.php`, content);
350
+ }
351
+ function formatPhpArray(obj) {
352
+ const entries = Object.entries(obj)
353
+ .map(([key, val]) => `'${key}' => '${escapePhp(val)}'`)
354
+ .join(', ');
355
+ return `[${entries}]`;
356
+ }
357
+ function escapePhp(s) {
358
+ return s.replace(/'/g, "\\'");
359
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Generates the HasFiles trait for models with File-type properties.
3
+ */
4
+ import type { GeneratedFile, PhpConfig } from './types.js';
5
+ /** Generate the HasFiles trait. */
6
+ export declare function generateFileTrait(config: PhpConfig): GeneratedFile[];
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Generates the HasFiles trait for models with File-type properties.
3
+ */
4
+ import { baseFile } from './types.js';
5
+ /** Generate the HasFiles trait. */
6
+ export function generateFileTrait(config) {
7
+ const baseNamespace = config.models.baseNamespace;
8
+ const modelNamespace = config.models.namespace;
9
+ const content = `<?php
10
+
11
+ namespace ${baseNamespace}\\Traits;
12
+
13
+ /**
14
+ * Trait for models with polymorphic file attachments.
15
+ *
16
+ * DO NOT EDIT - This file is auto-generated by Omnify.
17
+ * Any changes will be overwritten on next generation.
18
+ *
19
+ * @generated by omnify
20
+ */
21
+
22
+ use Illuminate\\Database\\Eloquent\\Relations\\MorphMany;
23
+ use ${modelNamespace}\\File;
24
+ use ${modelNamespace}\\FileStatusEnum;
25
+
26
+ trait HasFiles
27
+ {
28
+ /**
29
+ * Get all files attached to this model.
30
+ */
31
+ public function files(): MorphMany
32
+ {
33
+ return $this->morphMany(File::class, 'fileable');
34
+ }
35
+
36
+ /**
37
+ * Get files in a specific collection.
38
+ */
39
+ public function filesInCollection(string $collection): MorphMany
40
+ {
41
+ return $this->files()->where('collection', $collection);
42
+ }
43
+
44
+ /**
45
+ * Get all permanent files.
46
+ */
47
+ public function permanentFiles(): MorphMany
48
+ {
49
+ return $this->files()->where('status', FileStatusEnum::Permanent);
50
+ }
51
+
52
+ /**
53
+ * Attach files by IDs to a collection, making them permanent.
54
+ *
55
+ * @param array<string> $fileIds
56
+ */
57
+ public function attachFiles(array $fileIds, string $collection = 'default', int $startOrder = 0): void
58
+ {
59
+ if (empty($fileIds)) return;
60
+
61
+ File::whereIn('id', $fileIds)->update([
62
+ 'fileable_type' => $this->getMorphClass(),
63
+ 'fileable_id' => $this->getKey(),
64
+ 'collection' => $collection,
65
+ 'status' => FileStatusEnum::Permanent,
66
+ 'expires_at' => null,
67
+ 'sort_order' => \\Illuminate\\Support\\Facades\\DB::raw('sort_order + ' . $startOrder),
68
+ ]);
69
+ }
70
+
71
+ /**
72
+ * Sync files for a collection (detach old, attach new).
73
+ *
74
+ * @param array<string> $fileIds
75
+ */
76
+ public function syncFiles(array $fileIds, string $collection = 'default'): void
77
+ {
78
+ // Soft-delete files no longer in the list
79
+ $this->filesInCollection($collection)
80
+ ->whereNotIn('id', $fileIds)
81
+ ->delete();
82
+
83
+ // Attach new files
84
+ $this->attachFiles($fileIds, $collection);
85
+
86
+ // Update sort order based on array position
87
+ foreach ($fileIds as $index => $id) {
88
+ File::where('id', $id)->update(['sort_order' => $index]);
89
+ }
90
+ }
91
+ }
92
+ `;
93
+ return [
94
+ baseFile(`${config.models.basePath}/Traits/HasFiles.php`, content),
95
+ ];
96
+ }
@@ -18,10 +18,13 @@ import { generateLocales } from './locales-generator.js';
18
18
  import { generateModels } from './model-generator.js';
19
19
  import { generateRequests } from './request-generator.js';
20
20
  import { generateResources } from './resource-generator.js';
21
- import { generateFactories } from './factory-generator.js';
21
+ import { generateFactories, generateFileFactory } from './factory-generator.js';
22
22
  import { generateServiceProvider } from './service-provider-generator.js';
23
23
  import { generateTranslationModels } from './translation-model-generator.js';
24
24
  import { generatePolicies } from './policy-generator.js';
25
+ import { generateFileModels } from './file-model-generator.js';
26
+ import { generateFileTrait } from './file-trait-generator.js';
27
+ import { generateFileCleanup } from './file-cleanup-generator.js';
25
28
  export { derivePhpConfig } from './types.js';
26
29
  /** Generate all PHP files from schemas.json data. */
27
30
  export function generatePhp(data, overrides) {
@@ -32,6 +35,13 @@ export function generatePhp(data, overrides) {
32
35
  files.push(...generateBaseModel(config));
33
36
  files.push(...generateTrait(config));
34
37
  files.push(...generateServiceProvider(reader, config));
38
+ // File infrastructure (only when schemas use File type)
39
+ if (reader.hasFileProperties()) {
40
+ files.push(...generateFileModels(reader, config));
41
+ files.push(...generateFileTrait(config));
42
+ files.push(...generateFileFactory(reader, config));
43
+ files.push(...generateFileCleanup(reader, config));
44
+ }
35
45
  // Per-schema files
36
46
  files.push(...generateLocales(reader, config));
37
47
  files.push(...generateModels(reader, config));
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Port of ModelGenerator.php — generates Eloquent model base + user classes.
3
3
  */
4
- import { toPascalCase, toSnakeCase } from './naming-helper.js';
4
+ import { toPascalCase, toSnakeCase, toCamelCase } from './naming-helper.js';
5
5
  import { toCast, toPhpDocType, isHiddenByDefault } from './type-mapper.js';
6
6
  import { buildRelation } from './relation-builder.js';
7
7
  import { baseFile, userFile } from './types.js';
@@ -54,17 +54,23 @@ function generateBaseModel(name, schema, reader, config) {
54
54
  const isCompositeKey = primaryKey.includes(',');
55
55
  const needsUuidTrait = !isCompositeKey && idType === 'Uuid';
56
56
  const needsUlidTrait = !isCompositeKey && idType === 'Ulid';
57
- const imports = buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait, needsUlidTrait, hasNestedSet, config.nestedset.namespace);
57
+ // Detect File properties for HasFiles trait
58
+ const hasFiles = propertyOrder.some(p => {
59
+ const prop = properties[p];
60
+ return prop && prop['type'] === 'File';
61
+ });
62
+ const imports = buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait, needsUlidTrait, hasNestedSet, config.nestedset.namespace, hasFiles, modelNamespace);
58
63
  const docProperties = buildDocProperties(properties, expandedProperties, propertyOrder);
59
64
  const baseClass = isAuthenticatable ? 'Authenticatable' : 'BaseModel';
60
65
  const implementsClause = hasTranslatable ? ' implements TranslatableContract' : '';
61
- const traits = buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait, needsUlidTrait, hasNestedSet);
66
+ const traits = buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait, needsUlidTrait, hasNestedSet, hasFiles);
62
67
  const fillable = buildFillable(properties, expandedProperties, propertyOrder);
63
68
  const hidden = buildHidden(properties, expandedProperties, propertyOrder);
64
69
  const appends = buildAppends(expandedProperties);
65
70
  const casts = buildCasts(properties, expandedProperties, propertyOrder, reader);
66
71
  const relations = buildRelations(name, properties, propertyOrder, modelNamespace, reader);
67
72
  const accessors = buildAccessors(expandedProperties);
73
+ const fileAccessors = hasFiles ? buildFileAccessors(properties, propertyOrder, modelNamespace) : '';
68
74
  const nestedSetMethod = buildNestedSetParentIdMethod(nestedSetParentColumn);
69
75
  let keyTypeSection = '';
70
76
  if (idType === 'Uuid' || idType === 'Ulid' || idType === 'String') {
@@ -162,7 +168,7 @@ ${appends} ];
162
168
  return [
163
169
  ${casts} ];
164
170
  }
165
- ${relations}${accessors}${nestedSetMethod}
171
+ ${relations}${accessors}${fileAccessors}${nestedSetMethod}
166
172
  }
167
173
  `;
168
174
  return baseFile(`${config.models.basePath}/${modelName}BaseModel.php`, content);
@@ -217,7 +223,7 @@ ${traits.join('\n')}
217
223
  `;
218
224
  return userFile(`${config.models.path}/${modelName}.php`, content);
219
225
  }
220
- function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, nestedSetNamespace = 'Aimeos\\Nestedset') {
226
+ function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, nestedSetNamespace = 'Aimeos\\Nestedset', hasFiles = false, modelNamespace = '') {
221
227
  const lines = [];
222
228
  if (isAuthenticatable) {
223
229
  lines.push('use Illuminate\\Foundation\\Auth\\User as Authenticatable;');
@@ -237,6 +243,12 @@ function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable
237
243
  lines.push('use Illuminate\\Database\\Eloquent\\Collection as EloquentCollection;');
238
244
  lines.push(`use ${baseNamespace}\\Traits\\HasLocalizedDisplayName;`);
239
245
  lines.push(`use ${baseNamespace}\\Locales\\${modelName}Locales;`);
246
+ if (hasFiles) {
247
+ lines.push(`use ${baseNamespace}\\Traits\\HasFiles;`);
248
+ if (modelNamespace) {
249
+ lines.push(`use ${modelNamespace}\\File;`);
250
+ }
251
+ }
240
252
  if (hasSoftDelete) {
241
253
  lines.push('use Illuminate\\Database\\Eloquent\\SoftDeletes;');
242
254
  }
@@ -283,9 +295,11 @@ function buildDocProperties(properties, expandedProperties, propertyOrder) {
283
295
  }
284
296
  return lines.length === 0 ? '' : lines.join('\n') + '\n';
285
297
  }
286
- function buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false) {
298
+ function buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, hasFiles = false) {
287
299
  const lines = [];
288
300
  lines.push(' use HasLocalizedDisplayName;');
301
+ if (hasFiles)
302
+ lines.push(' use HasFiles;');
289
303
  if (needsUuidTrait)
290
304
  lines.push(' use HasUuids;');
291
305
  if (needsUlidTrait)
@@ -535,6 +549,38 @@ function buildNestedSetParentIdMethod(parentColumn) {
535
549
  }
536
550
  `;
537
551
  }
552
+ function buildFileAccessors(properties, propertyOrder, modelNamespace) {
553
+ const methods = [];
554
+ for (const propName of propertyOrder) {
555
+ const prop = properties[propName];
556
+ if (!prop || prop['type'] !== 'File')
557
+ continue;
558
+ const collection = prop['collection'] || toSnakeCase(propName);
559
+ const multiple = prop['multiple'] ?? false;
560
+ const methodName = toCamelCase(propName);
561
+ if (multiple) {
562
+ methods.push(`
563
+ /**
564
+ * Get ${propName} files (collection: ${collection}).
565
+ */
566
+ public function ${methodName}(): MorphMany
567
+ {
568
+ return $this->filesInCollection('${collection}');
569
+ }`);
570
+ }
571
+ else {
572
+ methods.push(`
573
+ /**
574
+ * Get ${propName} file (collection: ${collection}).
575
+ */
576
+ public function ${methodName}(): ?File
577
+ {
578
+ return $this->filesInCollection('${collection}')->first();
579
+ }`);
580
+ }
581
+ }
582
+ return methods.join('\n');
583
+ }
538
584
  function fullNameAccessor(prefix, suffix, fields, separator) {
539
585
  const methodName = 'get' + toPascalCase(prefix) + suffix + 'Attribute';
540
586
  const fieldAccess = fields.map(f => `$this->${f}`).join(', ');
@@ -86,6 +86,29 @@ function generateBaseRequest(name, schema, reader, config, action) {
86
86
  attributeKeys.push(snakeName);
87
87
  continue;
88
88
  }
89
+ // File type: generate array item rules for multiple files
90
+ if (type === 'File') {
91
+ const snakeName = toSnakeCase(propName);
92
+ const multiple = prop['multiple'] ?? false;
93
+ const rules = isUpdate
94
+ ? toUpdateRules(prop, tableName, modelRouteParam)
95
+ : toStoreRules(prop, tableName);
96
+ rulesLines.push(` '${snakeName}' => ${formatRules(rules)},`);
97
+ attributeKeys.push(snakeName);
98
+ if (multiple) {
99
+ // Generate array item rules: field.* => ['file', 'mimes:...', 'max:...']
100
+ const itemRules = ['file'];
101
+ const accept = prop['accept'];
102
+ if (accept?.length)
103
+ itemRules.push(`mimes:${accept.join(',')}`);
104
+ const maxSize = prop['maxSize'];
105
+ if (maxSize)
106
+ itemRules.push(`max:${maxSize}`);
107
+ const formattedItemRules = `[${itemRules.map(r => `'${r}'`).join(', ')}]`;
108
+ rulesLines.push(` '${snakeName}.*' => ${formattedItemRules},`);
109
+ }
110
+ continue;
111
+ }
89
112
  const snakeName = toSnakeCase(propName);
90
113
  const rules = isUpdate
91
114
  ? toUpdateRules(prop, tableName, modelRouteParam)
@@ -47,6 +47,12 @@ export declare class SchemaReader {
47
47
  getExpandedProperties(schemaName: string): Record<string, ExpandedProperty>;
48
48
  getPropertyOrder(schemaName: string): string[];
49
49
  getTableName(schemaName: string): string;
50
+ /** Check if any schema has File-type properties. */
51
+ hasFileProperties(): boolean;
52
+ /** Get the file config from schemas.json. */
53
+ getFileConfig(): import("../types.js").FileConfigExport | null;
54
+ /** Get all schemas that have File-type properties. */
55
+ getSchemasWithFileProperties(): Record<string, SchemaDefinition>;
50
56
  /** Get translatable field names (snake_case) for a schema. */
51
57
  getTranslatableFields(schemaName: string): string[];
52
58
  }
@@ -158,6 +158,35 @@ export class SchemaReader {
158
158
  const schema = this.getSchema(schemaName);
159
159
  return schema?.tableName ?? '';
160
160
  }
161
+ /** Check if any schema has File-type properties. */
162
+ hasFileProperties() {
163
+ for (const schema of Object.values(this.getObjectSchemas())) {
164
+ const props = schema.properties ?? {};
165
+ for (const prop of Object.values(props)) {
166
+ if (prop.type === 'File')
167
+ return true;
168
+ }
169
+ }
170
+ return false;
171
+ }
172
+ /** Get the file config from schemas.json. */
173
+ getFileConfig() {
174
+ return this.data.fileConfig ?? null;
175
+ }
176
+ /** Get all schemas that have File-type properties. */
177
+ getSchemasWithFileProperties() {
178
+ const result = {};
179
+ for (const [name, schema] of Object.entries(this.getObjectSchemas())) {
180
+ const props = schema.properties ?? {};
181
+ for (const prop of Object.values(props)) {
182
+ if (prop.type === 'File') {
183
+ result[name] = schema;
184
+ break;
185
+ }
186
+ }
187
+ }
188
+ return result;
189
+ }
161
190
  /** Get translatable field names (snake_case) for a schema. */
162
191
  getTranslatableFields(schemaName) {
163
192
  const schema = this.getSchema(schemaName);
@@ -12,6 +12,10 @@ export function generateServiceProvider(reader, config) {
12
12
  const modelName = toPascalCase(name);
13
13
  morphEntries.push(` '${modelName}' => \\${modelNamespace}\\${modelName}::class,`);
14
14
  }
15
+ // Add File to morph map when any schema uses File type
16
+ if (reader.hasFileProperties()) {
17
+ morphEntries.push(` 'File' => \\${modelNamespace}\\File::class,`);
18
+ }
15
19
  morphEntries.sort();
16
20
  const morphMapContent = morphEntries.join('\n');
17
21
  // Build package migration loading lines
@@ -149,6 +149,25 @@ export function toStoreRules(property, tableName) {
149
149
  }
150
150
  break;
151
151
  }
152
+ case 'File': {
153
+ const multiple = property['multiple'] ?? false;
154
+ if (multiple) {
155
+ rules.push('array');
156
+ const maxFiles = property['maxFiles'];
157
+ if (maxFiles)
158
+ rules.push(`max:${maxFiles}`);
159
+ }
160
+ else {
161
+ rules.push('file');
162
+ const accept = property['accept'];
163
+ if (accept?.length)
164
+ rules.push(`mimes:${accept.join(',')}`);
165
+ const maxSize = property['maxSize'];
166
+ if (maxSize)
167
+ rules.push(`max:${maxSize}`);
168
+ }
169
+ break;
170
+ }
152
171
  default:
153
172
  rules.push('string', 'max:255');
154
173
  }
@@ -5,6 +5,13 @@
5
5
  */
6
6
  /** Localized string — either a plain string or a locale map. */
7
7
  export type LocalizedString = string | Record<string, string>;
8
+ /** File upload configuration. */
9
+ export interface FileConfigExport {
10
+ readonly tempFlow?: boolean;
11
+ readonly tempTtl?: string;
12
+ readonly cleanupSchedule?: string;
13
+ readonly defaultDisk?: string;
14
+ }
8
15
  /** Top-level schemas.json structure. */
9
16
  export interface SchemasJson {
10
17
  readonly generatedAt: string;
@@ -22,6 +29,7 @@ export interface SchemasJson {
22
29
  readonly simple: Record<string, SimpleTypeDefinition>;
23
30
  readonly enums: Record<string, string[]>;
24
31
  };
32
+ readonly fileConfig?: FileConfigExport;
25
33
  readonly packages?: Record<string, PackageExportInfo>;
26
34
  readonly schemas: Record<string, SchemaDefinition>;
27
35
  }
@@ -189,6 +197,11 @@ export interface PropertyDefinition {
189
197
  readonly pattern?: string;
190
198
  readonly rules?: ValidationRules;
191
199
  readonly enum?: string | readonly string[];
200
+ readonly multiple?: boolean;
201
+ readonly maxFiles?: number;
202
+ readonly accept?: readonly string[];
203
+ readonly maxSize?: number;
204
+ readonly collection?: string;
192
205
  readonly relation?: string;
193
206
  readonly target?: string;
194
207
  readonly targets?: readonly string[];
@@ -227,8 +227,17 @@ function getZodSchemaForType(propDef, _fieldName, options) {
227
227
  schema = 'z.number().int().positive()';
228
228
  break;
229
229
  case 'Association':
230
- case 'File':
231
230
  return '';
231
+ case 'File': {
232
+ const multiple = propDef.multiple ?? false;
233
+ if (multiple) {
234
+ schema = 'z.array(OmnifyFileSchema)';
235
+ }
236
+ else {
237
+ schema = 'OmnifyFileSchema.nullable()';
238
+ }
239
+ break;
240
+ }
232
241
  default:
233
242
  schema = 'z.string()';
234
243
  }