@saltcorn/server 0.9.0-beta.9 → 0.9.1-beta.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/auth/admin.js +2 -0
- package/auth/testhelp.js +13 -0
- package/help/API actions.tmd +12 -0
- package/locales/en.json +8 -1
- package/locales/es.json +1270 -273
- package/locales/fr.json +7 -1
- package/package.json +8 -8
- package/public/saltcorn-common.js +41 -11
- package/public/saltcorn.js +14 -0
- package/routes/actions.js +21 -7
- package/routes/api.js +2 -2
- package/routes/common_lists.js +11 -5
- package/routes/homepage.js +10 -4
- package/routes/page.js +1 -0
- package/routes/pageedit.js +10 -3
- package/routes/viewedit.js +2 -2
- package/tests/api.test.js +67 -0
- package/tests/viewedit.test.js +244 -1
- package/wrapper.js +6 -3
package/locales/fr.json
CHANGED
|
@@ -286,5 +286,11 @@
|
|
|
286
286
|
"Users and security": "Users and security",
|
|
287
287
|
"Site structure": "Site structure",
|
|
288
288
|
"Events": "Events",
|
|
289
|
-
"Are you sure?": "Are you sure?"
|
|
289
|
+
"Are you sure?": "Are you sure?",
|
|
290
|
+
"API token": "API token",
|
|
291
|
+
"No API token issued": "No API token issued",
|
|
292
|
+
"Generate": "Generate",
|
|
293
|
+
"Two-factor authentication": "Two-factor authentication",
|
|
294
|
+
"Two-factor authentication is disabled": "Two-factor authentication is disabled",
|
|
295
|
+
"Enable 2FA": "Enable 2FA"
|
|
290
296
|
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saltcorn/server",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1-beta.0",
|
|
4
4
|
"description": "Server app for Saltcorn, open-source no-code platform",
|
|
5
5
|
"homepage": "https://saltcorn.com",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@saltcorn/base-plugin": "0.9.
|
|
10
|
-
"@saltcorn/builder": "0.9.
|
|
11
|
-
"@saltcorn/data": "0.9.
|
|
12
|
-
"@saltcorn/admin-models": "0.9.
|
|
13
|
-
"@saltcorn/filemanager": "0.9.
|
|
14
|
-
"@saltcorn/markup": "0.9.
|
|
15
|
-
"@saltcorn/sbadmin2": "0.9.
|
|
9
|
+
"@saltcorn/base-plugin": "0.9.1-beta.0",
|
|
10
|
+
"@saltcorn/builder": "0.9.1-beta.0",
|
|
11
|
+
"@saltcorn/data": "0.9.1-beta.0",
|
|
12
|
+
"@saltcorn/admin-models": "0.9.1-beta.0",
|
|
13
|
+
"@saltcorn/filemanager": "0.9.1-beta.0",
|
|
14
|
+
"@saltcorn/markup": "0.9.1-beta.0",
|
|
15
|
+
"@saltcorn/sbadmin2": "0.9.1-beta.0",
|
|
16
16
|
"@socket.io/cluster-adapter": "^0.2.1",
|
|
17
17
|
"@socket.io/sticky": "^1.0.1",
|
|
18
18
|
"adm-zip": "0.5.10",
|
|
@@ -128,10 +128,13 @@ function apply_showif() {
|
|
|
128
128
|
const dynwhere = JSON.parse(
|
|
129
129
|
decodeURIComponent(e.attr("data-fetch-options"))
|
|
130
130
|
);
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
if (window._sc_loglevel > 4) console.log("dynwhere", dynwhere);
|
|
132
|
+
const kvToQs = ([k, v]) => {
|
|
133
|
+
return k === "or" && Array.isArray(v)
|
|
134
|
+
? v.map((v1) => Object.entries(v1).map(kvToQs).join("&")).join("&")
|
|
135
|
+
: `${k}=${v[0] === "$" ? rec[v.substring(1)] : v}`;
|
|
136
|
+
};
|
|
137
|
+
const qss = Object.entries(dynwhere.whereParsed).map(kvToQs);
|
|
135
138
|
if (dynwhere.dereference) {
|
|
136
139
|
if (Array.isArray(dynwhere.dereference))
|
|
137
140
|
qss.push(...dynwhere.dereference.map((d) => `dereference=${d}`));
|
|
@@ -151,7 +154,8 @@ function apply_showif() {
|
|
|
151
154
|
e.empty();
|
|
152
155
|
e.prop("data-fetch-options-current-set", qs);
|
|
153
156
|
const toAppend = [];
|
|
154
|
-
if (!dynwhere.required)
|
|
157
|
+
if (!dynwhere.required)
|
|
158
|
+
toAppend.push({ label: dynwhere.neutral_label || "" });
|
|
155
159
|
let currentDataOption = undefined;
|
|
156
160
|
const dataOptions = [];
|
|
157
161
|
//console.log(success);
|
|
@@ -170,12 +174,28 @@ function apply_showif() {
|
|
|
170
174
|
const selected = `${current}` === `${r[dynwhere.refname]}`;
|
|
171
175
|
dataOptions.push({ text: label, value });
|
|
172
176
|
if (selected) currentDataOption = value;
|
|
173
|
-
|
|
174
|
-
selected ? "selected" : ""
|
|
175
|
-
} value="${value}">${label}</option>`;
|
|
176
|
-
toAppend.push(html);
|
|
177
|
+
toAppend.push({ selected, value, label });
|
|
177
178
|
});
|
|
178
|
-
|
|
179
|
+
toAppend.sort((a, b) =>
|
|
180
|
+
a.label === dynwhere.neutral_label
|
|
181
|
+
? -1
|
|
182
|
+
: b.label === dynwhere.neutral_label
|
|
183
|
+
? 1
|
|
184
|
+
: (a.label?.toLowerCase?.() || a.label) >
|
|
185
|
+
(b.label?.toLowerCase?.() || b.label)
|
|
186
|
+
? 1
|
|
187
|
+
: -1
|
|
188
|
+
);
|
|
189
|
+
e.html(
|
|
190
|
+
toAppend
|
|
191
|
+
.map(
|
|
192
|
+
({ label, value, selected }) =>
|
|
193
|
+
`<option${selected ? ` selected` : ""}${
|
|
194
|
+
value ? ` value="${value}"` : ""
|
|
195
|
+
}>${label || ""}</option>`
|
|
196
|
+
)
|
|
197
|
+
.join("")
|
|
198
|
+
);
|
|
179
199
|
|
|
180
200
|
//TODO: also sort inserted HTML options
|
|
181
201
|
dataOptions.sort((a, b) =>
|
|
@@ -205,6 +225,9 @@ function apply_showif() {
|
|
|
205
225
|
});
|
|
206
226
|
$.ajax(`/api/${dynwhere.table}?${qs}`).then((resp) => {
|
|
207
227
|
if (resp.success) {
|
|
228
|
+
if (window._sc_loglevel > 4)
|
|
229
|
+
console.log("dynwhere fetch", qs, resp.success);
|
|
230
|
+
|
|
208
231
|
activate(resp.success, qs);
|
|
209
232
|
const cacheNow = e.prop("data-fetch-options-cache") || {};
|
|
210
233
|
e.prop("data-fetch-options-cache", {
|
|
@@ -1016,7 +1039,14 @@ function common_done(res, viewname, isWeb = true) {
|
|
|
1016
1039
|
if (res.notify) handle(res.notify, notifyAlert);
|
|
1017
1040
|
if (res.error)
|
|
1018
1041
|
handle(res.error, (text) => notifyAlert({ type: "danger", text: text }));
|
|
1019
|
-
|
|
1042
|
+
|
|
1043
|
+
if (res.eval_js && res.row && res.field_names) {
|
|
1044
|
+
const f = new Function(`row, {${res.field_names}}`, res.eval_js);
|
|
1045
|
+
const evalres = f(res.row, res.row);
|
|
1046
|
+
if (evalres) common_done(evalres, viewname, isWeb);
|
|
1047
|
+
} else if (res.eval_js) {
|
|
1048
|
+
handle(res.eval_js, eval);
|
|
1049
|
+
}
|
|
1020
1050
|
|
|
1021
1051
|
if (res.reload_page) {
|
|
1022
1052
|
(isWeb ? location : parent).reload(); //TODO notify to cookie if reload or goto
|
package/public/saltcorn.js
CHANGED
|
@@ -572,6 +572,20 @@ function ajax_post_btn(e, reload_on_done, reload_delay) {
|
|
|
572
572
|
|
|
573
573
|
return false;
|
|
574
574
|
}
|
|
575
|
+
|
|
576
|
+
function api_action_call(name, body) {
|
|
577
|
+
$.ajax(`/api/action/${name}`, {
|
|
578
|
+
type: "POST",
|
|
579
|
+
headers: {
|
|
580
|
+
"CSRF-Token": _sc_globalCsrf,
|
|
581
|
+
},
|
|
582
|
+
data: body,
|
|
583
|
+
success: function (res) {
|
|
584
|
+
common_done(res.data);
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
575
589
|
function make_unique_field(
|
|
576
590
|
id,
|
|
577
591
|
table_id,
|
package/routes/actions.js
CHANGED
|
@@ -390,6 +390,16 @@ router.get(
|
|
|
390
390
|
return;
|
|
391
391
|
}
|
|
392
392
|
const action = getState().actions[trigger.action];
|
|
393
|
+
// get table related to trigger
|
|
394
|
+
const table = trigger.table_id
|
|
395
|
+
? Table.findOne({ id: trigger.table_id })
|
|
396
|
+
: null;
|
|
397
|
+
|
|
398
|
+
const subtitle = span(
|
|
399
|
+
{ class: "ms-3" },
|
|
400
|
+
trigger.action,
|
|
401
|
+
table ? ` on ` + a({ href: `/table/${table.name}` }, table.name) : ""
|
|
402
|
+
);
|
|
393
403
|
if (!action) {
|
|
394
404
|
req.flash("warning", req.__("Action not found"));
|
|
395
405
|
res.redirect(`/actions/`);
|
|
@@ -420,6 +430,7 @@ router.get(
|
|
|
420
430
|
type: "card",
|
|
421
431
|
titleAjaxIndicator: true,
|
|
422
432
|
title: req.__("Configure trigger %s", trigger.name),
|
|
433
|
+
subtitle,
|
|
423
434
|
contents: {
|
|
424
435
|
widths: [8, 4],
|
|
425
436
|
besides: [
|
|
@@ -465,10 +476,6 @@ router.get(
|
|
|
465
476
|
req.flash("warning", req.__("Action not configurable"));
|
|
466
477
|
res.redirect(`/actions/`);
|
|
467
478
|
} else {
|
|
468
|
-
// get table related to trigger
|
|
469
|
-
const table = trigger.table_id
|
|
470
|
-
? Table.findOne({ id: trigger.table_id })
|
|
471
|
-
: null;
|
|
472
479
|
// get configuration fields
|
|
473
480
|
const cfgFields = await getActionConfigFields(action, table);
|
|
474
481
|
// create form
|
|
@@ -491,6 +498,7 @@ router.get(
|
|
|
491
498
|
type: "card",
|
|
492
499
|
titleAjaxIndicator: true,
|
|
493
500
|
title: req.__("Configure trigger %s", trigger.name),
|
|
501
|
+
subtitle,
|
|
494
502
|
contents: renderForm(form, req.csrfToken()),
|
|
495
503
|
},
|
|
496
504
|
});
|
|
@@ -607,8 +615,10 @@ router.get(
|
|
|
607
615
|
table = Table.findOne({ id: trigger.table_id });
|
|
608
616
|
row = await table.getRow({});
|
|
609
617
|
}
|
|
618
|
+
let runres;
|
|
619
|
+
|
|
610
620
|
try {
|
|
611
|
-
await trigger.runWithoutRow({
|
|
621
|
+
runres = await trigger.runWithoutRow({
|
|
612
622
|
console: fakeConsole,
|
|
613
623
|
table,
|
|
614
624
|
row,
|
|
@@ -627,7 +637,9 @@ router.get(
|
|
|
627
637
|
req.__(
|
|
628
638
|
"Action %s run successfully with no console output",
|
|
629
639
|
trigger.action
|
|
630
|
-
)
|
|
640
|
+
) + runres
|
|
641
|
+
? script(domReady(`common_done(${JSON.stringify(runres)})`))
|
|
642
|
+
: ""
|
|
631
643
|
);
|
|
632
644
|
res.redirect(`/actions/`);
|
|
633
645
|
} else {
|
|
@@ -641,7 +653,9 @@ router.get(
|
|
|
641
653
|
title: req.__("Test run output"),
|
|
642
654
|
contents: div(
|
|
643
655
|
div({ class: "testrunoutput" }, output),
|
|
644
|
-
|
|
656
|
+
runres
|
|
657
|
+
? script(domReady(`common_done(${JSON.stringify(runres)})`))
|
|
658
|
+
: "",
|
|
645
659
|
a(
|
|
646
660
|
{ href: `/actions`, class: "mt-4 btn btn-primary me-1" },
|
|
647
661
|
"« " + req.__("back to actions")
|
package/routes/api.js
CHANGED
|
@@ -332,7 +332,7 @@ router.get(
|
|
|
332
332
|
* @function
|
|
333
333
|
* @memberof module:routes/api~apiRouter
|
|
334
334
|
*/
|
|
335
|
-
router.
|
|
335
|
+
router.all(
|
|
336
336
|
"/action/:actionname/",
|
|
337
337
|
error_catcher(async (req, res, next) => {
|
|
338
338
|
const { actionname } = req.params;
|
|
@@ -361,7 +361,7 @@ router.post(
|
|
|
361
361
|
const resp = await action.run({
|
|
362
362
|
configuration: trigger.configuration,
|
|
363
363
|
body: req.body,
|
|
364
|
-
row: req.body,
|
|
364
|
+
row: req.method === "GET" ? req.query : req.body,
|
|
365
365
|
req,
|
|
366
366
|
user: user || req.user,
|
|
367
367
|
});
|
package/routes/common_lists.js
CHANGED
|
@@ -9,7 +9,7 @@ const {
|
|
|
9
9
|
post_dropdown_item,
|
|
10
10
|
} = require("@saltcorn/markup");
|
|
11
11
|
const { get_base_url } = require("./utils.js");
|
|
12
|
-
const { h4, p, div, a, input, text } = require("@saltcorn/markup/tags");
|
|
12
|
+
const { h4, p, div, a, i, input, text } = require("@saltcorn/markup/tags");
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* @param {string} col
|
|
@@ -385,10 +385,16 @@ const getTriggerList = (triggers, req, { tagId, domId, showList } = {}) => {
|
|
|
385
385
|
},
|
|
386
386
|
{
|
|
387
387
|
label: req.__("When"),
|
|
388
|
-
key: (
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
388
|
+
key: (act) =>
|
|
389
|
+
act.when_trigger +
|
|
390
|
+
(act.when_trigger === "API call"
|
|
391
|
+
? a(
|
|
392
|
+
{
|
|
393
|
+
href: `javascript:ajax_modal('/admin/help/API%20actions?name=${act.name}')`,
|
|
394
|
+
},
|
|
395
|
+
i({ class: "fas fa-question-circle ms-1" })
|
|
396
|
+
)
|
|
397
|
+
: ""),
|
|
392
398
|
},
|
|
393
399
|
{
|
|
394
400
|
label: req.__("Test run"),
|
package/routes/homepage.js
CHANGED
|
@@ -262,10 +262,16 @@ const actionsTab = async (req, triggers) => {
|
|
|
262
262
|
},
|
|
263
263
|
{
|
|
264
264
|
label: req.__("When"),
|
|
265
|
-
key: (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
265
|
+
key: (act) =>
|
|
266
|
+
act.when_trigger +
|
|
267
|
+
(act.when_trigger === "API call"
|
|
268
|
+
? a(
|
|
269
|
+
{
|
|
270
|
+
href: `javascript:ajax_modal('/admin/help/API%20actions?name=${act.name}')`,
|
|
271
|
+
},
|
|
272
|
+
i({ class: "fas fa-question-circle ms-1" })
|
|
273
|
+
)
|
|
274
|
+
: ""),
|
|
269
275
|
},
|
|
270
276
|
],
|
|
271
277
|
triggers
|
package/routes/page.js
CHANGED
package/routes/pageedit.js
CHANGED
|
@@ -92,6 +92,12 @@ const pagePropertiesForm = async (req, isNew) => {
|
|
|
92
92
|
input_type: "select",
|
|
93
93
|
options: roles.map((r) => ({ value: r.id, label: r.role })),
|
|
94
94
|
},
|
|
95
|
+
{
|
|
96
|
+
name: "no_menu",
|
|
97
|
+
label: req.__("No menu"),
|
|
98
|
+
sublabel: req.__("Omit the menu from this page"),
|
|
99
|
+
type: "Bool",
|
|
100
|
+
},
|
|
95
101
|
],
|
|
96
102
|
});
|
|
97
103
|
return form;
|
|
@@ -283,7 +289,7 @@ const wrap = (contents, noCard, req, page) => ({
|
|
|
283
289
|
crumbs: [
|
|
284
290
|
{ text: req.__("Pages"), href: "/pageedit" },
|
|
285
291
|
page
|
|
286
|
-
? { href: `/
|
|
292
|
+
? { href: `/page/${page.name}`, text: page.name }
|
|
287
293
|
: { text: req.__("New") },
|
|
288
294
|
],
|
|
289
295
|
},
|
|
@@ -315,6 +321,7 @@ router.get(
|
|
|
315
321
|
const form = await pagePropertiesForm(req);
|
|
316
322
|
form.hidden("id");
|
|
317
323
|
form.values = page;
|
|
324
|
+
form.values.no_menu = page.attributes?.no_menu;
|
|
318
325
|
res.sendWrap(
|
|
319
326
|
req.__(`Page attributes`),
|
|
320
327
|
wrap(renderForm(form, req.csrfToken()), false, req, page)
|
|
@@ -360,9 +367,9 @@ router.post(
|
|
|
360
367
|
wrap(renderForm(form, req.csrfToken()), false, req)
|
|
361
368
|
);
|
|
362
369
|
} else {
|
|
363
|
-
const { id, columns, ...pageRow } = form.values;
|
|
370
|
+
const { id, columns, no_menu, ...pageRow } = form.values;
|
|
364
371
|
pageRow.min_role = +pageRow.min_role;
|
|
365
|
-
|
|
372
|
+
pageRow.attributes = { no_menu };
|
|
366
373
|
if (+id) {
|
|
367
374
|
await Page.update(+id, pageRow);
|
|
368
375
|
res.redirect(`/pageedit/`);
|
package/routes/viewedit.js
CHANGED
|
@@ -638,8 +638,8 @@ router.post(
|
|
|
638
638
|
newcfg = {
|
|
639
639
|
...view.configuration,
|
|
640
640
|
[step.contextField]: {
|
|
641
|
-
...view.configuration?.[step.contextField],
|
|
642
|
-
...context,
|
|
641
|
+
...(view.configuration?.[step.contextField] || {}),
|
|
642
|
+
...(context?.[step.contextField] || {}),
|
|
643
643
|
},
|
|
644
644
|
};
|
|
645
645
|
else newcfg = { ...view.configuration, ...context };
|
package/tests/api.test.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
const request = require("supertest");
|
|
2
2
|
const getApp = require("../app");
|
|
3
3
|
const Table = require("@saltcorn/data/models/table");
|
|
4
|
+
const Trigger = require("@saltcorn/data/models/trigger");
|
|
5
|
+
|
|
6
|
+
const Field = require("@saltcorn/data/models/field");
|
|
4
7
|
const {
|
|
5
8
|
getStaffLoginCookie,
|
|
6
9
|
getAdminLoginCookie,
|
|
@@ -9,6 +12,7 @@ const {
|
|
|
9
12
|
succeedJsonWith,
|
|
10
13
|
notAuthorized,
|
|
11
14
|
toRedirect,
|
|
15
|
+
succeedJsonWithWholeBody,
|
|
12
16
|
} = require("../auth/testhelp");
|
|
13
17
|
const db = require("@saltcorn/data/db");
|
|
14
18
|
const User = require("@saltcorn/data/models/user");
|
|
@@ -322,3 +326,66 @@ describe("API authentication", () => {
|
|
|
322
326
|
.expect(succeedJsonWith((rows) => rows.length == 2));
|
|
323
327
|
});
|
|
324
328
|
});
|
|
329
|
+
|
|
330
|
+
describe("API action", () => {
|
|
331
|
+
it("should set up trigger", async () => {
|
|
332
|
+
const table = await Table.create("triggercounter");
|
|
333
|
+
await Field.create({
|
|
334
|
+
table,
|
|
335
|
+
name: "thing",
|
|
336
|
+
label: "TheThing",
|
|
337
|
+
type: "String",
|
|
338
|
+
});
|
|
339
|
+
await Trigger.create({
|
|
340
|
+
action: "run_js_code",
|
|
341
|
+
when_trigger: "API call",
|
|
342
|
+
name: "mywebhook",
|
|
343
|
+
min_role: 100,
|
|
344
|
+
configuration: {
|
|
345
|
+
code: `
|
|
346
|
+
const table = Table.findOne({ name: "triggercounter" });
|
|
347
|
+
await table.insertRow({ thing: row?.thing || "no body" });
|
|
348
|
+
return {studio: 54}
|
|
349
|
+
`,
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
it("should POST to trigger", async () => {
|
|
354
|
+
const app = await getApp({ disableCsrf: true });
|
|
355
|
+
await request(app)
|
|
356
|
+
.post("/api/action/mywebhook")
|
|
357
|
+
.send({
|
|
358
|
+
thing: "inthebody",
|
|
359
|
+
})
|
|
360
|
+
.set("Content-Type", "application/json")
|
|
361
|
+
.set("Accept", "application/json")
|
|
362
|
+
.expect(succeedJsonWithWholeBody((resp) => resp?.data?.studio === 54));
|
|
363
|
+
const table = Table.findOne({ name: "triggercounter" });
|
|
364
|
+
const counts = await table.getRows({});
|
|
365
|
+
expect(counts.map((c) => c.thing)).toContain("inthebody");
|
|
366
|
+
expect(counts.map((c) => c.thing)).not.toContain("no body");
|
|
367
|
+
});
|
|
368
|
+
it("should GET with query to trigger", async () => {
|
|
369
|
+
const app = await getApp({ disableCsrf: true });
|
|
370
|
+
await request(app)
|
|
371
|
+
.get("/api/action/mywebhook?thing=inthequery")
|
|
372
|
+
.set("Content-Type", "application/json")
|
|
373
|
+
.set("Accept", "application/json")
|
|
374
|
+
.expect(succeedJsonWithWholeBody((resp) => resp?.data?.studio === 54));
|
|
375
|
+
const table = Table.findOne({ name: "triggercounter" });
|
|
376
|
+
const counts = await table.getRows({});
|
|
377
|
+
expect(counts.map((c) => c.thing)).toContain("inthequery");
|
|
378
|
+
expect(counts.map((c) => c.thing)).not.toContain("no body");
|
|
379
|
+
});
|
|
380
|
+
it("should GET to trigger", async () => {
|
|
381
|
+
const app = await getApp({ disableCsrf: true });
|
|
382
|
+
await request(app)
|
|
383
|
+
.get("/api/action/mywebhook")
|
|
384
|
+
.set("Content-Type", "application/json")
|
|
385
|
+
.set("Accept", "application/json")
|
|
386
|
+
.expect(succeedJsonWithWholeBody((resp) => resp?.data?.studio === 54));
|
|
387
|
+
const table = Table.findOne({ name: "triggercounter" });
|
|
388
|
+
const counts = await table.getRows({});
|
|
389
|
+
expect(counts.map((c) => c.thing)).toContain("no body");
|
|
390
|
+
});
|
|
391
|
+
});
|