kempo 4.6.1 → 4.7.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 +5 -0
- package/app-public/account/index.page.html +2 -0
- package/app-public/nav.fragment.html +2 -1
- package/app-templates/emails/email.template.html +14 -0
- package/app-templates/emails/reset-password.page.html +19 -0
- package/app-templates/emails/test.page.html +21 -0
- package/app-templates/emails/verify-email.page.html +18 -0
- package/bin/cli.js +3 -0
- package/dist/admin/components/Dashboard.js +2 -2
- package/dist/admin/components/Dashboard.js.map +1 -1
- package/dist/admin/components/Menus.js +103 -0
- package/dist/admin/components/Menus.js.map +1 -0
- package/dist/admin/components/tableControls/ExtensionDetails.js +1 -1
- package/dist/admin/components/tableControls/ExtensionDetails.js.map +1 -1
- package/dist/admin/content/menus/index.page.html +11 -0
- package/dist/admin/extensions/browse/index.page.html +4 -1
- package/dist/admin/extensions/index.page.html +1 -0
- package/dist/admin/nav.fragment.html +6 -0
- package/dist/kempo/api/extensions/POST.js +1 -1
- package/dist/kempo/api/extensions/POST.js.map +1 -1
- package/dist/kempo/api/extensions/known/GET.js +1 -1
- package/dist/kempo/api/extensions/known/GET.js.map +1 -1
- package/dist/kempo/api/extensions/rating/PUT.js +1 -1
- package/dist/kempo/api/extensions/rating/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/DELETE.js +1 -1
- package/dist/kempo/api/fragments/DELETE.js.map +1 -1
- package/dist/kempo/api/fragments/GET.js +1 -1
- package/dist/kempo/api/fragments/GET.js.map +1 -1
- package/dist/kempo/api/fragments/POST.js +1 -1
- package/dist/kempo/api/fragments/POST.js.map +1 -1
- package/dist/kempo/api/fragments/disable/PUT.js +1 -1
- package/dist/kempo/api/fragments/disable/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/enable/PUT.js +1 -1
- package/dist/kempo/api/fragments/enable/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/file/GET.js +1 -1
- package/dist/kempo/api/fragments/file/GET.js.map +1 -1
- package/dist/kempo/api/fragments/file/PATCH.js +1 -1
- package/dist/kempo/api/fragments/file/PATCH.js.map +1 -1
- package/dist/kempo/api/fragments/file/PUT.js +1 -1
- package/dist/kempo/api/fragments/file/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/lock/PUT.js +1 -1
- package/dist/kempo/api/fragments/lock/PUT.js.map +1 -1
- package/dist/kempo/api/globals/DELETE.js +1 -1
- package/dist/kempo/api/globals/DELETE.js.map +1 -1
- package/dist/kempo/api/globals/GET.js +1 -1
- package/dist/kempo/api/globals/GET.js.map +1 -1
- package/dist/kempo/api/globals/POST.js +1 -1
- package/dist/kempo/api/globals/POST.js.map +1 -1
- package/dist/kempo/api/globals/disable/PUT.js +1 -1
- package/dist/kempo/api/globals/disable/PUT.js.map +1 -1
- package/dist/kempo/api/globals/enable/PUT.js +1 -1
- package/dist/kempo/api/globals/enable/PUT.js.map +1 -1
- package/dist/kempo/api/globals/entry/GET.js +1 -1
- package/dist/kempo/api/globals/entry/GET.js.map +1 -1
- package/dist/kempo/api/globals/entry/PUT.js +1 -1
- package/dist/kempo/api/globals/entry/PUT.js.map +1 -1
- package/dist/kempo/api/globals/lock/PUT.js +1 -1
- package/dist/kempo/api/globals/lock/PUT.js.map +1 -1
- package/dist/kempo/api/menus/DELETE.js +2 -0
- package/dist/kempo/api/menus/DELETE.js.map +1 -0
- package/dist/kempo/api/menus/GET.js +2 -0
- package/dist/kempo/api/menus/GET.js.map +1 -0
- package/dist/kempo/api/menus/POST.js +2 -0
- package/dist/kempo/api/menus/POST.js.map +1 -0
- package/dist/kempo/api/menus/adopt/PUT.js +2 -0
- package/dist/kempo/api/menus/adopt/PUT.js.map +1 -0
- package/dist/kempo/api/menus/items/DELETE.js +2 -0
- package/dist/kempo/api/menus/items/DELETE.js.map +1 -0
- package/dist/kempo/api/menus/items/POST.js +2 -0
- package/dist/kempo/api/menus/items/POST.js.map +1 -0
- package/dist/kempo/api/menus/items/enabled/PUT.js +2 -0
- package/dist/kempo/api/menus/items/enabled/PUT.js.map +1 -0
- package/dist/kempo/api/menus/items/order/PUT.js +2 -0
- package/dist/kempo/api/menus/items/order/PUT.js.map +1 -0
- package/dist/kempo/api/menus/location/PUT.js +2 -0
- package/dist/kempo/api/menus/location/PUT.js.map +1 -0
- package/dist/kempo/api/pages/DELETE.js +1 -1
- package/dist/kempo/api/pages/DELETE.js.map +1 -1
- package/dist/kempo/api/pages/GET.js +1 -1
- package/dist/kempo/api/pages/GET.js.map +1 -1
- package/dist/kempo/api/pages/POST.js +1 -1
- package/dist/kempo/api/pages/POST.js.map +1 -1
- package/dist/kempo/api/pages/directories/GET.js +1 -1
- package/dist/kempo/api/pages/directories/GET.js.map +1 -1
- package/dist/kempo/api/pages/disable/PUT.js +1 -1
- package/dist/kempo/api/pages/disable/PUT.js.map +1 -1
- package/dist/kempo/api/pages/enable/PUT.js +1 -1
- package/dist/kempo/api/pages/enable/PUT.js.map +1 -1
- package/dist/kempo/api/pages/file/GET.js +1 -1
- package/dist/kempo/api/pages/file/GET.js.map +1 -1
- package/dist/kempo/api/pages/file/PATCH.js +1 -1
- package/dist/kempo/api/pages/file/PATCH.js.map +1 -1
- package/dist/kempo/api/pages/file/PUT.js +1 -1
- package/dist/kempo/api/pages/file/PUT.js.map +1 -1
- package/dist/kempo/api/pages/lock/PUT.js +1 -1
- package/dist/kempo/api/pages/lock/PUT.js.map +1 -1
- package/dist/kempo/api/pages/rescan/POST.js +1 -1
- package/dist/kempo/api/pages/rescan/POST.js.map +1 -1
- package/dist/kempo/api/pages/search/GET.js +1 -1
- package/dist/kempo/api/pages/search/GET.js.map +1 -1
- package/dist/kempo/api/pages/templates/GET.js +1 -1
- package/dist/kempo/api/pages/templates/GET.js.map +1 -1
- package/dist/kempo/api/templates/DELETE.js +1 -1
- package/dist/kempo/api/templates/DELETE.js.map +1 -1
- package/dist/kempo/api/templates/GET.js +1 -1
- package/dist/kempo/api/templates/GET.js.map +1 -1
- package/dist/kempo/api/templates/POST.js +1 -1
- package/dist/kempo/api/templates/POST.js.map +1 -1
- package/dist/kempo/api/templates/disable/PUT.js +1 -1
- package/dist/kempo/api/templates/disable/PUT.js.map +1 -1
- package/dist/kempo/api/templates/enable/PUT.js +1 -1
- package/dist/kempo/api/templates/enable/PUT.js.map +1 -1
- package/dist/kempo/api/templates/file/GET.js +1 -1
- package/dist/kempo/api/templates/file/GET.js.map +1 -1
- package/dist/kempo/api/templates/file/PATCH.js +1 -1
- package/dist/kempo/api/templates/file/PATCH.js.map +1 -1
- package/dist/kempo/api/templates/file/PUT.js +1 -1
- package/dist/kempo/api/templates/file/PUT.js.map +1 -1
- package/dist/kempo/api/templates/lock/PUT.js +1 -1
- package/dist/kempo/api/templates/lock/PUT.js.map +1 -1
- package/dist/kempo/components/DashboardWidget.js +4 -4
- package/dist/kempo/components/DashboardWidget.js.map +1 -1
- package/dist/kempo/icons/campaign.svg +1 -0
- package/dist/kempo/icons/fields.svg +1 -0
- package/dist/kempo/icons/open_in_new.svg +1 -0
- package/dist/kempo/sdk.js +1 -1
- package/dist/kempo/sdk.js.map +1 -1
- package/middleware/kempo.js +48 -12
- package/package.json +2 -2
- package/server/db/schema.js +24 -0
- package/server/sdk.js +21 -0
- package/server/utils/auth/requestPasswordReset.js +9 -3
- package/server/utils/auth/verifyEmail.js +10 -1
- package/server/utils/dashboard/coreWidgets.js +1 -0
- package/server/utils/dashboard/helpers.js +6 -0
- package/server/utils/email/renderEmailTemplate.js +63 -0
- package/server/utils/email/sendEmailFromTemplate.js +9 -27
- package/server/utils/extensions/aliases.js +71 -0
- package/server/utils/extensions/checkClaims.js +32 -0
- package/server/utils/extensions/claims.js +54 -0
- package/server/utils/extensions/createTablesFromSchema.js +13 -20
- package/server/utils/extensions/enableExtensionTree.js +8 -0
- package/server/utils/extensions/{getMarketplaceClient.js → getSiteMarketplaceClient.js} +1 -1
- package/server/utils/extensions/installExtension.js +32 -0
- package/server/utils/extensions/installFromCatalog.js +1 -1
- package/server/utils/extensions/installPackage.js +1 -1
- package/server/utils/extensions/{catalogClient.js → marketplaceClient.js} +4 -1
- package/server/utils/extensions/readKempoConfig.js +2 -2
- package/server/utils/extensions/scopeCache.js +14 -1
- package/server/utils/extensions/scopes.js +72 -0
- package/server/utils/extensions/uninstallExtensionTree.js +2 -1
- package/server/utils/extensions/updateExtension.js +35 -0
- package/server/utils/global-content/createGlobalContent.js +8 -18
- package/server/utils/global-content/deleteGlobalContent.js +1 -16
- package/server/utils/global-content/disableGlobalContent.js +1 -16
- package/server/utils/global-content/enableGlobalContent.js +1 -16
- package/server/utils/global-content/helpers.js +40 -6
- package/server/utils/global-content/listGlobalContent.js +2 -2
- package/server/utils/global-content/setGlobalContentLocked.js +1 -16
- package/server/utils/global-content/updateGlobalContent.js +1 -16
- package/server/utils/menus/adoptMenuItem.js +58 -0
- package/server/utils/menus/assignMenuLocation.js +47 -0
- package/server/utils/menus/deleteMenu.js +27 -0
- package/server/utils/menus/deleteMenuItems.js +27 -0
- package/server/utils/menus/findMenuLocations.js +41 -0
- package/server/utils/menus/findNavItems.js +74 -0
- package/server/utils/menus/findPages.js +73 -0
- package/server/utils/menus/listMenus.js +103 -0
- package/server/utils/menus/markup.js +55 -0
- package/server/utils/menus/reorderMenuItems.js +36 -0
- package/server/utils/menus/saveMenu.js +54 -0
- package/server/utils/menus/saveMenuItem.js +122 -0
- package/server/utils/menus/setMenuItemEnabled.js +31 -0
- package/server/utils/menus/store.js +90 -0
- package/server/utils/scheduler/helpers.js +36 -0
- package/server/utils/scheduler/listJobs.js +11 -0
- package/server/utils/scheduler/registerJobs.js +37 -0
- package/server/utils/scheduler/runJobs.js +90 -0
- package/server/utils/scheduler/scheduler.js +37 -0
- package/server/utils/scheduler/unregisterJobs.js +16 -0
- package/server/utils/settings/ensureEncryptionKey.js +132 -0
- package/server/utils/settings/secretCrypto.js +5 -1
- package/dist/kempo/icons/create_new_folder.svg +0 -1
- package/dist/kempo/icons/credit_card.svg +0 -1
- package/dist/kempo/icons/login.svg +0 -1
- package/dist/kempo/icons/payments.svg +0 -1
- package/dist/kempo/icons/toll.svg +0 -1
- package/dist/kempo/icons/wallet.svg +0 -1
|
@@ -14,6 +14,11 @@ import createTablesFromSchema from './createTablesFromSchema.js';
|
|
|
14
14
|
import readKempoConfig from './readKempoConfig.js';
|
|
15
15
|
import { invalidateScopeCache } from './scopeCache.js';
|
|
16
16
|
import { getUnmetDependencies } from './dependencies.js';
|
|
17
|
+
import registerJobs from '../scheduler/registerJobs.js';
|
|
18
|
+
import { normalizeJobs } from '../scheduler/helpers.js';
|
|
19
|
+
import { normalizeAliases } from './aliases.js';
|
|
20
|
+
import { normalizeScopes, scopeNames } from './scopes.js';
|
|
21
|
+
import checkClaims from './checkClaims.js';
|
|
17
22
|
|
|
18
23
|
export default async ({ name }) => {
|
|
19
24
|
if(!name){
|
|
@@ -48,6 +53,22 @@ export default async ({ name }) => {
|
|
|
48
53
|
return [{ code: 409, msg: `Requires ${unmet.join(', ')} to be installed and enabled first` }, null];
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
const [jobsError] = normalizeJobs(kempoConfig.jobs);
|
|
57
|
+
if(jobsError) return [jobsError, null];
|
|
58
|
+
|
|
59
|
+
const [scopesError] = normalizeScopes(kempoConfig);
|
|
60
|
+
if(scopesError) return [scopesError, null];
|
|
61
|
+
|
|
62
|
+
const [aliasesError] = normalizeAliases(kempoConfig.aliases, scopeNames(kempoConfig));
|
|
63
|
+
if(aliasesError) return [aliasesError, null];
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
An address can belong to one extension only. Installing a second one that claims it would leave
|
|
67
|
+
it with no public pages and no error, so it is refused here and the first has to be disabled.
|
|
68
|
+
*/
|
|
69
|
+
const [claimsError] = await checkClaims({ names: [name], verb: 'install', kempo: { [name]: kempoConfig } });
|
|
70
|
+
if(claimsError) return [claimsError, null];
|
|
71
|
+
|
|
51
72
|
/*
|
|
52
73
|
Create tables from declarative schema
|
|
53
74
|
*/
|
|
@@ -131,6 +152,17 @@ export default async ({ name }) => {
|
|
|
131
152
|
}
|
|
132
153
|
}
|
|
133
154
|
|
|
155
|
+
/*
|
|
156
|
+
Register declarative scheduled jobs. Like a widget, a job that cannot be registered (a site that has
|
|
157
|
+
not applied the scheduledJob table yet) is reported and skipped rather than failing the install.
|
|
158
|
+
*/
|
|
159
|
+
if((kempoConfig.jobs || []).length){
|
|
160
|
+
const [registerError] = await registerJobs({ owner: name, jobs: kempoConfig.jobs });
|
|
161
|
+
if(registerError){
|
|
162
|
+
console.warn(`[${name}] scheduled jobs were not registered: ${registerError.msg}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
const now = new Date();
|
|
135
167
|
const entry = {
|
|
136
168
|
name,
|
|
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
|
|
|
6
6
|
import installExtension from './installExtension.js';
|
|
7
7
|
import installPackage from './installPackage.js';
|
|
8
8
|
import enableExtension from './enableExtension.js';
|
|
9
|
-
import { NAME_PATTERN } from './
|
|
9
|
+
import { NAME_PATTERN } from './marketplaceClient.js';
|
|
10
10
|
|
|
11
11
|
const isInstalled = async name => {
|
|
12
12
|
const [row] = await db.select({ name: extension.name }).from(extension).where(eq(extension.name, name)).limit(1);
|
|
@@ -181,12 +181,15 @@ export const createCatalogClient = ({ baseUrl = DEFAULT_CATALOG_URL, fetchImpl =
|
|
|
181
181
|
};
|
|
182
182
|
|
|
183
183
|
return {
|
|
184
|
-
list
|
|
184
|
+
/* `exclude` is a list of extension names to leave out, which is how a site hides what it has installed. */
|
|
185
|
+
list: ({ search, sort, limit, offset, exclude } = {}) => {
|
|
185
186
|
const query = new URLSearchParams();
|
|
186
187
|
if(search) query.set('search', String(search).slice(0, 100));
|
|
187
188
|
if(sort) query.set('sort', String(sort));
|
|
188
189
|
if(limit) query.set('limit', String(limit));
|
|
189
190
|
if(offset) query.set('offset', String(offset));
|
|
191
|
+
const excluded = (Array.isArray(exclude) ? exclude : []).filter(name => NAME_PATTERN.test(name || '')).slice(0, 500);
|
|
192
|
+
if(excluded.length) query.set('exclude', excluded.join(','));
|
|
190
193
|
return read(`/extensions${query.size ? `?${query}` : ''}`, normalizePage);
|
|
191
194
|
},
|
|
192
195
|
|
|
@@ -2,8 +2,8 @@ import { join } from 'path';
|
|
|
2
2
|
import { readFile } from 'fs/promises';
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
|
-
What an installed extension declares to kempo: its `public-scope`, `docs`, `git`, `schema`, `hooks`,
|
|
6
|
-
`permissions`, `settings`, `groups`, `dependencies` and `
|
|
5
|
+
What an installed extension declares to kempo: its `public-scope` and `public-scopes`, `docs`, `git`, `schema`, `hooks`,
|
|
6
|
+
`permissions`, `settings`, `groups`, `dependencies`, `widgets`, `jobs` and `aliases`.
|
|
7
7
|
|
|
8
8
|
It lives in the extension's kempo-config.json, which is the one place it is written. package.json's own
|
|
9
9
|
`kempo` field is still read, as the fallback for an older extension that put everything there, and
|
|
@@ -3,7 +3,20 @@ import { extension } from '../../db/schema.js';
|
|
|
3
3
|
|
|
4
4
|
let cache = null;
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const listeners = new Set();
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
For what has to follow the set of enabled extensions, such as menus holding an extension's items.
|
|
10
|
+
Called after every install, enable, disable and uninstall; a listener's failure never reaches them.
|
|
11
|
+
*/
|
|
12
|
+
export const onExtensionsChanged = listener => listeners.add(listener);
|
|
13
|
+
|
|
14
|
+
export const invalidateScopeCache = () => {
|
|
15
|
+
cache = null;
|
|
16
|
+
for(const listener of listeners){
|
|
17
|
+
Promise.resolve().then(listener).catch(error => console.error('[kempo] An extension change listener failed:', error.message));
|
|
18
|
+
}
|
|
19
|
+
};
|
|
7
20
|
|
|
8
21
|
/* One read of the extension table serves both questions, until an install/enable/disable/uninstall. */
|
|
9
22
|
const load = async () => {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The addresses an extension answers on, read from its kempo-config.json.
|
|
3
|
+
|
|
4
|
+
An extension serves a folder of its package at an address under its scope: with the scope `products`,
|
|
5
|
+
`public/index.page.html` is `/products/` and `public/api/products/GET.js` is `/products/api/products`.
|
|
6
|
+
It can claim more than one scope, each serving its own folder:
|
|
7
|
+
|
|
8
|
+
"public-scope": "products", // shorthand for "products": "public"
|
|
9
|
+
"public-scopes": { "categories": "public-categories" } // /categories/ serves public-categories/
|
|
10
|
+
|
|
11
|
+
`aliases` give pages under a scope a second, shorter address (see aliases.js). Every scope and every
|
|
12
|
+
alias address is a *claim*: no two enabled extensions may claim the same address, or one inside the
|
|
13
|
+
other, because the router can only give an address to one of them (see claims.js).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const NAME = /^[a-z0-9][a-z0-9_-]*$/;
|
|
17
|
+
|
|
18
|
+
/* What kempo itself serves at the top level of the site. */
|
|
19
|
+
export const RESERVED_SCOPES = ['admin', 'api', 'kempo', 'kempo-css', 'kempo-ui', 'monaco-editor'];
|
|
20
|
+
|
|
21
|
+
const cleanDir = dir => String(dir).replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/+$/, '');
|
|
22
|
+
|
|
23
|
+
const badDir = dir => !dir || dir.startsWith('/') || /^[a-z]:/i.test(dir) || dir.split('/').some(part => part === '..' || part === '');
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
The scopes an extension declares, each with the folder of its package it serves: [error, [{ scope, dir }]].
|
|
27
|
+
An extension with no scope gets an empty list. A bad name, a reserved one, a folder that leaves the
|
|
28
|
+
package, or the same scope pointed at two folders is an error.
|
|
29
|
+
*/
|
|
30
|
+
export const normalizeScopes = kempo => {
|
|
31
|
+
const found = new Map();
|
|
32
|
+
|
|
33
|
+
const add = (scope, folder) => {
|
|
34
|
+
if(typeof scope !== 'string' || !NAME.test(scope)){
|
|
35
|
+
return { code: 400, msg: `Scope "${scope}" must be lowercase letters, numbers, dashes or underscores, such as "products"` };
|
|
36
|
+
}
|
|
37
|
+
if(RESERVED_SCOPES.includes(scope)){
|
|
38
|
+
return { code: 400, msg: `Scope "${scope}" is used by kempo itself` };
|
|
39
|
+
}
|
|
40
|
+
const dir = cleanDir(folder);
|
|
41
|
+
if(typeof folder !== 'string' || badDir(dir)){
|
|
42
|
+
return { code: 400, msg: `The folder for scope "${scope}" must be a folder inside the package, such as "public"` };
|
|
43
|
+
}
|
|
44
|
+
if(found.has(scope) && found.get(scope) !== dir){
|
|
45
|
+
return { code: 400, msg: `Scope "${scope}" is given two different folders` };
|
|
46
|
+
}
|
|
47
|
+
found.set(scope, dir);
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const single = kempo?.['public-scope'];
|
|
52
|
+
if(single !== undefined && single !== null && single !== ''){
|
|
53
|
+
const error = add(single, 'public');
|
|
54
|
+
if(error) return [error, null];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const several = kempo?.['public-scopes'];
|
|
58
|
+
if(several !== undefined && several !== null){
|
|
59
|
+
if(typeof several !== 'object' || Array.isArray(several)){
|
|
60
|
+
return [{ code: 400, msg: 'public-scopes must be an object of scope to folder' }, null];
|
|
61
|
+
}
|
|
62
|
+
for(const [scope, folder] of Object.entries(several)){
|
|
63
|
+
const error = add(scope, folder);
|
|
64
|
+
if(error) return [error, null];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return [null, [...found].map(([scope, dir]) => ({ scope, dir }))];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/* Just the scope names, for what only needs to know them (an alias must point inside one). */
|
|
72
|
+
export const scopeNames = kempo => (normalizeScopes(kempo)[1] ?? []).map(({ scope }) => scope);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import db from '../../db/index.js';
|
|
2
|
-
import { extension, hook, permission, setting, group, groupPermission, userGroup, dashboardWidget } from '../../db/schema.js';
|
|
2
|
+
import { extension, hook, permission, setting, group, groupPermission, userGroup, dashboardWidget, scheduledJob } from '../../db/schema.js';
|
|
3
3
|
import { eq, inArray, like } from 'drizzle-orm';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { pathToFileURL } from 'url';
|
|
@@ -105,6 +105,7 @@ const uninstallOne = async ({ name, purgeData = false }) => {
|
|
|
105
105
|
try {
|
|
106
106
|
await db.delete(hook).where(eq(hook.owner, name));
|
|
107
107
|
await db.delete(dashboardWidget).where(eq(dashboardWidget.owner, name));
|
|
108
|
+
await db.delete(scheduledJob).where(eq(scheduledJob.owner, name)).catch(() => {});
|
|
108
109
|
await db.delete(extension).where(eq(extension.name, name));
|
|
109
110
|
} catch(error) {
|
|
110
111
|
return [{ code: 500, msg: 'Failed to remove extension' }, null];
|
|
@@ -9,10 +9,14 @@ import triggerHook, { clearHandlerCache } from '../hooks/triggerHook.js';
|
|
|
9
9
|
import createHook from '../hooks/createHook.js';
|
|
10
10
|
import registerDashboardWidget from '../dashboard/registerDashboardWidget.js';
|
|
11
11
|
import unregisterDashboardWidget from '../dashboard/unregisterDashboardWidget.js';
|
|
12
|
+
import registerJobs from '../scheduler/registerJobs.js';
|
|
12
13
|
import setSetting from '../settings/setSetting.js';
|
|
13
14
|
import declaredValue from '../settings/declaredValue.js';
|
|
14
15
|
import createTablesFromSchema from './createTablesFromSchema.js';
|
|
15
16
|
import readKempoConfig from './readKempoConfig.js';
|
|
17
|
+
import { normalizeAliases } from './aliases.js';
|
|
18
|
+
import { normalizeScopes, scopeNames } from './scopes.js';
|
|
19
|
+
import checkClaims from './checkClaims.js';
|
|
16
20
|
|
|
17
21
|
const requireFromProject = createRequire(join(process.cwd(), 'package.json'));
|
|
18
22
|
|
|
@@ -56,6 +60,27 @@ export default async ({ name, version = 'latest' }) => {
|
|
|
56
60
|
return [{ code: 404, msg: `Updated package "${name}" not found` }, null];
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/*
|
|
64
|
+
The new version may claim addresses the old one did not. If it is on and another extension has one
|
|
65
|
+
of them, put the old version back before anything else changes: leaving the new one would break
|
|
66
|
+
one of the two, silently.
|
|
67
|
+
*/
|
|
68
|
+
const [scopesError] = normalizeScopes(newKempo);
|
|
69
|
+
const [aliasesError] = scopesError ? [null] : normalizeAliases(newKempo.aliases, scopeNames(newKempo));
|
|
70
|
+
const [claimsError] = scopesError || aliasesError || !oldRecord.enabled
|
|
71
|
+
? [null]
|
|
72
|
+
: await checkClaims({ names: [name], verb: 'update', kempo: { [name]: newKempo } });
|
|
73
|
+
const refusal = scopesError || aliasesError || claimsError;
|
|
74
|
+
if(refusal){
|
|
75
|
+
let msg = refusal.msg;
|
|
76
|
+
try {
|
|
77
|
+
if(oldVersion) execSync(`npm install ${name}@${oldVersion}`, { cwd: process.cwd(), stdio: 'pipe' });
|
|
78
|
+
} catch {
|
|
79
|
+
msg += ` The previous version could not be put back automatically: run npm install ${name}@${oldVersion}.`;
|
|
80
|
+
}
|
|
81
|
+
return [{ ...refusal, msg }, null];
|
|
82
|
+
}
|
|
83
|
+
|
|
59
84
|
/*
|
|
60
85
|
Create any new tables from schema
|
|
61
86
|
*/
|
|
@@ -161,6 +186,16 @@ export default async ({ name, version = 'latest' }) => {
|
|
|
161
186
|
}
|
|
162
187
|
}
|
|
163
188
|
|
|
189
|
+
/*
|
|
190
|
+
Sync scheduled jobs: the schedule becomes exactly what is declared now
|
|
191
|
+
*/
|
|
192
|
+
if((newKempo.jobs || []).length || (oldKempo.jobs || []).length){
|
|
193
|
+
const [jobsError] = await registerJobs({ owner: name, jobs: newKempo.jobs || [] });
|
|
194
|
+
if(jobsError){
|
|
195
|
+
console.warn(`[${name}] scheduled jobs were not registered: ${jobsError.msg}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
164
199
|
const now = new Date();
|
|
165
200
|
try {
|
|
166
201
|
await db.update(extension).set({
|
|
@@ -2,25 +2,10 @@ import { readFile, writeFile } from 'fs/promises';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import crypto from 'crypto';
|
|
5
|
-
import { GLOBAL_FILE, parseEntries } from './helpers.js';
|
|
5
|
+
import { GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
|
|
6
6
|
import triggerHook from '../hooks/triggerHook.js';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
const attrs = [
|
|
10
|
-
`id="${e.id}"`,
|
|
11
|
-
`name="${e.name}"`,
|
|
12
|
-
`owner="${e.owner}"`,
|
|
13
|
-
`author="${e.author}"`,
|
|
14
|
-
`created="${e.createdAt}"`,
|
|
15
|
-
`updated="${e.updatedAt}"`,
|
|
16
|
-
`locked="${e.locked}"`,
|
|
17
|
-
`location="${e.location}"`,
|
|
18
|
-
`priority="${e.priority}"`
|
|
19
|
-
].join(' ');
|
|
20
|
-
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
21
|
-
}).join('\n');
|
|
22
|
-
|
|
23
|
-
export default async ({ rootDir, name, location, priority, author, owner = 'custom' }) => {
|
|
8
|
+
export default async ({ rootDir, name, location, priority, author, owner = 'custom', markup = '' }) => {
|
|
24
9
|
if(!rootDir){
|
|
25
10
|
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
26
11
|
}
|
|
@@ -45,11 +30,16 @@ export default async ({ rootDir, name, location, priority, author, owner = 'cust
|
|
|
45
30
|
owner,
|
|
46
31
|
author: author || '',
|
|
47
32
|
locked: false,
|
|
33
|
+
menu: '',
|
|
34
|
+
menuLocations: null,
|
|
35
|
+
menuCopyOf: '',
|
|
36
|
+
navItem: '',
|
|
37
|
+
extra: {},
|
|
48
38
|
location,
|
|
49
39
|
priority: parseInt(priority || '0', 10),
|
|
50
40
|
createdAt: now,
|
|
51
41
|
updatedAt: now,
|
|
52
|
-
markup
|
|
42
|
+
markup
|
|
53
43
|
};
|
|
54
44
|
|
|
55
45
|
entries.push(entry);
|
|
@@ -1,24 +1,9 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
|
-
import { GLOBAL_FILE, parseEntries } from './helpers.js';
|
|
4
|
+
import { GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
|
|
5
5
|
import triggerHook from '../hooks/triggerHook.js';
|
|
6
6
|
|
|
7
|
-
const serializeEntries = entries => entries.map(e => {
|
|
8
|
-
const attrs = [
|
|
9
|
-
`id="${e.id}"`,
|
|
10
|
-
`name="${e.name}"`,
|
|
11
|
-
`owner="${e.owner}"`,
|
|
12
|
-
`author="${e.author}"`,
|
|
13
|
-
`created="${e.createdAt}"`,
|
|
14
|
-
`updated="${e.updatedAt}"`,
|
|
15
|
-
`locked="${e.locked}"`,
|
|
16
|
-
`location="${e.location}"`,
|
|
17
|
-
`priority="${e.priority}"`
|
|
18
|
-
].join(' ');
|
|
19
|
-
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
20
|
-
}).join('\n');
|
|
21
|
-
|
|
22
7
|
export default async ({ rootDir, ids }) => {
|
|
23
8
|
if(!rootDir){
|
|
24
9
|
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
@@ -1,22 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
|
-
import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries } from './helpers.js';
|
|
5
|
-
|
|
6
|
-
const serializeEntries = entries => entries.map(e => {
|
|
7
|
-
const attrs = [
|
|
8
|
-
`id="${e.id}"`,
|
|
9
|
-
`name="${e.name}"`,
|
|
10
|
-
`owner="${e.owner}"`,
|
|
11
|
-
`author="${e.author}"`,
|
|
12
|
-
`created="${e.createdAt}"`,
|
|
13
|
-
`updated="${e.updatedAt}"`,
|
|
14
|
-
`locked="${e.locked}"`,
|
|
15
|
-
`location="${e.location}"`,
|
|
16
|
-
`priority="${e.priority}"`
|
|
17
|
-
].join(' ');
|
|
18
|
-
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
19
|
-
}).join('\n');
|
|
4
|
+
import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries, serializeEntries } from './helpers.js';
|
|
20
5
|
|
|
21
6
|
export default async ({ rootDir, id }) => {
|
|
22
7
|
if(!rootDir){
|
|
@@ -1,22 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
|
-
import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries } from './helpers.js';
|
|
5
|
-
|
|
6
|
-
const serializeEntries = entries => entries.map(e => {
|
|
7
|
-
const attrs = [
|
|
8
|
-
`id="${e.id}"`,
|
|
9
|
-
`name="${e.name}"`,
|
|
10
|
-
`owner="${e.owner}"`,
|
|
11
|
-
`author="${e.author}"`,
|
|
12
|
-
`created="${e.createdAt}"`,
|
|
13
|
-
`updated="${e.updatedAt}"`,
|
|
14
|
-
`locked="${e.locked}"`,
|
|
15
|
-
`location="${e.location}"`,
|
|
16
|
-
`priority="${e.priority}"`
|
|
17
|
-
].join(' ');
|
|
18
|
-
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
19
|
-
}).join('\n');
|
|
4
|
+
import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries, serializeEntries } from './helpers.js';
|
|
20
5
|
|
|
21
6
|
export default async ({ rootDir, id }) => {
|
|
22
7
|
if(!rootDir){
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import { readFile } from 'fs/promises';
|
|
2
|
-
import { join } from 'path';
|
|
3
|
-
import { existsSync } from 'fs';
|
|
4
|
-
|
|
5
1
|
const GLOBAL_FILE = 'kempo-global.global.html';
|
|
6
2
|
const GLOBAL_DISABLED_FILE = 'kempo-global.global-disabled.html';
|
|
7
3
|
|
|
4
|
+
/*
|
|
5
|
+
A quote or angle bracket inside an attribute would end the tag early, so they are written as
|
|
6
|
+
entities and read back.
|
|
7
|
+
*/
|
|
8
|
+
const escapeAttr = value => String(value ?? '').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
9
|
+
const unescapeAttr = value => value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
10
|
+
|
|
11
|
+
const KNOWN_ATTRIBUTES = ['id', 'name', 'owner', 'author', 'locked', 'location', 'priority', 'created', 'updated', 'menu', 'menu-locations', 'menu-copy', 'menu-nav-item'];
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
Attributes this code does not know about are kept in `extra` and written back unchanged, so a
|
|
15
|
+
marker added by hand or by a newer version survives a save instead of being silently dropped.
|
|
16
|
+
*/
|
|
8
17
|
const parseEntries = raw => {
|
|
9
18
|
const entries = [];
|
|
10
19
|
const regex = /<content\s([^>]*)>([\s\S]*?)<\/content>/g;
|
|
@@ -13,21 +22,46 @@ const parseEntries = raw => {
|
|
|
13
22
|
const attrs = {};
|
|
14
23
|
const attrRegex = /(\w[\w-]*)="([^"]*)"/g;
|
|
15
24
|
let a;
|
|
16
|
-
while((a = attrRegex.exec(match[1])) !== null) attrs[a[1]] = a[2];
|
|
25
|
+
while((a = attrRegex.exec(match[1])) !== null) attrs[a[1]] = unescapeAttr(a[2]);
|
|
17
26
|
entries.push({
|
|
18
27
|
id: attrs.id || '',
|
|
19
28
|
name: attrs.name || '',
|
|
20
29
|
owner: attrs.owner || 'custom',
|
|
21
30
|
author: attrs.author || '',
|
|
22
31
|
locked: attrs.locked === 'true',
|
|
32
|
+
menu: attrs.menu || '',
|
|
33
|
+
menuLocations: attrs['menu-locations'] === undefined ? null : attrs['menu-locations'].split(',').map(l => l.trim()).filter(Boolean),
|
|
34
|
+
menuCopyOf: attrs['menu-copy'] || '',
|
|
35
|
+
navItem: attrs['menu-nav-item'] || '',
|
|
23
36
|
location: attrs.location || 'default',
|
|
24
37
|
priority: parseInt(attrs.priority || '0', 10),
|
|
25
38
|
createdAt: attrs.created || '',
|
|
26
39
|
updatedAt: attrs.updated || '',
|
|
40
|
+
extra: Object.fromEntries(Object.entries(attrs).filter(([key]) => !KNOWN_ATTRIBUTES.includes(key))),
|
|
27
41
|
markup: match[2].trim()
|
|
28
42
|
});
|
|
29
43
|
}
|
|
30
44
|
return entries;
|
|
31
45
|
};
|
|
32
46
|
|
|
33
|
-
|
|
47
|
+
const serializeEntries = entries => entries.map(e => {
|
|
48
|
+
const attrs = [
|
|
49
|
+
`id="${e.id}"`,
|
|
50
|
+
`name="${escapeAttr(e.name)}"`,
|
|
51
|
+
`owner="${e.owner}"`,
|
|
52
|
+
`author="${escapeAttr(e.author)}"`,
|
|
53
|
+
`created="${e.createdAt}"`,
|
|
54
|
+
`updated="${e.updatedAt}"`,
|
|
55
|
+
`locked="${e.locked}"`,
|
|
56
|
+
`location="${e.location}"`,
|
|
57
|
+
`priority="${e.priority}"`,
|
|
58
|
+
...(e.menu ? [`menu="${e.menu}"`] : []),
|
|
59
|
+
...(e.menuLocations ? [`menu-locations="${e.menuLocations.join(',')}"`] : []),
|
|
60
|
+
...(e.menuCopyOf ? [`menu-copy="${e.menuCopyOf}"`] : []),
|
|
61
|
+
...(e.navItem ? [`menu-nav-item="${e.navItem}"`] : []),
|
|
62
|
+
...Object.entries(e.extra || {}).map(([key, value]) => `${key}="${escapeAttr(value)}"`)
|
|
63
|
+
].join(' ');
|
|
64
|
+
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
65
|
+
}).join('\n');
|
|
66
|
+
|
|
67
|
+
export { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries, serializeEntries };
|
|
@@ -3,7 +3,7 @@ import { join } from 'path';
|
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries } from './helpers.js';
|
|
5
5
|
|
|
6
|
-
export default async ({ rootDir }) => {
|
|
6
|
+
export default async ({ rootDir, includeMenu = true }) => {
|
|
7
7
|
if(!rootDir){
|
|
8
8
|
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
9
9
|
}
|
|
@@ -19,6 +19,6 @@ export default async ({ rootDir }) => {
|
|
|
19
19
|
? parseEntries(await readFile(disabledPath, 'utf-8')).map(e => ({ ...e, disabled: true }))
|
|
20
20
|
: [];
|
|
21
21
|
|
|
22
|
-
const entries = [...activeEntries, ...disabledEntries];
|
|
22
|
+
const entries = [...activeEntries, ...disabledEntries].filter(e => includeMenu || !e.menu);
|
|
23
23
|
return [null, { entries, total: entries.length }];
|
|
24
24
|
};
|
|
@@ -1,22 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
|
-
import { GLOBAL_FILE, parseEntries } from './helpers.js';
|
|
5
|
-
|
|
6
|
-
const serializeEntries = entries => entries.map(e => {
|
|
7
|
-
const attrs = [
|
|
8
|
-
`id="${e.id}"`,
|
|
9
|
-
`name="${e.name}"`,
|
|
10
|
-
`owner="${e.owner}"`,
|
|
11
|
-
`author="${e.author}"`,
|
|
12
|
-
`created="${e.createdAt}"`,
|
|
13
|
-
`updated="${e.updatedAt}"`,
|
|
14
|
-
`locked="${e.locked}"`,
|
|
15
|
-
`location="${e.location}"`,
|
|
16
|
-
`priority="${e.priority}"`
|
|
17
|
-
].join(' ');
|
|
18
|
-
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
19
|
-
}).join('\n');
|
|
4
|
+
import { GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
|
|
20
5
|
|
|
21
6
|
export default async ({ rootDir, id, locked }) => {
|
|
22
7
|
if(!rootDir) return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
@@ -1,24 +1,9 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
|
-
import { GLOBAL_FILE, parseEntries } from './helpers.js';
|
|
4
|
+
import { GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
|
|
5
5
|
import triggerHook from '../hooks/triggerHook.js';
|
|
6
6
|
|
|
7
|
-
const serializeEntries = entries => entries.map(e => {
|
|
8
|
-
const attrs = [
|
|
9
|
-
`id="${e.id}"`,
|
|
10
|
-
`name="${e.name}"`,
|
|
11
|
-
`owner="${e.owner}"`,
|
|
12
|
-
`author="${e.author}"`,
|
|
13
|
-
`created="${e.createdAt}"`,
|
|
14
|
-
`updated="${e.updatedAt}"`,
|
|
15
|
-
`locked="${e.locked}"`,
|
|
16
|
-
`location="${e.location}"`,
|
|
17
|
-
`priority="${e.priority}"`
|
|
18
|
-
].join(' ');
|
|
19
|
-
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
20
|
-
}).join('\n');
|
|
21
|
-
|
|
22
7
|
export default async ({ rootDir, id, name, location, priority, markup }) => {
|
|
23
8
|
if(!rootDir){
|
|
24
9
|
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readStores, writeStores, menuHome, isMenuDefinition } from './store.js';
|
|
2
|
+
import { parseLink } from './markup.js';
|
|
3
|
+
import saveMenu from './saveMenu.js';
|
|
4
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Moves an existing single link into a menu so it is managed on the Menus page. Only a single link
|
|
8
|
+
qualifies: anything else would lose what it is made of when the page rewrites it.
|
|
9
|
+
|
|
10
|
+
Without a `menuId` it goes into the menu already shown in the link's location; if there is none, a
|
|
11
|
+
menu named `menuName` is made and shown there, so the link stays exactly where it was on the site.
|
|
12
|
+
*/
|
|
13
|
+
export default async ({ rootDir, id, menuId, menuName, extensionDirs }) => {
|
|
14
|
+
if(!rootDir){
|
|
15
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
16
|
+
}
|
|
17
|
+
if(!id){
|
|
18
|
+
return [{ code: 400, msg: 'Global content ID is required' }, null];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let stores = await readStores(rootDir);
|
|
22
|
+
const entry = stores.active.find(e => e.id === id && !e.menu);
|
|
23
|
+
if(!entry){
|
|
24
|
+
return [{ code: 404, msg: 'Global content not found' }, null];
|
|
25
|
+
}
|
|
26
|
+
if(entry.owner !== 'custom'){
|
|
27
|
+
return [{ code: 403, msg: 'This belongs to an extension' }, null];
|
|
28
|
+
}
|
|
29
|
+
if(!parseLink(entry.markup)){
|
|
30
|
+
return [{ code: 400, msg: 'Only a single link can be managed as a menu item' }, null];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let target = menuId
|
|
34
|
+
? stores.active.find(e => e.id === menuId && isMenuDefinition(e))
|
|
35
|
+
: stores.active.find(e => isMenuDefinition(e) && e.menuLocations.includes(entry.location));
|
|
36
|
+
if(menuId && !target){
|
|
37
|
+
return [{ code: 404, msg: 'Menu not found' }, null];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if(!target){
|
|
41
|
+
const [error, created] = await saveMenu({ rootDir, name: menuName || entry.location, extensionDirs });
|
|
42
|
+
if(error){
|
|
43
|
+
return [error, null];
|
|
44
|
+
}
|
|
45
|
+
stores = await readStores(rootDir);
|
|
46
|
+
target = stores.active.find(e => e.id === created.id);
|
|
47
|
+
target.menuLocations = [entry.location];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const live = stores.active.find(e => e.id === id);
|
|
51
|
+
live.menu = target.id;
|
|
52
|
+
live.location = menuHome(target.id);
|
|
53
|
+
live.updatedAt = new Date().toISOString();
|
|
54
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
55
|
+
await triggerHook('globalContent:updated', { id, updatedAt: live.updatedAt });
|
|
56
|
+
|
|
57
|
+
return [null, { id, menuId: target.id }];
|
|
58
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readStores, writeStores, isMenuDefinition } from './store.js';
|
|
2
|
+
import findMenuLocations from './findMenuLocations.js';
|
|
3
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Chooses the menu shown in a location, or none when `menuId` is empty. A location shows one menu, so
|
|
7
|
+
choosing one takes the location from whichever menu had it; a menu can be chosen for several.
|
|
8
|
+
Only a place the theme marks as a menu can be newly assigned, so a typo cannot silently show nothing.
|
|
9
|
+
*/
|
|
10
|
+
export default async ({ rootDir, location, menuId, extensionDirs }) => {
|
|
11
|
+
if(!rootDir){
|
|
12
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
13
|
+
}
|
|
14
|
+
if(!location){
|
|
15
|
+
return [{ code: 400, msg: 'Location is required' }, null];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const stores = await readStores(rootDir);
|
|
19
|
+
const definitions = stores.active.filter(isMenuDefinition);
|
|
20
|
+
const target = menuId ? definitions.find(d => d.id === menuId) : null;
|
|
21
|
+
if(menuId && !target){
|
|
22
|
+
return [{ code: 404, msg: 'Menu not found' }, null];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if(target){
|
|
26
|
+
const [, declared] = await findMenuLocations({ rootDir });
|
|
27
|
+
if(!target.menuLocations.includes(location) && !declared.some(l => l.location === location)){
|
|
28
|
+
return [{ code: 400, msg: 'That place is not a menu in the theme' }, null];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const now = new Date().toISOString();
|
|
33
|
+
for(const definition of definitions){
|
|
34
|
+
if(!definition.menuLocations.includes(location)) continue;
|
|
35
|
+
definition.menuLocations = definition.menuLocations.filter(l => l !== location);
|
|
36
|
+
definition.updatedAt = now;
|
|
37
|
+
}
|
|
38
|
+
if(target){
|
|
39
|
+
target.menuLocations = [...target.menuLocations, location];
|
|
40
|
+
target.updatedAt = now;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
44
|
+
await triggerHook('globalContent:updated', { id: target?.id ?? '', updatedAt: now });
|
|
45
|
+
|
|
46
|
+
return [null, { location, menuId: target?.id ?? null }];
|
|
47
|
+
};
|