includio-cms 0.5.7 → 0.6.1

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/ROADMAP.md +20 -4
  3. package/dist/admin/api/handler.js +2 -0
  4. package/dist/admin/api/media-gc.d.ts +3 -0
  5. package/dist/admin/api/media-gc.js +27 -0
  6. package/dist/admin/client/collection/collection-entries.svelte +43 -1
  7. package/dist/admin/client/collection/table-toolbar.svelte +64 -1
  8. package/dist/admin/client/collection/table-toolbar.svelte.d.ts +11 -0
  9. package/dist/admin/client/index.d.ts +1 -0
  10. package/dist/admin/client/index.js +1 -0
  11. package/dist/admin/client/maintenance/maintenance-page.svelte +205 -0
  12. package/dist/admin/client/maintenance/maintenance-page.svelte.d.ts +3 -0
  13. package/dist/admin/components/fields/field-renderer.svelte +3 -2
  14. package/dist/admin/components/fields/field-renderer.svelte.d.ts +1 -0
  15. package/dist/admin/components/fields/object-field.svelte +5 -5
  16. package/dist/admin/components/fields/object-field.svelte.d.ts +1 -1
  17. package/dist/admin/components/fields/text-field-wrapper.svelte +5 -3
  18. package/dist/admin/components/layout/lang.d.ts +1 -0
  19. package/dist/admin/components/layout/lang.js +4 -2
  20. package/dist/admin/components/layout/layout-renderer.svelte +81 -107
  21. package/dist/admin/components/layout/layout-renderer.svelte.d.ts +1 -0
  22. package/dist/admin/components/layout/nav-main.svelte +6 -0
  23. package/dist/admin/components/tiptap/InlineBlockNodeView.svelte +13 -6
  24. package/dist/admin/components/tiptap/content-editor.svelte +11 -2
  25. package/dist/admin/styles/admin.css +2 -1
  26. package/dist/ai-claude/index.js +10 -4
  27. package/dist/cli/index.js +10 -3
  28. package/dist/cli/install-peers.d.ts +3 -0
  29. package/dist/cli/install-peers.js +52 -0
  30. package/dist/core/fields/fieldSchemaToTs.js +2 -0
  31. package/dist/core/fields/layoutUtils.d.ts +30 -3
  32. package/dist/core/fields/layoutUtils.js +145 -17
  33. package/dist/core/server/generator/generator.js +21 -10
  34. package/dist/core/server/media/operations/purgeImageStyles.d.ts +7 -0
  35. package/dist/core/server/media/operations/purgeImageStyles.js +25 -0
  36. package/dist/core/server/media/operations/reconcileMedia.d.ts +12 -0
  37. package/dist/core/server/media/operations/reconcileMedia.js +62 -0
  38. package/dist/core/server/media/styles/operations/createMediaStyle.js +12 -1
  39. package/dist/db-postgres/index.js +25 -12
  40. package/dist/db-postgres/schema/imageStyle.js +2 -0
  41. package/dist/entity/index.d.ts +26 -0
  42. package/dist/entity/index.js +113 -0
  43. package/dist/files-local/index.js +11 -1
  44. package/dist/types/adapters/db.d.ts +8 -0
  45. package/dist/types/adapters/files.d.ts +2 -0
  46. package/dist/types/layout.d.ts +8 -0
  47. package/dist/updates/0.5.8/index.d.ts +2 -0
  48. package/dist/updates/0.5.8/index.js +27 -0
  49. package/dist/updates/0.6.0/index.d.ts +2 -0
  50. package/dist/updates/0.6.0/index.js +20 -0
  51. package/dist/updates/index.js +3 -1
  52. package/package.json +48 -14
