diff options
author | Markus Hatvan <markus_hatvan@aon.at> | 2021-11-18 17:37:45 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-11-18 22:07:45 +0530 |
commit | b37a6b07b39c8c7827052dc6fb97f490f1e0f514 (patch) | |
tree | 0276e7c51f5ebfa14c566def7aac39f014c2291d /src/api/server/ServerApi.ts | |
parent | Update github issues template [skip ci] (diff) | |
download | ferdium-app-b37a6b07b39c8c7827052dc6fb97f490f1e0f514.tar.gz ferdium-app-b37a6b07b39c8c7827052dc6fb97f490f1e0f514.tar.zst ferdium-app-b37a6b07b39c8c7827052dc6fb97f490f1e0f514.zip |
chore: convert various files to TS (#2246)
* convert various files to TS
* removed outdated docs/example-feature folder
* turn off unicorn/no-empty-file
* update eslint config
Diffstat (limited to 'src/api/server/ServerApi.ts')
-rw-r--r-- | src/api/server/ServerApi.ts | 616 |
1 files changed, 616 insertions, 0 deletions
diff --git a/src/api/server/ServerApi.ts b/src/api/server/ServerApi.ts new file mode 100644 index 000000000..2fd1a8d0d --- /dev/null +++ b/src/api/server/ServerApi.ts | |||
@@ -0,0 +1,616 @@ | |||
1 | /* eslint-disable import/no-import-module-exports */ | ||
2 | /* eslint-disable global-require */ | ||
3 | import { join } from 'path'; | ||
4 | import tar from 'tar'; | ||
5 | import { | ||
6 | readdirSync, | ||
7 | statSync, | ||
8 | writeFileSync, | ||
9 | copySync, | ||
10 | ensureDirSync, | ||
11 | pathExistsSync, | ||
12 | readJsonSync, | ||
13 | removeSync, | ||
14 | PathOrFileDescriptor, | ||
15 | } from 'fs-extra'; | ||
16 | import fetch from 'electron-fetch'; | ||
17 | |||
18 | import ServiceModel from '../../models/Service'; | ||
19 | import RecipePreviewModel from '../../models/RecipePreview'; | ||
20 | import RecipeModel from '../../models/Recipe'; | ||
21 | import UserModel from '../../models/User'; | ||
22 | |||
23 | import { sleep } from '../../helpers/async-helpers'; | ||
24 | |||
25 | import { SERVER_NOT_LOADED } from '../../config'; | ||
26 | import { userDataRecipesPath, userDataPath } from '../../environment-remote'; | ||
27 | import { asarRecipesPath } from '../../helpers/asar-helpers'; | ||
28 | import apiBase from '../apiBase'; | ||
29 | import { prepareAuthRequest, sendAuthRequest } from '../utils/auth'; | ||
30 | |||
31 | import { | ||
32 | getRecipeDirectory, | ||
33 | getDevRecipeDirectory, | ||
34 | loadRecipeConfig, | ||
35 | } from '../../helpers/recipe-helpers'; | ||
36 | |||
37 | import { removeServicePartitionDirectory } from '../../helpers/service-helpers'; | ||
38 | |||
39 | const debug = require('debug')('Ferdi:ServerApi'); | ||
40 | |||
41 | module.paths.unshift(getDevRecipeDirectory(), getRecipeDirectory()); | ||
42 | |||
43 | export default class ServerApi { | ||
44 | recipePreviews: any[] = []; | ||
45 | |||
46 | recipes: any[] = []; | ||
47 | |||
48 | // User | ||
49 | async login(email: string, passwordHash: string) { | ||
50 | const request = await sendAuthRequest( | ||
51 | `${apiBase()}/auth/login`, | ||
52 | { | ||
53 | method: 'POST', | ||
54 | headers: { | ||
55 | Authorization: `Basic ${window.btoa(`${email}:${passwordHash}`)}`, | ||
56 | }, | ||
57 | }, | ||
58 | false, | ||
59 | ); | ||
60 | if (!request.ok) { | ||
61 | throw new Error(request.statusText); | ||
62 | } | ||
63 | const u = await request.json(); | ||
64 | |||
65 | debug('ServerApi::login resolves', u); | ||
66 | return u.token; | ||
67 | } | ||
68 | |||
69 | async signup(data: any) { | ||
70 | const request = await sendAuthRequest( | ||
71 | `${apiBase()}/auth/signup`, | ||
72 | { | ||
73 | method: 'POST', | ||
74 | body: JSON.stringify(data), | ||
75 | }, | ||
76 | false, | ||
77 | ); | ||
78 | if (!request.ok) { | ||
79 | throw new Error(request.statusText); | ||
80 | } | ||
81 | const u = await request.json(); | ||
82 | |||
83 | debug('ServerApi::signup resolves', u); | ||
84 | return u.token; | ||
85 | } | ||
86 | |||
87 | async inviteUser(data: any) { | ||
88 | const request = await sendAuthRequest(`${apiBase()}/invite`, { | ||
89 | method: 'POST', | ||
90 | body: JSON.stringify(data), | ||
91 | }); | ||
92 | if (!request.ok) { | ||
93 | throw new Error(request.statusText); | ||
94 | } | ||
95 | |||
96 | debug('ServerApi::inviteUser'); | ||
97 | return true; | ||
98 | } | ||
99 | |||
100 | async retrievePassword(email: string) { | ||
101 | const request = await sendAuthRequest( | ||
102 | `${apiBase()}/auth/password`, | ||
103 | { | ||
104 | method: 'POST', | ||
105 | body: JSON.stringify({ | ||
106 | email, | ||
107 | }), | ||
108 | }, | ||
109 | false, | ||
110 | ); | ||
111 | if (!request.ok) { | ||
112 | throw new Error(request.statusText); | ||
113 | } | ||
114 | const r = await request.json(); | ||
115 | |||
116 | debug('ServerApi::retrievePassword'); | ||
117 | return r; | ||
118 | } | ||
119 | |||
120 | async userInfo() { | ||
121 | if (apiBase() === SERVER_NOT_LOADED) { | ||
122 | throw new Error('Server not loaded'); | ||
123 | } | ||
124 | |||
125 | const request = await sendAuthRequest(`${apiBase()}/me`); | ||
126 | if (!request.ok) { | ||
127 | throw new Error(request.statusText); | ||
128 | } | ||
129 | const data = await request.json(); | ||
130 | |||
131 | const user = new UserModel(data); | ||
132 | debug('ServerApi::userInfo resolves', user); | ||
133 | |||
134 | return user; | ||
135 | } | ||
136 | |||
137 | async updateUserInfo(data: any) { | ||
138 | const request = await sendAuthRequest(`${apiBase()}/me`, { | ||
139 | method: 'PUT', | ||
140 | body: JSON.stringify(data), | ||
141 | }); | ||
142 | if (!request.ok) { | ||
143 | throw new Error(request.statusText); | ||
144 | } | ||
145 | const updatedData = await request.json(); | ||
146 | |||
147 | const user = Object.assign(updatedData, { | ||
148 | data: new UserModel(updatedData.data), | ||
149 | }); | ||
150 | debug('ServerApi::updateUserInfo resolves', user); | ||
151 | return user; | ||
152 | } | ||
153 | |||
154 | async deleteAccount() { | ||
155 | const request = await sendAuthRequest(`${apiBase()}/me`, { | ||
156 | method: 'DELETE', | ||
157 | }); | ||
158 | if (!request.ok) { | ||
159 | throw new Error(request.statusText); | ||
160 | } | ||
161 | const data = await request.json(); | ||
162 | |||
163 | debug('ServerApi::deleteAccount resolves', data); | ||
164 | return data; | ||
165 | } | ||
166 | |||
167 | // Services | ||
168 | async getServices() { | ||
169 | if (apiBase() === SERVER_NOT_LOADED) { | ||
170 | throw new Error('Server not loaded'); | ||
171 | } | ||
172 | |||
173 | const request = await sendAuthRequest(`${apiBase()}/me/services`); | ||
174 | if (!request.ok) { | ||
175 | throw new Error(request.statusText); | ||
176 | } | ||
177 | const data = await request.json(); | ||
178 | |||
179 | const services = await this._mapServiceModels(data); | ||
180 | const filteredServices = services.filter(service => !!service); | ||
181 | debug('ServerApi::getServices resolves', filteredServices); | ||
182 | return filteredServices; | ||
183 | } | ||
184 | |||
185 | async createService(recipeId: string, data: { iconFile: any }) { | ||
186 | const request = await sendAuthRequest(`${apiBase()}/service`, { | ||
187 | method: 'POST', | ||
188 | body: JSON.stringify({ recipeId, ...data }), | ||
189 | }); | ||
190 | if (!request.ok) { | ||
191 | throw new Error(request.statusText); | ||
192 | } | ||
193 | const serviceData = await request.json(); | ||
194 | |||
195 | if (data.iconFile) { | ||
196 | const iconData = await this.uploadServiceIcon( | ||
197 | serviceData.data.id, | ||
198 | data.iconFile, | ||
199 | ); | ||
200 | |||
201 | serviceData.data = iconData; | ||
202 | } | ||
203 | |||
204 | const service = Object.assign(serviceData, { | ||
205 | data: await this._prepareServiceModel(serviceData.data), | ||
206 | }); | ||
207 | |||
208 | debug('ServerApi::createService resolves', service); | ||
209 | return service; | ||
210 | } | ||
211 | |||
212 | async updateService(serviceId: string, rawData: any) { | ||
213 | const data = rawData; | ||
214 | |||
215 | if (data.iconFile) { | ||
216 | await this.uploadServiceIcon(serviceId, data.iconFile); | ||
217 | } | ||
218 | |||
219 | const request = await sendAuthRequest(`${apiBase()}/service/${serviceId}`, { | ||
220 | method: 'PUT', | ||
221 | body: JSON.stringify(data), | ||
222 | }); | ||
223 | |||
224 | if (!request.ok) { | ||
225 | throw new Error(request.statusText); | ||
226 | } | ||
227 | |||
228 | const serviceData = await request.json(); | ||
229 | |||
230 | const service = Object.assign(serviceData, { | ||
231 | data: await this._prepareServiceModel(serviceData.data), | ||
232 | }); | ||
233 | |||
234 | debug('ServerApi::updateService resolves', service); | ||
235 | return service; | ||
236 | } | ||
237 | |||
238 | async uploadServiceIcon(serviceId: string, icon: string | Blob) { | ||
239 | const formData = new FormData(); | ||
240 | formData.append('icon', icon); | ||
241 | |||
242 | const requestData = prepareAuthRequest({ | ||
243 | method: 'PUT', | ||
244 | // @ts-expect-error Argument of type '{ method: string; body: FormData; }' is not assignable to parameter of type '{ method: string; }'. | ||
245 | body: formData, | ||
246 | }); | ||
247 | |||
248 | delete requestData.headers['Content-Type']; | ||
249 | |||
250 | const request = await window.fetch( | ||
251 | `${apiBase()}/service/${serviceId}`, | ||
252 | // @ts-expect-error Argument of type '{ method: string; } & { mode: string; headers: any; }' is not assignable to parameter of type 'RequestInit | undefined'. | ||
253 | requestData, | ||
254 | ); | ||
255 | |||
256 | if (!request.ok) { | ||
257 | throw new Error(request.statusText); | ||
258 | } | ||
259 | |||
260 | const serviceData = await request.json(); | ||
261 | |||
262 | return serviceData.data; | ||
263 | } | ||
264 | |||
265 | async reorderService(data: any) { | ||
266 | const request = await sendAuthRequest(`${apiBase()}/service/reorder`, { | ||
267 | method: 'PUT', | ||
268 | body: JSON.stringify(data), | ||
269 | }); | ||
270 | if (!request.ok) { | ||
271 | throw new Error(request.statusText); | ||
272 | } | ||
273 | const serviceData = await request.json(); | ||
274 | debug('ServerApi::reorderService resolves', serviceData); | ||
275 | return serviceData; | ||
276 | } | ||
277 | |||
278 | async deleteService(id: string) { | ||
279 | const request = await sendAuthRequest(`${apiBase()}/service/${id}`, { | ||
280 | method: 'DELETE', | ||
281 | }); | ||
282 | if (!request.ok) { | ||
283 | throw new Error(request.statusText); | ||
284 | } | ||
285 | const data = await request.json(); | ||
286 | |||
287 | removeServicePartitionDirectory(id, true); | ||
288 | |||
289 | debug('ServerApi::deleteService resolves', data); | ||
290 | return data; | ||
291 | } | ||
292 | |||
293 | // Features | ||
294 | async getDefaultFeatures() { | ||
295 | const request = await sendAuthRequest(`${apiBase()}/features/default`); | ||
296 | if (!request.ok) { | ||
297 | throw new Error(request.statusText); | ||
298 | } | ||
299 | const data = await request.json(); | ||
300 | |||
301 | const features = data; | ||
302 | debug('ServerApi::getDefaultFeatures resolves', features); | ||
303 | return features; | ||
304 | } | ||
305 | |||
306 | async getFeatures() { | ||
307 | if (apiBase() === SERVER_NOT_LOADED) { | ||
308 | throw new Error('Server not loaded'); | ||
309 | } | ||
310 | |||
311 | const request = await sendAuthRequest(`${apiBase()}/features`); | ||
312 | if (!request.ok) { | ||
313 | throw new Error(request.statusText); | ||
314 | } | ||
315 | const data = await request.json(); | ||
316 | |||
317 | const features = data; | ||
318 | debug('ServerApi::getFeatures resolves', features); | ||
319 | return features; | ||
320 | } | ||
321 | |||
322 | // Recipes | ||
323 | async getInstalledRecipes() { | ||
324 | const recipesDirectory = getRecipeDirectory(); | ||
325 | const paths = readdirSync(recipesDirectory).filter( | ||
326 | file => | ||
327 | statSync(join(recipesDirectory, file)).isDirectory() && | ||
328 | file !== 'temp' && | ||
329 | file !== 'dev', | ||
330 | ); | ||
331 | |||
332 | this.recipes = paths | ||
333 | .map(id => { | ||
334 | // eslint-disable-next-line import/no-dynamic-require | ||
335 | const Recipe = require(id)(RecipeModel); | ||
336 | return new Recipe(loadRecipeConfig(id)); | ||
337 | }) | ||
338 | .filter(recipe => recipe.id); | ||
339 | |||
340 | // eslint-disable-next-line unicorn/prefer-spread | ||
341 | this.recipes = this.recipes.concat(this._getDevRecipes()); | ||
342 | |||
343 | debug('StubServerApi::getInstalledRecipes resolves', this.recipes); | ||
344 | return this.recipes; | ||
345 | } | ||
346 | |||
347 | async getRecipeUpdates(recipeVersions: any) { | ||
348 | const request = await sendAuthRequest(`${apiBase()}/recipes/update`, { | ||
349 | method: 'POST', | ||
350 | body: JSON.stringify(recipeVersions), | ||
351 | }); | ||
352 | if (!request.ok) { | ||
353 | throw new Error(request.statusText); | ||
354 | } | ||
355 | const recipes = await request.json(); | ||
356 | debug('ServerApi::getRecipeUpdates resolves', recipes); | ||
357 | return recipes; | ||
358 | } | ||
359 | |||
360 | // Recipes Previews | ||
361 | async getRecipePreviews() { | ||
362 | const request = await sendAuthRequest(`${apiBase()}/recipes`); | ||
363 | if (!request.ok) throw new Error(request.statusText); | ||
364 | const data = await request.json(); | ||
365 | const recipePreviews = this._mapRecipePreviewModel(data); | ||
366 | debug('ServerApi::getRecipes resolves', recipePreviews); | ||
367 | return recipePreviews; | ||
368 | } | ||
369 | |||
370 | async getFeaturedRecipePreviews() { | ||
371 | // TODO: If we are hitting the internal-server, we need to return an empty list, else we can hit the remote server and get the data | ||
372 | const request = await sendAuthRequest(`${apiBase()}/recipes/popular`); | ||
373 | if (!request.ok) throw new Error(request.statusText); | ||
374 | |||
375 | const data = await request.json(); | ||
376 | const recipePreviews = this._mapRecipePreviewModel(data); | ||
377 | debug('ServerApi::getFeaturedRecipes resolves', recipePreviews); | ||
378 | return recipePreviews; | ||
379 | } | ||
380 | |||
381 | async searchRecipePreviews(needle: string) { | ||
382 | const url = `${apiBase()}/recipes/search?needle=${needle}`; | ||
383 | const request = await sendAuthRequest(url); | ||
384 | if (!request.ok) throw new Error(request.statusText); | ||
385 | |||
386 | const data = await request.json(); | ||
387 | const recipePreviews = this._mapRecipePreviewModel(data); | ||
388 | debug('ServerApi::searchRecipePreviews resolves', recipePreviews); | ||
389 | return recipePreviews; | ||
390 | } | ||
391 | |||
392 | async getRecipePackage(recipeId: string) { | ||
393 | try { | ||
394 | const recipesDirectory = userDataRecipesPath(); | ||
395 | const recipeTempDirectory = join(recipesDirectory, 'temp', recipeId); | ||
396 | const tempArchivePath = join(recipeTempDirectory, 'recipe.tar.gz'); | ||
397 | |||
398 | const internalRecipeFile = asarRecipesPath(`${recipeId}.tar.gz`); | ||
399 | |||
400 | ensureDirSync(recipeTempDirectory); | ||
401 | |||
402 | let archivePath: PathOrFileDescriptor; | ||
403 | |||
404 | if (pathExistsSync(internalRecipeFile)) { | ||
405 | debug('[ServerApi::getRecipePackage] Using internal recipe file'); | ||
406 | archivePath = internalRecipeFile; | ||
407 | } else { | ||
408 | debug('[ServerApi::getRecipePackage] Downloading recipe from server'); | ||
409 | archivePath = tempArchivePath; | ||
410 | |||
411 | const packageUrl = `${apiBase()}/recipes/download/${recipeId}`; | ||
412 | |||
413 | const res = await fetch(packageUrl); | ||
414 | debug('Recipe downloaded', recipeId); | ||
415 | const buffer = await res.buffer(); | ||
416 | writeFileSync(archivePath, buffer); | ||
417 | } | ||
418 | debug(archivePath); | ||
419 | |||
420 | await sleep(10); | ||
421 | |||
422 | // @ts-expect-error No overload matches this call. | ||
423 | await tar.x({ | ||
424 | file: archivePath, | ||
425 | cwd: recipeTempDirectory, | ||
426 | preservePaths: true, | ||
427 | unlink: true, | ||
428 | preserveOwner: false, | ||
429 | onwarn: x => debug('warn', recipeId, x), | ||
430 | }); | ||
431 | |||
432 | await sleep(10); | ||
433 | |||
434 | const { id } = readJsonSync(join(recipeTempDirectory, 'package.json')); | ||
435 | const recipeDirectory = join(recipesDirectory, id); | ||
436 | copySync(recipeTempDirectory, recipeDirectory); | ||
437 | removeSync(recipeTempDirectory); | ||
438 | removeSync(join(recipesDirectory, recipeId, 'recipe.tar.gz')); | ||
439 | |||
440 | return id; | ||
441 | } catch (error) { | ||
442 | console.error(error); | ||
443 | |||
444 | return false; | ||
445 | } | ||
446 | } | ||
447 | |||
448 | // Health Check | ||
449 | async healthCheck() { | ||
450 | if (apiBase() === SERVER_NOT_LOADED) { | ||
451 | throw new Error('Server not loaded'); | ||
452 | } | ||
453 | |||
454 | const request = await sendAuthRequest( | ||
455 | `${apiBase(false)}/health`, | ||
456 | { | ||
457 | method: 'GET', | ||
458 | }, | ||
459 | false, | ||
460 | ); | ||
461 | if (!request.ok) { | ||
462 | throw new Error(request.statusText); | ||
463 | } | ||
464 | debug('ServerApi::healthCheck resolves'); | ||
465 | } | ||
466 | |||
467 | async getLegacyServices() { | ||
468 | const file = userDataPath('settings', 'services.json'); | ||
469 | |||
470 | try { | ||
471 | const config = readJsonSync(file); | ||
472 | |||
473 | if (Object.prototype.hasOwnProperty.call(config, 'services')) { | ||
474 | const services = await Promise.all( | ||
475 | config.services.map(async (s: { service: any }) => { | ||
476 | const service = s; | ||
477 | const request = await sendAuthRequest( | ||
478 | `${apiBase()}/recipes/${s.service}`, | ||
479 | ); | ||
480 | |||
481 | if (request.status === 200) { | ||
482 | const data = await request.json(); | ||
483 | // @ts-expect-error Property 'recipe' does not exist on type '{ service: any; }'. | ||
484 | service.recipe = new RecipePreviewModel(data); | ||
485 | } | ||
486 | |||
487 | return service; | ||
488 | }), | ||
489 | ); | ||
490 | |||
491 | debug('ServerApi::getLegacyServices resolves', services); | ||
492 | return services; | ||
493 | } | ||
494 | } catch { | ||
495 | console.error('ServerApi::getLegacyServices no config found'); | ||
496 | } | ||
497 | |||
498 | return []; | ||
499 | } | ||
500 | |||
501 | // Helper | ||
502 | async _mapServiceModels(services: any[]) { | ||
503 | const recipes = services.map((s: { recipeId: string }) => s.recipeId); | ||
504 | await this._bulkRecipeCheck(recipes); | ||
505 | /* eslint-disable no-return-await */ | ||
506 | return Promise.all( | ||
507 | services.map(async (service: any) => this._prepareServiceModel(service)), | ||
508 | ); | ||
509 | /* eslint-enable no-return-await */ | ||
510 | } | ||
511 | |||
512 | async _prepareServiceModel(service: { recipeId: string }) { | ||
513 | let recipe: undefined; | ||
514 | try { | ||
515 | recipe = this.recipes.find(r => r.id === service.recipeId); | ||
516 | |||
517 | if (!recipe) { | ||
518 | console.warn(`Recipe ${service.recipeId} not loaded`); | ||
519 | return null; | ||
520 | } | ||
521 | |||
522 | return new ServiceModel(service, recipe); | ||
523 | } catch (error) { | ||
524 | debug(error); | ||
525 | return null; | ||
526 | } | ||
527 | } | ||
528 | |||
529 | async _bulkRecipeCheck(unfilteredRecipes: any[]) { | ||
530 | // Filter recipe duplicates as we don't need to download 3 Slack recipes | ||
531 | const recipes = unfilteredRecipes.filter( | ||
532 | (elem: any, pos: number, arr: string | any[]) => | ||
533 | arr.indexOf(elem) === pos, | ||
534 | ); | ||
535 | |||
536 | return Promise.all( | ||
537 | recipes.map(async (recipeId: string) => { | ||
538 | let recipe = this.recipes.find(r => r.id === recipeId); | ||
539 | |||
540 | if (!recipe) { | ||
541 | console.warn( | ||
542 | `Recipe '${recipeId}' not installed, trying to fetch from server`, | ||
543 | ); | ||
544 | |||
545 | await this.getRecipePackage(recipeId); | ||
546 | |||
547 | debug('Rerun ServerAPI::getInstalledRecipes'); | ||
548 | await this.getInstalledRecipes(); | ||
549 | |||
550 | recipe = this.recipes.find(r => r.id === recipeId); | ||
551 | |||
552 | if (!recipe) { | ||
553 | console.warn(`Could not load recipe ${recipeId}`); | ||
554 | return null; | ||
555 | } | ||
556 | } | ||
557 | |||
558 | return recipe; | ||
559 | }), | ||
560 | ).catch(error => console.error("Can't load recipe", error)); | ||
561 | } | ||
562 | |||
563 | _mapRecipePreviewModel(recipes: any[]) { | ||
564 | return recipes | ||
565 | .map(recipe => { | ||
566 | try { | ||
567 | return new RecipePreviewModel(recipe); | ||
568 | } catch (error) { | ||
569 | console.error(error); | ||
570 | return null; | ||
571 | } | ||
572 | }) | ||
573 | .filter(recipe => recipe !== null); | ||
574 | } | ||
575 | |||
576 | _getDevRecipes() { | ||
577 | const recipesDirectory = getDevRecipeDirectory(); | ||
578 | try { | ||
579 | const paths = readdirSync(recipesDirectory).filter( | ||
580 | file => | ||
581 | statSync(join(recipesDirectory, file)).isDirectory() && | ||
582 | file !== 'temp', | ||
583 | ); | ||
584 | |||
585 | const recipes = paths | ||
586 | .map(id => { | ||
587 | let Recipe; | ||
588 | try { | ||
589 | // eslint-disable-next-line import/no-dynamic-require | ||
590 | Recipe = require(id)(RecipeModel); | ||
591 | return new Recipe(loadRecipeConfig(id)); | ||
592 | } catch (error) { | ||
593 | console.error(error); | ||
594 | } | ||
595 | |||
596 | return false; | ||
597 | }) | ||
598 | .filter(recipe => recipe.id) | ||
599 | .map(data => { | ||
600 | const recipe = data; | ||
601 | |||
602 | recipe.icons = { | ||
603 | svg: `${recipe.path}/icon.svg`, | ||
604 | }; | ||
605 | recipe.local = true; | ||
606 | |||
607 | return data; | ||
608 | }); | ||
609 | |||
610 | return recipes; | ||
611 | } catch { | ||
612 | debug('Could not load dev recipes'); | ||
613 | return false; | ||
614 | } | ||
615 | } | ||
616 | } | ||