domma-cms 0.8.7 → 0.9.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/README.md +31 -9
- package/admin/js/templates/action-editor.html +5 -0
- package/admin/js/templates/block-editor.html +5 -0
- package/admin/js/templates/collection-editor.html +7 -0
- package/admin/js/templates/effects.html +147 -72
- package/admin/js/templates/form-editor.html +7 -0
- package/admin/js/templates/page-editor.html +5 -0
- package/admin/js/templates/view-editor.html +5 -0
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/block-editor.js +4 -4
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/effects.js +1 -1
- package/admin/js/views/form-editor.js +1 -1
- package/admin/js/views/navigation.js +13 -12
- package/admin/js/views/page-editor.js +11 -11
- package/admin/js/views/pages.js +2 -2
- package/admin/js/views/view-editor.js +1 -1
- package/package.json +1 -1
- package/plugins/contacts/collections/user-contact-groups/schema.json +35 -0
- package/plugins/contacts/collections/user-contacts/schema.json +71 -0
- package/plugins/contacts/plugin.js +1 -55
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/plugin.js +0 -40
- package/plugins/notes/collections/user-notes/schema.json +53 -0
- package/plugins/notes/plugin.js +1 -47
- package/plugins/todo/collections/todos/schema.json +59 -0
- package/plugins/todo/plugin.js +1 -48
- package/server/routes/api/blocks.js +19 -43
- package/server/routes/api/forms.js +8 -1
- package/server/services/blocks.js +124 -5
- package/server/services/collections.js +17 -3
- package/server/services/forms.js +78 -0
- package/server/services/plugins.js +197 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "todos",
|
|
3
|
+
"title": "Todos",
|
|
4
|
+
"description": "Todo items managed by the Todo plugin.",
|
|
5
|
+
"plugin": "todo",
|
|
6
|
+
"fields": [
|
|
7
|
+
{
|
|
8
|
+
"name": "text",
|
|
9
|
+
"label": "Task",
|
|
10
|
+
"type": "text",
|
|
11
|
+
"required": true
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "status",
|
|
15
|
+
"label": "Status",
|
|
16
|
+
"type": "text",
|
|
17
|
+
"required": false
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "priority",
|
|
21
|
+
"label": "Priority",
|
|
22
|
+
"type": "text",
|
|
23
|
+
"required": false
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "dueAt",
|
|
27
|
+
"label": "Due Date",
|
|
28
|
+
"type": "text",
|
|
29
|
+
"required": false
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "userId",
|
|
33
|
+
"label": "User ID",
|
|
34
|
+
"type": "text",
|
|
35
|
+
"required": false
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"api": {
|
|
39
|
+
"create": {
|
|
40
|
+
"enabled": true,
|
|
41
|
+
"access": "admin"
|
|
42
|
+
},
|
|
43
|
+
"read": {
|
|
44
|
+
"enabled": true,
|
|
45
|
+
"access": "admin"
|
|
46
|
+
},
|
|
47
|
+
"update": {
|
|
48
|
+
"enabled": false,
|
|
49
|
+
"access": "admin"
|
|
50
|
+
},
|
|
51
|
+
"delete": {
|
|
52
|
+
"enabled": false,
|
|
53
|
+
"access": "admin"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"storage": {
|
|
57
|
+
"adapter": "file"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/plugins/todo/plugin.js
CHANGED
|
@@ -4,44 +4,10 @@ import {
|
|
|
4
4
|
createEntry,
|
|
5
5
|
updateEntry,
|
|
6
6
|
deleteEntry,
|
|
7
|
-
getEntry
|
|
8
|
-
getCollection,
|
|
9
|
-
createCollection
|
|
7
|
+
getEntry
|
|
10
8
|
} from '../../server/services/collections.js';
|
|
11
9
|
|
|
12
10
|
const SLUG = 'todos';
|
|
13
|
-
const STORAGE = defaultConfig.storage ?? {adapter: 'file'};
|
|
14
|
-
|
|
15
|
-
const FIELDS = [
|
|
16
|
-
{name: 'text', label: 'Task', type: 'text', required: true},
|
|
17
|
-
{name: 'status', label: 'Status', type: 'text', required: false},
|
|
18
|
-
{name: 'priority', label: 'Priority', type: 'text', required: false},
|
|
19
|
-
{name: 'dueAt', label: 'Due Date', type: 'text', required: false},
|
|
20
|
-
{name: 'userId', label: 'User ID', type: 'text', required: false}
|
|
21
|
-
];
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Lifecycle: create the todos collection (MongoDB-backed) on plugin enable.
|
|
25
|
-
*/
|
|
26
|
-
export async function onEnable({services: {collections}}) {
|
|
27
|
-
const existing = await collections.getCollection(SLUG).catch(() => null);
|
|
28
|
-
if (existing) return;
|
|
29
|
-
await collections.createCollection({
|
|
30
|
-
title: 'Todos',
|
|
31
|
-
slug: SLUG,
|
|
32
|
-
description: 'Todo items managed by the Todo plugin.',
|
|
33
|
-
fields: FIELDS,
|
|
34
|
-
storage: STORAGE
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Lifecycle: remove the todos collection on plugin disable.
|
|
40
|
-
*/
|
|
41
|
-
export async function onDisable({services: {collections}}) {
|
|
42
|
-
await collections.deleteCollection(SLUG).catch(() => {
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
11
|
|
|
46
12
|
/** Flatten a collection entry into the shape the admin view expects. */
|
|
47
13
|
function toTodo(entry) {
|
|
@@ -57,19 +23,6 @@ export default async function todoPlugin(fastify, options) {
|
|
|
57
23
|
const { authenticate } = options.auth;
|
|
58
24
|
const config = {...defaultConfig, ...(options.settings || {})};
|
|
59
25
|
const scope = config.scope ?? 'user';
|
|
60
|
-
const storage = config.storage ?? STORAGE;
|
|
61
|
-
|
|
62
|
-
// Auto-create the collection if it doesn't exist yet.
|
|
63
|
-
const existing = await getCollection(SLUG).catch(() => null);
|
|
64
|
-
if (!existing) {
|
|
65
|
-
await createCollection({
|
|
66
|
-
title: 'Todos',
|
|
67
|
-
slug: SLUG,
|
|
68
|
-
description: 'Todo items managed by the Todo plugin.',
|
|
69
|
-
fields: FIELDS,
|
|
70
|
-
storage
|
|
71
|
-
}).catch(err => fastify.log.warn(`[todo] Collection setup: ${err.message}`));
|
|
72
|
-
}
|
|
73
26
|
|
|
74
27
|
function userId(request) {
|
|
75
28
|
return scope === 'user' ? (request.user?.id ?? request.user?.sub ?? null) : null;
|
|
@@ -6,20 +6,8 @@
|
|
|
6
6
|
* PUT /api/blocks/:name - create or update block
|
|
7
7
|
* DELETE /api/blocks/:name - delete block
|
|
8
8
|
*/
|
|
9
|
-
import path from 'path';
|
|
10
|
-
import fs from 'fs/promises';
|
|
11
|
-
import {fileURLToPath} from 'url';
|
|
12
9
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
13
|
-
|
|
14
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
const ROOT = path.resolve(__dirname, '../../..');
|
|
16
|
-
const BLOCKS_DIR = path.join(ROOT, 'content', 'blocks');
|
|
17
|
-
|
|
18
|
-
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
19
|
-
|
|
20
|
-
function blockPath(name) {
|
|
21
|
-
return path.join(BLOCKS_DIR, `${name}.html`);
|
|
22
|
-
}
|
|
10
|
+
import {deleteBlock, getBlock, listBlocks, saveBlock} from '../../services/blocks.js';
|
|
23
11
|
|
|
24
12
|
export async function blocksRoutes(fastify) {
|
|
25
13
|
const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
|
|
@@ -28,57 +16,45 @@ export async function blocksRoutes(fastify) {
|
|
|
28
16
|
|
|
29
17
|
// List all blocks
|
|
30
18
|
fastify.get('/blocks', canRead, async () => {
|
|
31
|
-
|
|
32
|
-
const files = await fs.readdir(BLOCKS_DIR);
|
|
33
|
-
const blocks = [];
|
|
34
|
-
for (const file of files.filter(f => f.endsWith('.html'))) {
|
|
35
|
-
const name = file.slice(0, -5);
|
|
36
|
-
const stat = await fs.stat(path.join(BLOCKS_DIR, file)).catch(() => null);
|
|
37
|
-
blocks.push({
|
|
38
|
-
name,
|
|
39
|
-
size: stat?.size ?? 0,
|
|
40
|
-
updatedAt: stat?.mtime?.toISOString() ?? null
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
return blocks.sort((a, b) => a.name.localeCompare(b.name));
|
|
19
|
+
return listBlocks();
|
|
44
20
|
});
|
|
45
21
|
|
|
46
22
|
// Get single block
|
|
47
23
|
fastify.get('/blocks/:name', canRead, async (request, reply) => {
|
|
48
24
|
const {name} = request.params;
|
|
49
|
-
if (!NAME_RE.test(name)) return reply.status(400).send({error: 'Invalid block name'});
|
|
50
|
-
|
|
51
25
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return reply.status(404).send({error: 'Block not found'});
|
|
26
|
+
return await getBlock(name);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
29
|
+
if (err.code === 'ENOENT') return reply.status(404).send({error: 'Block not found'});
|
|
30
|
+
throw err;
|
|
56
31
|
}
|
|
57
32
|
});
|
|
58
33
|
|
|
59
34
|
// Create or update block
|
|
60
35
|
fastify.put('/blocks/:name', canUpdate, async (request, reply) => {
|
|
61
36
|
const {name} = request.params;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const {content} = request.body || {};
|
|
37
|
+
const {content, bundled} = request.body || {};
|
|
65
38
|
if (typeof content !== 'string') return reply.status(400).send({error: 'content (string) is required'});
|
|
66
39
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
40
|
+
try {
|
|
41
|
+
return await saveBlock(name, content, {bundled: !!bundled});
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
70
46
|
});
|
|
71
47
|
|
|
72
48
|
// Delete block
|
|
73
49
|
fastify.delete('/blocks/:name', canDelete, async (request, reply) => {
|
|
74
50
|
const {name} = request.params;
|
|
75
|
-
if (!NAME_RE.test(name)) return reply.status(400).send({error: 'Invalid block name'});
|
|
76
|
-
|
|
77
51
|
try {
|
|
78
|
-
await
|
|
52
|
+
await deleteBlock(name);
|
|
79
53
|
return reply.status(204).send();
|
|
80
|
-
} catch {
|
|
81
|
-
return reply.status(
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
56
|
+
if (err.code === 'ENOENT') return reply.status(404).send({error: 'Block not found'});
|
|
57
|
+
throw err;
|
|
82
58
|
}
|
|
83
59
|
});
|
|
84
60
|
}
|
|
@@ -456,10 +456,17 @@ export async function formsRoutes(fastify) {
|
|
|
456
456
|
|
|
457
457
|
hooks.emit('form:submitted', {slug, entryId: entry?.id || null, data, formTitle: form.title});
|
|
458
458
|
|
|
459
|
+
// Template interpolation for successRedirect
|
|
460
|
+
let redirect = settings.successRedirect || null;
|
|
461
|
+
if (redirect && entry?.id) {
|
|
462
|
+
redirect = redirect.replace(/\{\{entryId\}\}/g, entry.id);
|
|
463
|
+
}
|
|
464
|
+
|
|
459
465
|
return {
|
|
460
466
|
ok: true,
|
|
467
|
+
entryId: entry?.id || null,
|
|
461
468
|
message: settings.successMessage || 'Thank you for your submission.',
|
|
462
|
-
redirect:
|
|
469
|
+
redirect: redirect
|
|
463
470
|
};
|
|
464
471
|
});
|
|
465
472
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Blocks Service
|
|
3
|
-
*
|
|
4
|
-
* Never overwrites existing files — user customisations are preserved.
|
|
3
|
+
* CRUD operations and seeding for reusable HTML block templates in content/blocks/.
|
|
4
|
+
* Never overwrites existing files during seeding — user customisations are preserved.
|
|
5
5
|
*/
|
|
6
|
-
import
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import {fileURLToPath} from 'url';
|
|
9
9
|
|
|
@@ -19,10 +19,10 @@ const BLOCKS_DIR = path.resolve(__dirname, '../../content/blocks');
|
|
|
19
19
|
async function seedBlock(name, content) {
|
|
20
20
|
const filePath = path.join(BLOCKS_DIR, `${name}.html`);
|
|
21
21
|
try {
|
|
22
|
-
await access(filePath);
|
|
22
|
+
await fs.access(filePath);
|
|
23
23
|
// File exists — leave it alone
|
|
24
24
|
} catch {
|
|
25
|
-
await writeFile(filePath, content.trim() + '\n', 'utf8');
|
|
25
|
+
await fs.writeFile(filePath, content.trim() + '\n', 'utf8');
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -145,6 +145,125 @@ const BLOCKS = {
|
|
|
145
145
|
|
|
146
146
|
};
|
|
147
147
|
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Validation
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/** Block names must be lowercase alphanumeric + hyphens, no path traversal. */
|
|
153
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
154
|
+
|
|
155
|
+
function assertValidName(name) {
|
|
156
|
+
if (!NAME_RE.test(name)) {
|
|
157
|
+
const err = new Error('Invalid block name. Use lowercase letters, digits, and hyphens only.');
|
|
158
|
+
err.code = 'INVALID_NAME';
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function blockFilePath(name) {
|
|
164
|
+
return path.join(BLOCKS_DIR, `${name}.html`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// CRUD service functions
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* List all blocks in the blocks directory.
|
|
173
|
+
*
|
|
174
|
+
* @returns {Promise<Array<{name: string, size: number, updatedAt: string|null}>>}
|
|
175
|
+
*/
|
|
176
|
+
export async function listBlocks() {
|
|
177
|
+
await fs.mkdir(BLOCKS_DIR, {recursive: true});
|
|
178
|
+
const files = await fs.readdir(BLOCKS_DIR);
|
|
179
|
+
const blocks = [];
|
|
180
|
+
for (const file of files.filter(f => f.endsWith('.html'))) {
|
|
181
|
+
const name = file.slice(0, -5);
|
|
182
|
+
const fileStat = await fs.stat(path.join(BLOCKS_DIR, file)).catch(() => null);
|
|
183
|
+
let bundled = false;
|
|
184
|
+
try {
|
|
185
|
+
const meta = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
|
|
186
|
+
bundled = !!meta.bundled;
|
|
187
|
+
} catch { /* no meta file */ }
|
|
188
|
+
blocks.push({
|
|
189
|
+
name,
|
|
190
|
+
size: fileStat?.size ?? 0,
|
|
191
|
+
updatedAt: fileStat?.mtime?.toISOString() ?? null,
|
|
192
|
+
bundled,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return blocks.sort((a, b) => a.name.localeCompare(b.name));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Read a single block's content by name.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} name - Block name (without .html extension)
|
|
202
|
+
* @returns {Promise<{name: string, content: string}>}
|
|
203
|
+
* @throws {Error} With code INVALID_NAME or ENOENT when not found
|
|
204
|
+
*/
|
|
205
|
+
export async function getBlock(name) {
|
|
206
|
+
assertValidName(name);
|
|
207
|
+
try {
|
|
208
|
+
const content = await fs.readFile(blockFilePath(name), 'utf8');
|
|
209
|
+
let bundled = false;
|
|
210
|
+
try {
|
|
211
|
+
const meta = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
|
|
212
|
+
bundled = !!meta.bundled;
|
|
213
|
+
} catch { /* no meta file */ }
|
|
214
|
+
return {name, content, bundled};
|
|
215
|
+
} catch (err) {
|
|
216
|
+
if (err.code === 'ENOENT') {
|
|
217
|
+
const notFound = new Error('Block not found');
|
|
218
|
+
notFound.code = 'ENOENT';
|
|
219
|
+
throw notFound;
|
|
220
|
+
}
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Create or update a block file (upsert).
|
|
227
|
+
*
|
|
228
|
+
* @param {string} name - Block name (without .html extension)
|
|
229
|
+
* @param {string} content - HTML template content
|
|
230
|
+
* @returns {Promise<{success: boolean, name: string}>}
|
|
231
|
+
* @throws {Error} With code INVALID_NAME on bad name
|
|
232
|
+
*/
|
|
233
|
+
export async function saveBlock(name, content, {bundled} = {}) {
|
|
234
|
+
assertValidName(name);
|
|
235
|
+
await fs.mkdir(BLOCKS_DIR, {recursive: true});
|
|
236
|
+
await fs.writeFile(blockFilePath(name), content, 'utf8');
|
|
237
|
+
const metaPath = path.join(BLOCKS_DIR, `${name}.meta.json`);
|
|
238
|
+
if (bundled) {
|
|
239
|
+
await fs.writeFile(metaPath, JSON.stringify({bundled: true}, null, 2) + '\n', 'utf8');
|
|
240
|
+
} else {
|
|
241
|
+
await fs.unlink(metaPath).catch(() => {});
|
|
242
|
+
}
|
|
243
|
+
return {success: true, name};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Delete a block file.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} name - Block name (without .html extension)
|
|
250
|
+
* @returns {Promise<void>}
|
|
251
|
+
* @throws {Error} With code INVALID_NAME or ENOENT when not found
|
|
252
|
+
*/
|
|
253
|
+
export async function deleteBlock(name) {
|
|
254
|
+
assertValidName(name);
|
|
255
|
+
try {
|
|
256
|
+
await fs.unlink(blockFilePath(name));
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (err.code === 'ENOENT') {
|
|
259
|
+
const notFound = new Error('Block not found');
|
|
260
|
+
notFound.code = 'ENOENT';
|
|
261
|
+
throw notFound;
|
|
262
|
+
}
|
|
263
|
+
throw err;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
148
267
|
// ---------------------------------------------------------------------------
|
|
149
268
|
// Public API
|
|
150
269
|
// ---------------------------------------------------------------------------
|
|
@@ -36,7 +36,13 @@ function slugify(str) {
|
|
|
36
36
|
|
|
37
37
|
async function readSchema(slug) {
|
|
38
38
|
const raw = await fs.readFile(schemaPath(slug), 'utf8');
|
|
39
|
-
|
|
39
|
+
const schema = JSON.parse(raw);
|
|
40
|
+
// Normalise legacy 'preset' flag — both mean "bundled with fresh installs"
|
|
41
|
+
if (schema.preset && !schema.bundled) {
|
|
42
|
+
schema.bundled = true;
|
|
43
|
+
delete schema.preset;
|
|
44
|
+
}
|
|
45
|
+
return schema;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
async function writeSchema(schema) {
|
|
@@ -115,7 +121,7 @@ export async function getCollection(slug) {
|
|
|
115
121
|
* @returns {Promise<object>} Created schema
|
|
116
122
|
* @throws {Error} If a collection with that slug already exists
|
|
117
123
|
*/
|
|
118
|
-
export async function createCollection({title, slug, description = '', fields = [], api = {}, storage}) {
|
|
124
|
+
export async function createCollection({title, slug, description = '', fields = [], api = {}, storage, bundled, plugin}) {
|
|
119
125
|
await ensureDir();
|
|
120
126
|
const finalSlug = slug ? slugify(slug) : slugify(title);
|
|
121
127
|
if (!finalSlug) throw new Error('Could not derive a slug from the title');
|
|
@@ -133,6 +139,8 @@ export async function createCollection({title, slug, description = '', fields =
|
|
|
133
139
|
slug: finalSlug,
|
|
134
140
|
title: title.trim(),
|
|
135
141
|
description: description.trim(),
|
|
142
|
+
...(bundled ? {bundled: true} : {}),
|
|
143
|
+
...(plugin ? {plugin} : {}),
|
|
136
144
|
fields,
|
|
137
145
|
api: { ...defaultApiAccess(), ...api },
|
|
138
146
|
storage: storage || {adapter: 'file'},
|
|
@@ -157,14 +165,20 @@ export async function updateCollection(slug, updates) {
|
|
|
157
165
|
const schema = await getCollection(slug);
|
|
158
166
|
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
159
167
|
|
|
160
|
-
const { slug: _ignore, createdAt, ...rest } = updates;
|
|
168
|
+
const { slug: _ignore, createdAt, plugin: _plugin, bundled: _bundled, ...rest } = updates;
|
|
161
169
|
const updated = {
|
|
162
170
|
...schema,
|
|
163
171
|
...rest,
|
|
172
|
+
// bundled is user-editable — set from update, omit if falsy
|
|
173
|
+
...(updates.bundled ? {bundled: true} : {}),
|
|
174
|
+
// plugin is ownership metadata — never overwrite from updates
|
|
175
|
+
...(schema.plugin ? {plugin: schema.plugin} : {}),
|
|
164
176
|
slug,
|
|
165
177
|
createdAt: schema.createdAt,
|
|
166
178
|
updatedAt: new Date().toISOString()
|
|
167
179
|
};
|
|
180
|
+
// Clear bundled from schema if it was unchecked (schema spread may have preserved old value)
|
|
181
|
+
if (!updates.bundled) delete updated.bundled;
|
|
168
182
|
|
|
169
183
|
await writeSchema(updated);
|
|
170
184
|
|
package/server/services/forms.js
CHANGED
|
@@ -90,6 +90,84 @@ export function slugify(str) {
|
|
|
90
90
|
return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Get a single form definition by slug (alias for readForm).
|
|
95
|
+
*
|
|
96
|
+
* @param {string} slug
|
|
97
|
+
* @returns {Promise<object>}
|
|
98
|
+
* @throws {Error} If the form file does not exist or cannot be parsed.
|
|
99
|
+
*/
|
|
100
|
+
export async function getForm(slug) {
|
|
101
|
+
return readForm(slug);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Create a new form definition and write it to disk.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} data - Form definition data
|
|
108
|
+
* @param {string} data.title - Form title (required if no slug)
|
|
109
|
+
* @param {string} [data.slug] - Explicit slug (derived from title if omitted)
|
|
110
|
+
* @param {string} [data.description]
|
|
111
|
+
* @param {Array} [data.fields]
|
|
112
|
+
* @param {object} [data.settings]
|
|
113
|
+
* @param {object} [data.actions]
|
|
114
|
+
* @returns {Promise<object>} The created form object
|
|
115
|
+
* @throws {Error} If title is missing, slug cannot be derived, or a form with the slug already exists.
|
|
116
|
+
*/
|
|
117
|
+
export async function createForm(data) {
|
|
118
|
+
const { title, slug: rawSlug, description = '', fields = [], settings = {}, actions = {}, plugin } = data || {};
|
|
119
|
+
|
|
120
|
+
if (!title?.trim() && !rawSlug?.trim()) {
|
|
121
|
+
throw new Error('A title or slug is required to create a form.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const slug = rawSlug ? slugify(rawSlug) : slugify(title);
|
|
125
|
+
if (!slug) {
|
|
126
|
+
throw new Error('Could not derive a valid slug from the provided title or slug.');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Throw if a form with this slug already exists
|
|
130
|
+
try {
|
|
131
|
+
await readForm(slug);
|
|
132
|
+
const err = new Error(`Form with slug "${slug}" already exists`);
|
|
133
|
+
err.code = 'FORM_ALREADY_EXISTS';
|
|
134
|
+
throw err;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.code === 'FORM_ALREADY_EXISTS') throw err;
|
|
137
|
+
// File not found — safe to proceed
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const now = new Date().toISOString();
|
|
141
|
+
const trimmedTitle = title ? title.trim() : slug;
|
|
142
|
+
|
|
143
|
+
const form = {
|
|
144
|
+
slug,
|
|
145
|
+
title: trimmedTitle,
|
|
146
|
+
description,
|
|
147
|
+
...(plugin ? {plugin} : {}),
|
|
148
|
+
fields: Array.isArray(fields) ? fields : [],
|
|
149
|
+
settings: {
|
|
150
|
+
submitText: 'Submit',
|
|
151
|
+
successMessage: 'Thank you for your submission.',
|
|
152
|
+
layout: 'stacked',
|
|
153
|
+
honeypot: true,
|
|
154
|
+
rateLimitPerMinute: 3,
|
|
155
|
+
...settings
|
|
156
|
+
},
|
|
157
|
+
actions: {
|
|
158
|
+
email: { enabled: false, recipients: '', subjectPrefix: `[${trimmedTitle}]` },
|
|
159
|
+
webhook: { enabled: false, url: '', method: 'POST' },
|
|
160
|
+
collection: { enabled: true, slug },
|
|
161
|
+
...actions
|
|
162
|
+
},
|
|
163
|
+
createdAt: now,
|
|
164
|
+
updatedAt: now
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
await writeForm(slug, form);
|
|
168
|
+
return form;
|
|
169
|
+
}
|
|
170
|
+
|
|
93
171
|
/** System collections that should never have an auto-generated public form. */
|
|
94
172
|
const NO_FORM_SLUGS = new Set(['roles', 'user-profiles']);
|
|
95
173
|
|