@@ -5,7 +5,8 @@ export function getFieldsFromConfig(config) {
5
5
  export function hasLayout(config) {
6
6
  return !!config.layout;
7
7
  }
8
- /** Collect all field slugs referenced in layout nodes (depth-first order) */
8
+ /** Collect all field paths referenced in layout nodes (depth-first order).
9
+ * Supports both plain slugs ('title') and dot-notation ('hero.title'). */
9
10
  export function collectFieldSlugs(nodes) {
10
11
  const slugs = [];
11
12
  for (const node of nodes) {
@@ -18,28 +19,133 @@ export function collectFieldSlugs(nodes) {
18
19
  }
19
20
  return slugs;
20
21
  }
22
+ /**
23
+ * Resolve a field definition by dot-notation path.
24
+ * E.g. 'companyInfo.contact.email' navigates: fields → companyInfo → fields → contact → fields → email
25
+ */
26
+ export function resolveFieldByPath(fields, path) {
27
+ const parts = path.split('.');
28
+ let current = fields;
29
+ for (let i = 0; i < parts.length; i++) {
30
+ const field = current.find((f) => f.slug === parts[i]);
31
+ if (!field)
32
+ return undefined;
33
+ if (i === parts.length - 1)
34
+ return field;
35
+ if (field.type !== 'object' || !('fields' in field))
36
+ return undefined;
37
+ current = field.fields;
38
+ }
39
+ return undefined;
40
+ }
41
+ /**
42
+ * Collect all leaf field paths from field definitions (recursively flattens objects).
43
+ * E.g. an object 'hero' with fields 'title','image' → ['hero.title', 'hero.image']
44
+ * Non-object fields at top level → ['slug']
45
+ */
46
+ export function collectAllLeafPaths(fields, prefix = '') {
47
+ const paths = [];
48
+ for (const f of fields) {
49
+ const fullPath = prefix ? `${prefix}.${f.slug}` : f.slug;
50
+ if (f.type === 'object' && 'fields' in f) {
51
+ paths.push(...collectAllLeafPaths(f.fields, fullPath));
52
+ }
53
+ else {
54
+ paths.push(fullPath);
55
+ }
56
+ }
57
+ return paths;
58
+ }
59
+ /**
60
+ * Expand a layout field reference into leaf paths.
61
+ * - 'title' (non-object) → ['title']
62
+ * - 'hero' (object with fields) → ['hero.title', 'hero.image', ...]
63
+ * - 'hero.title' (dot-notation to leaf) → ['hero.title']
64
+ */
65
+ function expandToLeafPaths(ref, fields) {
66
+ const resolved = resolveFieldByPath(fields, ref);
67
+ if (!resolved)
68
+ return [ref]; // unresolved — let validation catch it
69
+ if (resolved.type === 'object' && 'fields' in resolved) {
70
+ return collectAllLeafPaths(resolved.fields, ref);
71
+ }
72
+ return [ref];
73
+ }
74
+ /**
75
+ * Identify top-level object slugs where ALL leaf fields are individually
76
+ * distributed across layout nodes (via dot-notation).
77
+ * These objects should suppress their own wrapper rendering.
78
+ */
79
+ export function getDistributedObjectSlugs(nodes, fields) {
80
+ const refs = collectFieldSlugs(nodes);
81
+ const distributed = new Set();
82
+ for (const f of fields) {
83
+ if (f.type !== 'object' || !('fields' in f))
84
+ continue;
85
+ // Check: is this object referenced as a whole?
86
+ if (refs.includes(f.slug))
87
+ continue;
88
+ // Get all leaf paths for this object
89
+ const leafPaths = collectAllLeafPaths(f.fields, f.slug);
90
+ if (leafPaths.length === 0)
91
+ continue;
92
+ // Check if ALL leaf paths are covered by layout refs
93
+ const allCovered = leafPaths.every((lp) => refs.includes(lp));
94
+ if (allCovered)
95
+ distributed.add(f.slug);
96
+ }
97
+ return distributed;
98
+ }
99
+ /**
100
+ * Build SuperForm-compatible path for a dot-notation field reference.
101
+ * 'hero.title' → 'hero.data.title'
102
+ * 'hero.contact.email' → 'hero.data.contact.data.email'
103
+ * 'title' → 'title' (no change for top-level)
104
+ */
105
+ export function buildFormPath(dotPath) {
106
+ const parts = dotPath.split('.');
107
+ if (parts.length <= 1)
108
+ return dotPath;
109
+ const result = [parts[0]];
110
+ for (let i = 1; i < parts.length; i++) {
111
+ result.push('data', parts[i]);
112
+ }
113
+ return result.join('.');
114
+ }
21
115
  /** Count columns expected by a ratio string */
22
116
  function columnCount(ratio) {
23
117
  return ratio.split(' ').length;
24
118
  }
25
- /** Validate layout against fields — returns errors or empty array */
119
+ /** Validate layout against fields — returns errors or empty array.
120
+ * Supports dot-notation paths (e.g. 'hero.title'). */
26
121
  export function validateLayout(nodes, fields) {
27
122
  const errors = [];
28
- const fieldSlugs = new Set(fields.map((f) => f.slug));
123
+ const topLevelSlugs = new Set(fields.map((f) => f.slug));
29
124
  const referencedSlugs = collectFieldSlugs(nodes);
30
- // Check for missing fields
125
+ // Check for missing fields — support both top-level slugs and dot-notation
31
126
  for (const slug of referencedSlugs) {
32
- if (!fieldSlugs.has(slug)) {
33
- errors.push({ type: 'missing_field', message: `Field "${slug}" not found in fields[]` });
127
+ if (slug.includes('.')) {
128
+ // Dot-notation: resolve through field tree
129
+ if (!resolveFieldByPath(fields, slug)) {
130
+ errors.push({ type: 'missing_field', message: `Field "${slug}" not found in fields[]` });
131
+ }
132
+ }
133
+ else {
134
+ if (!topLevelSlugs.has(slug)) {
135
+ errors.push({ type: 'missing_field', message: `Field "${slug}" not found in fields[]` });
136
+ }
34
137
  }
35
138
  }
36
- // Check for duplicates
37
- const seen = new Set();
38
- for (const slug of referencedSlugs) {
39
- if (seen.has(slug)) {
40
- errors.push({ type: 'duplicate_field', message: `Field "${slug}" referenced multiple times` });
139
+ // Check for duplicates (expand to leaf paths for overlap detection)
140
+ const seenLeaves = new Set();
141
+ for (const ref of referencedSlugs) {
142
+ const leaves = expandToLeafPaths(ref, fields);
143
+ for (const leaf of leaves) {
144
+ if (seenLeaves.has(leaf)) {
145
+ errors.push({ type: 'duplicate_field', message: `Field "${leaf}" referenced multiple times` });
146
+ }
147
+ seenLeaves.add(leaf);
41
148
  }
42
- seen.add(slug);
43
149
  }
44
150
  // Check depth + columns
45
151
  function walk(nodeList, depth) {
@@ -72,7 +178,8 @@ export function resolveLayout(layout, fields) {
72
178
  return layout;
73
179
  return expandPreset(layout, fields);
74
180
  }
75
- /** Resolve layout + append orphan fields in a trailing section */
181
+ /** Resolve layout + append orphan fields in a trailing section.
182
+ * Orphan detection works at leaf level — dot-notation refs count as covering their leaves. */
76
183
  export function resolveLayoutWithOrphans(config) {
77
184
  if (!config.layout) {
78
185
  // No layout — single section with all fields
@@ -85,16 +192,37 @@ export function resolveLayoutWithOrphans(config) {
85
192
  ];
86
193
  }
87
194
  const nodes = resolveLayout(config.layout, config.fields);
88
- const referenced = new Set(collectFieldSlugs(nodes));
89
- const orphans = config.fields.filter((f) => !referenced.has(f.slug)).map((f) => f.slug);
90
- if (orphans.length === 0)
195
+ const refs = collectFieldSlugs(nodes);
196
+ // Expand all refs to leaf paths for coverage checking
197
+ const coveredLeaves = new Set();
198
+ for (const ref of refs) {
199
+ for (const leaf of expandToLeafPaths(ref, config.fields)) {
200
+ coveredLeaves.add(leaf);
201
+ }
202
+ }
203
+ // Find orphan top-level fields (not covered at all)
204
+ const allLeaves = collectAllLeafPaths(config.fields);
205
+ const orphanTopSlugs = [];
206
+ for (const f of config.fields) {
207
+ if (f.type === 'object' && 'fields' in f) {
208
+ const objectLeaves = collectAllLeafPaths(f.fields, f.slug);
209
+ const anyCovered = objectLeaves.some((lp) => coveredLeaves.has(lp));
210
+ if (!anyCovered)
211
+ orphanTopSlugs.push(f.slug);
212
+ }
213
+ else {
214
+ if (!coveredLeaves.has(f.slug))
215
+ orphanTopSlugs.push(f.slug);
216
+ }
217
+ }
218
+ if (orphanTopSlugs.length === 0)
91
219
  return nodes;
92
220
  return [
93
221
  ...nodes,
94
222
  {
95
223
  type: 'section',
96
224
  label: { en: 'Other', pl: 'Pozostałe' },
97
- fields: orphans
225
+ fields: orphanTopSlugs
98
226
  }
99
227
  ];
100
228
  }
@@ -31,7 +31,8 @@ function generateTypesStringForRecords(type, records) {
31
31
  export type ${recordTypeString}EntryMap = {
32
32
  ${records
33
33
  .map((single) => {
34
- return `${single.slug}: ${toPascalCase(single.slug)}`;
34
+ const key = single.slug.includes('-') ? `'${single.slug}'` : single.slug;
35
+ return `${key}: ${toPascalCase(single.slug)}`;
35
36
  })
36
37
  .join(';\n')}
37
38
  }
@@ -58,7 +59,8 @@ function generateTypesStringForForms(records) {
58
59
  export type ${recordTypeString}EntryMap = {
59
60
  ${records
60
61
  .map((single) => {
61
- return `${single.slug}: ${toPascalCase(single.slug)}`;
62
+ const key = single.slug.includes('-') ? `'${single.slug}'` : single.slug;
63
+ return `${key}: ${toPascalCase(single.slug)}`;
62
64
  })
63
65
  .join(';\n')}
64
66
  }
@@ -94,10 +96,10 @@ function generateAPI(config) {
94
96
  `;
95
97
  code += `
96
98
 
97
- interface GetEntryQueryOptions<T extends { data: Record<string, unknown> }> {
99
+ interface GetEntryQueryOptions {
98
100
  id?: string;
99
101
  status?: 'draft' | 'published' | 'scheduled' | 'archived';
100
- dataValues?: Partial<T['data']>;
102
+ dataValues?: Record<string, unknown>;
101
103
  }
102
104
 
103
105
  interface GetEntryOptions {
@@ -118,7 +120,7 @@ function generateAPI(config) {
118
120
  `;
119
121
  code += `
120
122
 
121
- export async function getSingleEntry<K extends SingleSlug>(slug: K, data: GetEntryQueryOptions<SingleEntryMap[K]>, options: GetEntryOptions): Promise<SingleEntryMap[K] | null> {
123
+ export async function getSingleEntry<K extends SingleSlug>(slug: K, data: GetEntryQueryOptions, options: GetEntryOptions): Promise<SingleEntryMap[K] | null> {
122
124
  return (await getEntry({
123
125
  ...data,
124
126
  slug,
@@ -126,7 +128,7 @@ function generateAPI(config) {
126
128
  })) as unknown as SingleEntryMap[K] | null;
127
129
  }
128
130
 
129
- export async function getCollectionEntry<K extends CollectionSlug>(slug: K, data: GetEntryQueryOptions<CollectionEntryMap[K]>, options: GetEntryOptions): Promise<CollectionEntryMap[K] | null> {
131
+ export async function getCollectionEntry<K extends CollectionSlug>(slug: K, data: GetEntryQueryOptions, options: GetEntryOptions): Promise<CollectionEntryMap[K] | null> {
130
132
  return (await getEntry({
131
133
  ...data,
132
134
  slug,
@@ -134,7 +136,15 @@ function generateAPI(config) {
134
136
  })) as unknown as CollectionEntryMap[K] | null;
135
137
  }
136
138
 
137
- export async function getCollectionEntries<K extends CollectionSlug>(slug: K, options: GetEntryOptions): Promise<CollectionEntryMap[K][]> {
139
+ interface GetEntriesQueryOptions {
140
+ ids?: string[];
141
+ status?: 'draft' | 'published' | 'scheduled' | 'archived';
142
+ dataValues?: Record<string, unknown>;
143
+ dataLike?: Record<string, unknown>;
144
+ orderBy?: { column: 'createdAt' | 'updatedAt' | 'sortOrder'; direction: 'asc' | 'desc' };
145
+ }
146
+
147
+ export async function getCollectionEntries<K extends CollectionSlug>(slug: K, options: GetEntryOptions & GetEntriesQueryOptions = {}): Promise<CollectionEntryMap[K][]> {
138
148
  return (await getEntries({ slug, ...options })) as unknown as CollectionEntryMap[K][];
139
149
  }
140
150
 
@@ -162,8 +172,9 @@ function generateSchemas(config) {
162
172
  import { z } from 'zod';
163
173
  `;
164
174
  config.forms?.map((form) => {
175
+ const varName = toPascalCase(form.slug);
165
176
  code += `
166
- export const ${form.slug}FormSchema = ${generateZodSchemaStringFromFormFieldsAsString(form.fields)} \n
177
+ export const ${varName}FormSchema = ${generateZodSchemaStringFromFormFieldsAsString(form.fields)} \n
167
178
  `;
168
179
  });
169
180
  writeFileSync(filePath, code);
@@ -177,13 +188,13 @@ function generateRemote(config) {
177
188
  code += `import { command } from '$app/server';\n`;
178
189
  code += `import { submitForm } from './api';\n`;
179
190
  const schemaImports = config.forms
180
- .map((form) => `${form.slug}FormSchema`)
191
+ .map((form) => `${toPascalCase(form.slug)}FormSchema`)
181
192
  .join(', ');
182
193
  code += `import { ${schemaImports} } from './schemas';\n\n`;
183
194
  config.forms.forEach((form) => {
184
195
  const pascalSlug = toPascalCase(form.slug);
185
196
  code += `export const submit${pascalSlug}Command = command(\n`;
186
- code += `\t${form.slug}FormSchema,\n`;
197
+ code += `\t${pascalSlug}FormSchema,\n`;
187
198
  code += `\tasync (data) => {\n`;
188
199
  code += `\t\tawait submitForm('${form.slug}', data);\n`;
189
200
  code += `\t}\n`;
@@ -0,0 +1,7 @@
1
+ export declare function getImageStylesStats(): Promise<{
2
+ count: number;
3
+ }>;
4
+ export declare function purgeAllImageStyles(): Promise<{
5
+ deletedCount: number;
6
+ filesDeleted: number;
7
+ }>;
@@ -0,0 +1,25 @@
1
+ import { getCMS } from '../../../cms.js';
2
+ export async function getImageStylesStats() {
3
+ const styles = await getCMS().databaseAdapter.getAllImageStyles();
4
+ return { count: styles.length };
5
+ }
6
+ export async function purgeAllImageStyles() {
7
+ const cms = getCMS();
8
+ const styles = await cms.databaseAdapter.getAllImageStyles();
9
+ // Delete files from disk
10
+ let filesDeleted = 0;
11
+ for (const style of styles) {
12
+ const filename = style.url.split('/').pop();
13
+ if (filename) {
14
+ try {
15
+ await cms.filesAdapter.deleteFile(filename);
16
+ filesDeleted++;
17
+ }
18
+ catch {
19
+ // File may not exist
20
+ }
21
+ }
22
+ }
23
+ const deletedCount = await cms.databaseAdapter.deleteAllImageStyles();
24
+ return { deletedCount, filesDeleted };
25
+ }
@@ -0,0 +1,12 @@
1
+ export interface ReconciliationReport {
2
+ orphanedDisk: string[];
3
+ missingDisk: {
4
+ table: string;
5
+ id: string;
6
+ url: string;
7
+ }[];
8
+ }
9
+ export declare function getReconciliationReport(): Promise<ReconciliationReport>;
10
+ export declare function deleteOrphanedDiskFiles(): Promise<{
11
+ deletedCount: number;
12
+ }>;
@@ -0,0 +1,62 @@
1
+ import { getCMS } from '../../../cms.js';
2
+ export async function getReconciliationReport() {
3
+ const cms = getCMS();
4
+ const diskFiles = await cms.filesAdapter.listFiles();
5
+ // Collect all known URLs from DB
6
+ const mediaFiles = await cms.databaseAdapter.getMediaFiles({ data: {} });
7
+ const imageStyles = await cms.databaseAdapter.getAllImageStyles();
8
+ const dbFilenames = new Set();
9
+ for (const mf of mediaFiles) {
10
+ const fn = mf.url.split('/').pop();
11
+ if (fn)
12
+ dbFilenames.add(fn);
13
+ if (mf.thumbnailUrl) {
14
+ const tfn = mf.thumbnailUrl.split('/').pop();
15
+ if (tfn)
16
+ dbFilenames.add(tfn);
17
+ }
18
+ if (mf.posterUrl) {
19
+ const pfn = mf.posterUrl.split('/').pop();
20
+ if (pfn)
21
+ dbFilenames.add(pfn);
22
+ }
23
+ }
24
+ for (const is of imageStyles) {
25
+ const fn = is.url.split('/').pop();
26
+ if (fn)
27
+ dbFilenames.add(fn);
28
+ }
29
+ // Orphaned: on disk but not in DB
30
+ const orphanedDisk = diskFiles.filter((f) => !dbFilenames.has(f));
31
+ // Missing: in DB but not on disk
32
+ const diskSet = new Set(diskFiles);
33
+ const missingDisk = [];
34
+ for (const mf of mediaFiles) {
35
+ const fn = mf.url.split('/').pop();
36
+ if (fn && !diskSet.has(fn)) {
37
+ missingDisk.push({ table: 'media_files', id: mf.id, url: mf.url });
38
+ }
39
+ }
40
+ for (const is of imageStyles) {
41
+ const fn = is.url.split('/').pop();
42
+ if (fn && !diskSet.has(fn)) {
43
+ missingDisk.push({ table: 'image_styles', id: is.id, url: is.url });
44
+ }
45
+ }
46
+ return { orphanedDisk, missingDisk };
47
+ }
48
+ export async function deleteOrphanedDiskFiles() {
49
+ const cms = getCMS();
50
+ const report = await getReconciliationReport();
51
+ let deletedCount = 0;
52
+ for (const filename of report.orphanedDisk) {
53
+ try {
54
+ await cms.filesAdapter.deleteFile(filename);
55
+ deletedCount++;
56
+ }
57
+ catch {
58
+ // ignore
59
+ }
60
+ }
61
+ return { deletedCount };
62
+ }
@@ -1,7 +1,18 @@
1
1
  import { getCMS } from '../../../../cms.js';
2
2
  import { generateImageStyle } from '../sharp/generateImageStyle.js';
3
3
  export async function createImageStyle(mediaFileId, style) {
4
+ const cms = getCMS();
5
+ // Check for existing style to clean up old file after upsert
6
+ const existing = await cms.databaseAdapter.getImageStyle(mediaFileId, style);
7
+ const oldUrl = existing?.url;
4
8
  const imageStyleFile = await generateImageStyle(mediaFileId, style);
5
- const imageStyle = await getCMS().databaseAdapter.createImageStyle(mediaFileId, imageStyleFile, style);
9
+ const imageStyle = await cms.databaseAdapter.createImageStyle(mediaFileId, imageStyleFile, style);
10
+ // Delete old file from disk if URL changed
11
+ if (oldUrl && oldUrl !== imageStyle.url) {
12
+ const oldFilename = oldUrl.split('/').pop();
13
+ if (oldFilename) {
14
+ cms.filesAdapter.deleteFile(oldFilename).catch((e) => console.warn('Old style file cleanup failed:', e));
15
+ }
16
+ }
6
17
  return imageStyle;
7
18
  }
@@ -326,19 +326,19 @@ export function pg(config) {
326
326
  };
327
327
  },
328
328
  createImageStyle: async (mediaFileId, file, style) => {
329
- const [imageStyle] = await db
330
- .insert(schema.imageStylesTable)
331
- .values({
332
- ...style,
333
- url: file.url,
334
- mimeType: file.mimeType ?? `image/${style.format}`,
335
- mediaFileId: mediaFileId
336
- })
337
- .returning();
329
+ const mimeType = file.mimeType ?? `image/${style.format}`;
330
+ const rows = await db.execute(sql `
331
+ INSERT INTO image_styles (id, media_file_id, name, url, width, height, crop, quality, media, mime_type)
332
+ VALUES (gen_random_uuid(), ${mediaFileId}, ${style.name}, ${file.url}, ${style.width ?? null}, ${style.height ?? null}, ${style.crop ?? false}, ${style.quality ?? null}, ${style.media ?? null}, ${mimeType})
333
+ ON CONFLICT (media_file_id, name, COALESCE(width, 0), COALESCE(height, 0), COALESCE(quality, 0))
334
+ DO UPDATE SET url = EXCLUDED.url, mime_type = EXCLUDED.mime_type
335
+ RETURNING url, mime_type, media
336
+ `);
337
+ const row = rows[0];
338
338
  return {
339
- url: imageStyle.url,
340
- mimeType: imageStyle.mimeType,
341
- media: imageStyle.media ? imageStyle.media : undefined
339
+ url: row.url,
340
+ mimeType: row.mime_type,
341
+ media: row.media ? row.media : undefined
342
342
  };
343
343
  },
344
344
  deleteMediaFile: async (id) => {
@@ -421,6 +421,19 @@ export function pg(config) {
421
421
  .returning();
422
422
  const [file] = await hydrateFileTags(db, [rawFile]);
423
423
  return file;
424
+ },
425
+ getAllImageStyles: async () => {
426
+ return db
427
+ .select({
428
+ id: schema.imageStylesTable.id,
429
+ url: schema.imageStylesTable.url,
430
+ mediaFileId: schema.imageStylesTable.mediaFileId
431
+ })
432
+ .from(schema.imageStylesTable);
433
+ },
434
+ deleteAllImageStyles: async () => {
435
+ const result = await db.execute(sql `DELETE FROM image_styles`);
436
+ return Number(result.count ?? 0);
424
437
  }
425
438
  };
426
439
  }
@@ -11,3 +11,5 @@ export const imageStylesTable = pgTable('image_styles', {
11
11
  media: text('media'),
12
12
  mimeType: text('mime_type').notNull()
13
13
  });
14
+ // NOTE: unique index on (media_file_id, name, COALESCE(width,0), COALESCE(height,0), COALESCE(quality,0))
15
+ // is managed via SQL migration in src/lib/updates/0.5.8/index.ts (expression-based, not supported by Drizzle ORM)
@@ -0,0 +1,26 @@
1
+ import type { CMS } from '../core/cms.js';
2
+ import type { DbEntry, DbEntryVersion, EntryData } from '../types/entries.js';
3
+ interface EntityAPIOptions {
4
+ userId?: string;
5
+ }
6
+ interface CreateOptions {
7
+ skipValidation?: boolean;
8
+ sortOrder?: number;
9
+ }
10
+ export declare function createEntityAPI(cms: CMS, opts?: EntityAPIOptions): {
11
+ create(slug: string, data?: EntryData, options?: CreateOptions): Promise<DbEntry>;
12
+ update(entryId: string, data: EntryData, options?: {
13
+ skipValidation?: boolean;
14
+ }): Promise<DbEntryVersion>;
15
+ publish(entryId: string): Promise<void>;
16
+ unpublish(entryId: string): Promise<void>;
17
+ archive(entryId: string): Promise<void>;
18
+ unarchive(entryId: string): Promise<void>;
19
+ delete(entryId: string): Promise<void>;
20
+ list(slug: string, options?: {
21
+ includeArchived?: boolean;
22
+ onlyArchived?: boolean;
23
+ }): Promise<import("../types/entries.js").RawEntry[]>;
24
+ createAndPublish(slug: string, data: EntryData, options?: CreateOptions): Promise<DbEntry>;
25
+ };
26
+ export {};
@@ -0,0 +1,113 @@
1
+ import { generateZodSchemaFromFields } from '../core/fields/fieldSchemaToTs.js';
2
+ import { getFieldsFromConfig } from '../core/fields/layoutUtils.js';
3
+ import { getRawEntries } from '../core/server/entries/operations/get.js';
4
+ export function createEntityAPI(cms, opts) {
5
+ const db = cms.databaseAdapter;
6
+ const userId = opts?.userId ?? 'system';
7
+ function validate(slug, data) {
8
+ const config = cms.getBySlug(slug);
9
+ const schema = generateZodSchemaFromFields(getFieldsFromConfig(config), cms.languages);
10
+ const result = schema.safeParse(data);
11
+ if (!result.success) {
12
+ throw Error('Invalid data: ' + JSON.stringify(result.error.flatten()));
13
+ }
14
+ return result.data;
15
+ }
16
+ return {
17
+ async create(slug, data, options) {
18
+ const config = cms.getBySlug(slug);
19
+ const type = config.type === 'collection' ? 'collection' : 'singleton';
20
+ const entry = await db.createEntry({
21
+ slug,
22
+ type,
23
+ sortOrder: options?.sortOrder ?? null
24
+ });
25
+ const entryData = data ?? {};
26
+ const validatedData = data && !options?.skipValidation ? validate(slug, entryData) : entryData;
27
+ await db.createEntryVersion({
28
+ entryId: entry.id,
29
+ data: validatedData,
30
+ createdBy: userId
31
+ });
32
+ return entry;
33
+ },
34
+ async update(entryId, data, options) {
35
+ const entries = await db.getEntries({ ids: [entryId] });
36
+ const entry = entries[0];
37
+ if (!entry)
38
+ throw Error('Entry not found');
39
+ const validatedData = !options?.skipValidation ? validate(entry.slug, data) : data;
40
+ const versions = await db.getEntryVersions({ entryIds: [entryId] });
41
+ const sorted = versions.sort((a, b) => b.versionNumber - a.versionNumber);
42
+ const latestDraft = sorted.find((v) => v.id !== entry.publishedVersionId);
43
+ if (latestDraft) {
44
+ if (JSON.stringify(latestDraft.data) === JSON.stringify(validatedData)) {
45
+ return latestDraft;
46
+ }
47
+ return db.updateEntryVersion({
48
+ id: latestDraft.id,
49
+ data: { data: validatedData, createdAt: new Date() }
50
+ });
51
+ }
52
+ return db.createEntryVersion({
53
+ entryId,
54
+ data: validatedData,
55
+ createdBy: userId
56
+ });
57
+ },
58
+ async publish(entryId) {
59
+ const versions = await db.getEntryVersions({ entryIds: [entryId] });
60
+ const entries = await db.getEntries({ ids: [entryId] });
61
+ const entry = entries[0];
62
+ if (!entry)
63
+ throw Error('Entry not found');
64
+ const draft = versions
65
+ .filter((v) => v.id !== entry.publishedVersionId)
66
+ .sort((a, b) => b.versionNumber - a.versionNumber)[0];
67
+ if (!draft)
68
+ throw Error('No draft to publish');
69
+ await db.updateEntry({
70
+ id: entryId,
71
+ data: {
72
+ publishedVersionId: draft.id,
73
+ publishedAt: new Date(),
74
+ publishedBy: userId,
75
+ archivedAt: null
76
+ }
77
+ });
78
+ },
79
+ async unpublish(entryId) {
80
+ await db.updateEntry({
81
+ id: entryId,
82
+ data: {
83
+ publishedVersionId: null,
84
+ publishedAt: null,
85
+ publishedBy: null
86
+ }
87
+ });
88
+ },
89
+ async archive(entryId) {
90
+ await db.updateEntry({
91
+ id: entryId,
92
+ data: { archivedAt: new Date() }
93
+ });
94
+ },
95
+ async unarchive(entryId) {
96
+ await db.updateEntry({
97
+ id: entryId,
98
+ data: { archivedAt: null }
99
+ });
100
+ },
101
+ async delete(entryId) {
102
+ await db.deleteEntry({ id: entryId });
103
+ },
104
+ async list(slug, options) {
105
+ return getRawEntries({ slug, ...options });
106
+ },
107
+ async createAndPublish(slug, data, options) {
108
+ const entry = await this.create(slug, data, options);
109
+ await this.publish(entry.id);
110
+ return entry;
111
+ }
112
+ };
113
+ }
@@ -1,6 +1,6 @@
1
1
  import { basename, extname, resolve } from 'node:path';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { readFile, writeFile, rename, unlink, mkdir } from 'node:fs/promises';
3
+ import { readFile, writeFile, rename, unlink, mkdir, readdir } from 'node:fs/promises';
4
4
  import sharp from 'sharp';
5
5
  import ffmpeg from 'fluent-ffmpeg';
6
6
  import { fileURLToPath } from 'url';
@@ -301,6 +301,16 @@ export function local() {
301
301
  catch {
302
302
  // File may already be deleted
303
303
  }
304
+ },
305
+ listFiles: async () => {
306
+ try {
307
+ await ensureDir(fullDir);
308
+ const entries = await readdir(fullDir, { withFileTypes: true });
309
+ return entries.filter((e) => e.isFile()).map((e) => e.name);
310
+ }
311
+ catch {
312
+ return [];
313
+ }
304
314
  }
305
315
  };
306
316
  }