@kispace-io/gs-lib 0.0.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.
Files changed (59) hide show
  1. package/README.md +58 -0
  2. package/bin/map-builder.js +132 -0
  3. package/dist/base-map-builder.d.ts +102 -0
  4. package/dist/base-map-builder.d.ts.map +1 -0
  5. package/dist/gs-gs2ol.d.ts +41 -0
  6. package/dist/gs-gs2ol.d.ts.map +1 -0
  7. package/dist/gs-lib.css +3724 -0
  8. package/dist/gs-lib.d.ts +16 -0
  9. package/dist/gs-lib.d.ts.map +1 -0
  10. package/dist/gs-litns.d.ts +32 -0
  11. package/dist/gs-litns.d.ts.map +1 -0
  12. package/dist/gs-model.d.ts +186 -0
  13. package/dist/gs-model.d.ts.map +1 -0
  14. package/dist/gs-ol-adapters.d.ts +23 -0
  15. package/dist/gs-ol-adapters.d.ts.map +1 -0
  16. package/dist/gs-ol2gs.d.ts +9 -0
  17. package/dist/gs-ol2gs.d.ts.map +1 -0
  18. package/dist/gs-olns.d.ts +22 -0
  19. package/dist/gs-olns.d.ts.map +1 -0
  20. package/dist/index.d.ts +11 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.html +69 -0
  23. package/dist/index.js +104888 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/pwa/assets/icons/192x192.png +0 -0
  26. package/dist/pwa/assets/icons/24x24.png +0 -0
  27. package/dist/pwa/assets/icons/48x48.png +0 -0
  28. package/dist/pwa/assets/icons/512x512.png +0 -0
  29. package/dist/pwa/assets/icons/icon_192.png +0 -0
  30. package/dist/pwa/assets/icons/icon_24.png +0 -0
  31. package/dist/pwa/assets/icons/icon_48.png +0 -0
  32. package/dist/pwa/assets/icons/icon_512.png +0 -0
  33. package/dist/pwa/manifest.json +54 -0
  34. package/dist/pwa/staticwebapp.config.json +6 -0
  35. package/dist/pwa/sw.js +109 -0
  36. package/lib/node-map-builder.ts +200 -0
  37. package/package.json +51 -0
  38. package/public/index.html +69 -0
  39. package/public/pwa/assets/icons/192x192.png +0 -0
  40. package/public/pwa/assets/icons/24x24.png +0 -0
  41. package/public/pwa/assets/icons/48x48.png +0 -0
  42. package/public/pwa/assets/icons/512x512.png +0 -0
  43. package/public/pwa/assets/icons/icon_192.png +0 -0
  44. package/public/pwa/assets/icons/icon_24.png +0 -0
  45. package/public/pwa/assets/icons/icon_48.png +0 -0
  46. package/public/pwa/assets/icons/icon_512.png +0 -0
  47. package/public/pwa/manifest.json +54 -0
  48. package/public/pwa/staticwebapp.config.json +6 -0
  49. package/public/pwa/sw.js +109 -0
  50. package/src/base-map-builder.ts +414 -0
  51. package/src/gs-gs2ol.ts +626 -0
  52. package/src/gs-lib.ts +54 -0
  53. package/src/gs-litns.ts +213 -0
  54. package/src/gs-model.ts +393 -0
  55. package/src/gs-ol-adapters.ts +89 -0
  56. package/src/gs-ol2gs.ts +86 -0
  57. package/src/gs-olns.ts +30 -0
  58. package/src/index.ts +15 -0
  59. package/tsconfig.json +23 -0
@@ -0,0 +1,109 @@
1
+ importScripts(
2
+ 'https://storage.googleapis.com/workbox-cdn/releases/7.3.0/workbox-sw.js'
3
+ );
4
+
5
+ // Version management for PWA updates
6
+ const CACHE_VERSION = '$PWA_VERSION';
7
+ const CACHE_NAME = `geospace-v${CACHE_VERSION}`;
8
+
9
+ // This is your Service Worker, you can put any of your custom Service Worker
10
+ // code in this file, above the `precacheAndRoute` line.
11
+
12
+ // When widget is installed/pinned, push initial state.
13
+ self.addEventListener('widgetinstall', (event) => {
14
+ event.waitUntil(updateWidget(event));
15
+ });
16
+
17
+ // When widget is shown, update content to ensure it is up-to-date.
18
+ self.addEventListener('widgetresume', (event) => {
19
+ event.waitUntil(updateWidget(event));
20
+ });
21
+
22
+ // When the user clicks an element with an associated Action.Execute,
23
+ // handle according to the 'verb' in event.action.
24
+ self.addEventListener('widgetclick', (event) => {
25
+ if (event.action == "updateName") {
26
+ event.waitUntil(updateName(event));
27
+ }
28
+ });
29
+
30
+ // When the widget is uninstalled/unpinned, clean up any unnecessary
31
+ // periodic sync or widget-related state.
32
+ self.addEventListener('widgetuninstall', (event) => {});
33
+
34
+ // Handle service worker updates and version changes
35
+ self.addEventListener('install', (event) => {
36
+ console.log(`Service Worker installing version ${CACHE_VERSION}`);
37
+ // Skip waiting to activate immediately
38
+ self.skipWaiting();
39
+ });
40
+
41
+ self.addEventListener('activate', (event) => {
42
+ console.log(`Service Worker activating version ${CACHE_VERSION}`);
43
+ event.waitUntil(
44
+ // Clean up old caches
45
+ caches.keys().then((cacheNames) => {
46
+ return Promise.all(
47
+ cacheNames.map((cacheName) => {
48
+ // Delete caches that don't match current version
49
+ if (cacheName.startsWith('geospace-v') && cacheName !== CACHE_NAME) {
50
+ console.log(`Deleting old cache: ${cacheName}`);
51
+ return caches.delete(cacheName);
52
+ }
53
+ })
54
+ );
55
+ }).then(() => {
56
+ // Take control of all clients immediately
57
+ return self.clients.claim().then(() => {
58
+ // Notify all clients about the new version
59
+ return self.clients.matchAll().then(clients => {
60
+ clients.forEach(client => {
61
+ client.postMessage({
62
+ type: 'RELOAD',
63
+ version: CACHE_VERSION
64
+ });
65
+ });
66
+ });
67
+ });
68
+ })
69
+ );
70
+ });
71
+
72
+ // Handle version change notifications
73
+ self.addEventListener('message', (event) => {
74
+ if (event.data && event.data.type === 'SKIP_WAITING') {
75
+ self.skipWaiting();
76
+ }
77
+ });
78
+
79
+ const updateWidget = async (event) => {
80
+ // The widget definition represents the fields specified in the manifest.
81
+ const widgetDefinition = event.widget.definition;
82
+
83
+ // Fetch the template and data defined in the manifest to generate the payload.
84
+ const payload = {
85
+ template: JSON.stringify(await (await fetch(widgetDefinition.msAcTemplate)).json()),
86
+ data: JSON.stringify(await (await fetch(widgetDefinition.data)).json()),
87
+ };
88
+
89
+ // Push payload to widget.
90
+ await self.widgets.updateByInstanceId(event.instanceId, payload);
91
+ }
92
+
93
+ const updateName = async (event) => {
94
+ const name = event.data.json().name;
95
+
96
+ // The widget definition represents the fields specified in the manifest.
97
+ const widgetDefinition = event.widget.definition;
98
+
99
+ // Fetch the template and data defined in the manifest to generate the payload.
100
+ const payload = {
101
+ template: JSON.stringify(await (await fetch(widgetDefinition.msAcTemplate)).json()),
102
+ data: JSON.stringify({name}),
103
+ };
104
+
105
+ // Push payload to widget.
106
+ await self.widgets.updateByInstanceId(event.instanceId, payload);
107
+ }
108
+
109
+ workbox.precaching.precacheAndRoute(self.__WB_MANIFEST || []);
@@ -0,0 +1,414 @@
1
+ import {GsMap, GsScript} from './gs-model'
2
+
3
+ // esbuild types (without importing the implementation to avoid bundling Node.js version in browser)
4
+ type EsbuildPlugin = {
5
+ name: string
6
+ setup: (build: any) => void
7
+ }
8
+
9
+ // Type for esbuild instance (works with both esbuild and esbuild-wasm)
10
+ type EsbuildInstance = {
11
+ build: (options: any) => Promise<{ outputFiles?: Array<{ contents: Uint8Array }> }>
12
+ }
13
+
14
+ export interface BuildOptions {
15
+ title: string,
16
+ gsMap: GsMap,
17
+ env: any,
18
+ version: string
19
+ }
20
+
21
+ export interface FileSystem {
22
+ readFile(path: string): Promise<string | Uint8Array>
23
+ writeFile(path: string, content: string | Uint8Array): Promise<void>
24
+ ensureDir(path: string): Promise<void>
25
+ exists(path: string): Promise<boolean>
26
+ deleteDir?(path: string): Promise<void>
27
+ }
28
+
29
+ export interface ProgressCallback {
30
+ (step: number, message: string, totalSteps?: number): void
31
+ }
32
+
33
+
34
+ /**
35
+ * Generate the entry point JavaScript code for the map application
36
+ */
37
+ export function generateAppJs(vars: {
38
+ gsMap: GsMap,
39
+ gsLibPath: string,
40
+ env: any
41
+ }): string {
42
+ // Collect all script paths from controls and overlays
43
+ const allScripts = [...(vars.gsMap.controls || []), ...(vars.gsMap.overlays || [])]
44
+ const scriptPaths = allScripts
45
+ .map((script: GsScript) => script.src)
46
+ .filter((src: string) => src) // Filter out empty src
47
+
48
+ // Generate static imports for all scripts so esbuild can bundle them
49
+ // Static imports ensure esbuild bundles them, and they'll be available at runtime
50
+ const scriptImports = scriptPaths.map((src: string, index: number) => {
51
+ // Escape the src for use in template string
52
+ const escapedSrc = src.replace(/`/g, '\\`').replace(/\$/g, '\\$')
53
+ return `import script${index} from '${escapedSrc}'`
54
+ })
55
+
56
+ // Create a modules map that maps src to the imported module
57
+ // This allows gs-lib to properly parameterize user modules at runtime
58
+ const modulesMap = scriptPaths.map((src: string, index: number) => {
59
+ const escapedSrc = JSON.stringify(src)
60
+ return `${escapedSrc}: script${index}`
61
+ }).join(',\n ')
62
+
63
+ return `
64
+ import {gsLib} from "${vars.gsLibPath}"
65
+
66
+ ${scriptImports.join('\n')}
67
+
68
+ export const renderMap = (mapContainerSelector) => {
69
+ const modules = {
70
+ ${modulesMap}
71
+ }
72
+ return gsLib({
73
+ containerSelector: mapContainerSelector,
74
+ gsMap: ${JSON.stringify(vars.gsMap)},
75
+ mapOptions: {
76
+ controls: {zoom: false, attribution: false}
77
+ },
78
+ env: ${JSON.stringify(vars.env || {})},
79
+ modules: modules
80
+ })
81
+ }
82
+ `
83
+ }
84
+
85
+ /**
86
+ * Process service worker content by replacing version placeholder
87
+ */
88
+ export function processServiceWorker(content: string, version: string): string {
89
+ return content.replace(/\$PWA_VERSION/g, version)
90
+ }
91
+
92
+ /**
93
+ * Process manifest content by updating title and version
94
+ */
95
+ export function processManifest(content: string, title: string, version: string): string {
96
+ const manifest = JSON.parse(content)
97
+ manifest.name = title
98
+ manifest.short_name = title
99
+ manifest.description = title
100
+ manifest.version = version
101
+ return JSON.stringify(manifest, null, 2)
102
+ }
103
+
104
+ /**
105
+ * Process HTML template by replacing title placeholder
106
+ */
107
+ export function processHtml(content: string, title: string): string {
108
+ return content.replace(/\$TITLE/g, title)
109
+ }
110
+
111
+ /**
112
+ * Bundle the application code using esbuild
113
+ * @param esbuildInstance - The esbuild instance to use (esbuild-wasm for browser, esbuild for Node.js)
114
+ */
115
+ export async function bundleApp(
116
+ entryPointPath: string,
117
+ outputPath: string,
118
+ gsLibPath: string,
119
+ fileSys: FileSystem,
120
+ resolvePlugin: EsbuildPlugin,
121
+ esbuildInstance: EsbuildInstance,
122
+ progress?: ProgressCallback,
123
+ currentStep?: { value: number },
124
+ totalSteps?: number
125
+ ): Promise<void> {
126
+ const updateProgress = (message: string) => {
127
+ if (progress) {
128
+ if (currentStep !== undefined) {
129
+ progress(++currentStep.value, message, totalSteps)
130
+ } else {
131
+ progress(0, message, totalSteps)
132
+ }
133
+ }
134
+ }
135
+
136
+ updateProgress("Bundling and minifying code...")
137
+
138
+ const result = await esbuildInstance.build({
139
+ entryPoints: [entryPointPath],
140
+ bundle: true,
141
+ outfile: outputPath,
142
+ format: "esm",
143
+ minify: true,
144
+ plugins: [resolvePlugin],
145
+ // Runtime dependencies (lit, webawesome) are bundled with gs-lib
146
+ external: [],
147
+ // Bundle all dependencies
148
+ packages: 'bundle'
149
+ })
150
+
151
+ updateProgress("Saving bundled output...")
152
+
153
+ // When using outfile, esbuild writes directly to disk and outputFiles is not populated
154
+ // If outputFiles exists, use it; otherwise, the file was already written by esbuild
155
+ if (result.outputFiles && result.outputFiles.length > 0) {
156
+ await fileSys.writeFile(outputPath, result.outputFiles[0].contents)
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Interface for copying files from gs-lib package
162
+ * This abstracts the difference between browser (import promises) and Node.js (file system)
163
+ */
164
+ export interface GsLibFileCopier {
165
+ /**
166
+ * Copy a text file from gs-lib
167
+ * @param srcPath - Source path relative to gs-lib package (e.g., "dist/index.js", "public/index.html")
168
+ * @param destPath - Destination path in the project
169
+ * @param processor - Optional function to process content before saving
170
+ */
171
+ copyTextFile(srcPath: string, destPath: string, processor?: (content: string) => string | Promise<string>): Promise<void>
172
+
173
+ /**
174
+ * Copy a binary file from gs-lib
175
+ * @param srcPath - Source path relative to gs-lib package
176
+ * @param destPath - Destination path in the project
177
+ */
178
+ copyBinaryFile(srcPath: string, destPath: string): Promise<void>
179
+ }
180
+
181
+ /**
182
+ * Create a standard GsLibFileCopier that uses FileSystem to read from gs-lib package
183
+ */
184
+ export function createFileSystemGsLibCopier(
185
+ fs: FileSystem,
186
+ gsLibPackagePath: string
187
+ ): GsLibFileCopier {
188
+ const copyFile = async (
189
+ srcPath: string,
190
+ destPath: string,
191
+ asText: boolean,
192
+ processor?: (content: string) => string | Promise<string>
193
+ ): Promise<void> => {
194
+ const fullSrcPath = `${gsLibPackagePath}/${srcPath}`
195
+ let content: string | Uint8Array = await fs.readFile(fullSrcPath)
196
+
197
+ if (asText) {
198
+ // Convert to string if needed
199
+ if (content instanceof Uint8Array) {
200
+ content = new TextDecoder().decode(content)
201
+ } else {
202
+ content = content as string
203
+ }
204
+
205
+ if (processor) {
206
+ content = await processor(content)
207
+ }
208
+ }
209
+
210
+ await fs.writeFile(destPath, content)
211
+ }
212
+
213
+ return {
214
+ async copyTextFile(srcPath: string, destPath: string, processor?: (content: string) => string | Promise<string>): Promise<void> {
215
+ await copyFile(srcPath, destPath, true, processor)
216
+ },
217
+ async copyBinaryFile(srcPath: string, destPath: string): Promise<void> {
218
+ await copyFile(srcPath, destPath, false)
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Recursively copy a directory
225
+ */
226
+ async function copyDirectory(fs: FileSystem, src: string, dest: string): Promise<void> {
227
+ // This is a simplified version - would need full directory traversal
228
+ // For now, we'll handle specific known directories
229
+ await fs.ensureDir(dest)
230
+ }
231
+
232
+ /**
233
+ * Calculate total build steps based on configuration
234
+ */
235
+ export function calculateTotalSteps(
236
+ hasReadStep: boolean,
237
+ hasAssets: boolean,
238
+ cleanBeforeBuild: boolean,
239
+ cleanAfterBuild: boolean
240
+ ): number {
241
+ const baseSteps = 9 // buildMap base steps
242
+ const bundleSteps = 2 // bundling steps
243
+ const completedStep = 1 // build completed step
244
+ const readStep = hasReadStep ? 1 : 0
245
+ const assetsStep = hasAssets ? 1 : 0
246
+ const cleanupBeforeStep = cleanBeforeBuild ? 1 : 0
247
+ const cleanupAfterStep = cleanAfterBuild ? 1 : 0
248
+
249
+ return readStep + cleanupBeforeStep + baseSteps + bundleSteps + completedStep + assetsStep + cleanupAfterStep
250
+ }
251
+
252
+ /**
253
+ * Create a standard copyAssets function that works with FileSystem
254
+ */
255
+ export function createCopyAssetsFunction(fs: FileSystem): (fs: FileSystem, outputDir: string, progress?: ProgressCallback) => Promise<void> {
256
+ return async (fileSys: FileSystem, outputDir: string, progress?: ProgressCallback) => {
257
+ if (await fileSys.exists('assets')) {
258
+ await copyDirectory(fileSys, 'assets', `${outputDir}/assets`)
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Unified build function that works in both browser and Node.js
265
+ * This is the single source of truth for the build process
266
+ */
267
+ export async function buildMap(
268
+ options: BuildOptions,
269
+ fs: FileSystem,
270
+ resolvePlugin: EsbuildPlugin,
271
+ esbuildInstance: EsbuildInstance,
272
+ config: {
273
+ outputDir?: string,
274
+ buildDir?: string,
275
+ gsLibPath?: string,
276
+ gsLibPackagePath?: string,
277
+ gsLibCopier?: GsLibFileCopier,
278
+ cleanBeforeBuild?: boolean,
279
+ cleanAfterBuild?: boolean,
280
+ copyAssets?: (fs: FileSystem, outputDir: string, progress?: ProgressCallback) => Promise<void>
281
+ } = {},
282
+ progress?: ProgressCallback
283
+ ): Promise<void> {
284
+ const {
285
+ outputDir = 'dist',
286
+ buildDir = '__build',
287
+ gsLibPath = `${buildDir}/gs-lib/index.js`,
288
+ gsLibPackagePath,
289
+ gsLibCopier: providedGsLibCopier,
290
+ cleanBeforeBuild = true,
291
+ cleanAfterBuild = true,
292
+ copyAssets
293
+ } = config
294
+
295
+ // Create gsLibCopier if not provided, using FileSystem
296
+ const gsLibCopier = providedGsLibCopier || (gsLibPackagePath ? createFileSystemGsLibCopier(fs, gsLibPackagePath) : null)
297
+ if (!gsLibCopier) {
298
+ throw new Error('Either gsLibCopier or gsLibPackagePath must be provided')
299
+ }
300
+
301
+ const buildGsLibPath = gsLibPath
302
+
303
+ let step = (config as any).startingStep ?? 0
304
+ const totalSteps = (config as any).totalSteps
305
+ const updateProgress = (message: string) => {
306
+ if (progress) progress(++step, message, totalSteps)
307
+ }
308
+
309
+ // Clean up build directories before starting
310
+ if (cleanBeforeBuild) {
311
+ updateProgress("Cleaning build directories...")
312
+ const cleanupPromises: Promise<void>[] = []
313
+ if (fs.deleteDir) {
314
+ cleanupPromises.push(
315
+ fs.deleteDir(buildDir).catch(() => {}), // Ignore errors if directory doesn't exist
316
+ fs.deleteDir(outputDir).catch(() => {})
317
+ )
318
+ } else {
319
+ // Fallback: try to use Node.js fs if available
320
+ try {
321
+ const nodeFs = await import('fs/promises')
322
+ const path = await import('path')
323
+ const projectRoot = process.cwd()
324
+ cleanupPromises.push(
325
+ nodeFs.rm(path.resolve(projectRoot, buildDir), { recursive: true, force: true }).catch(() => {}),
326
+ nodeFs.rm(path.resolve(projectRoot, outputDir), { recursive: true, force: true }).catch(() => {})
327
+ )
328
+ } catch {
329
+ // If Node.js fs is not available, skip cleanup
330
+ }
331
+ }
332
+ await Promise.all(cleanupPromises)
333
+ }
334
+
335
+ // Ensure directories exist
336
+ updateProgress("Preparing build directories...")
337
+ await Promise.all([
338
+ fs.ensureDir(`${outputDir}/assets/icons/`),
339
+ fs.ensureDir(`${buildDir}/gs-lib/`)
340
+ ])
341
+
342
+ // Copy gs-lib package files
343
+ updateProgress("Copying gs-lib package...")
344
+ await Promise.all([
345
+ gsLibCopier.copyTextFile('dist/index.js', `${buildDir}/gs-lib/index.js`),
346
+ gsLibCopier.copyTextFile('dist/gs-lib.css', `${outputDir}/app.css`)
347
+ ])
348
+
349
+ // Copy and process PWA files
350
+ updateProgress("Copying PWA core files...")
351
+ await gsLibCopier.copyTextFile('public/pwa/staticwebapp.config.json', `${outputDir}/staticwebapp.config.json`)
352
+
353
+ updateProgress("Processing service worker...")
354
+ await gsLibCopier.copyTextFile('public/pwa/sw.js', `${outputDir}/sw.js`, (content) => processServiceWorker(content, options.version))
355
+
356
+ updateProgress("Creating manifest file...")
357
+ await gsLibCopier.copyTextFile('public/pwa/manifest.json', `${outputDir}/manifest.json`, (content) => processManifest(content, options.title, options.version))
358
+
359
+ // Copy PWA icons
360
+ updateProgress("Copying PWA icons...")
361
+ const iconFiles = [
362
+ '24x24.png', '48x48.png', '192x192.png', '512x512.png',
363
+ 'icon_24.png', 'icon_48.png', 'icon_192.png', 'icon_512.png'
364
+ ]
365
+ await Promise.all(iconFiles.map(icon =>
366
+ gsLibCopier.copyBinaryFile(`public/pwa/assets/icons/${icon}`, `${outputDir}/assets/icons/${icon}`)
367
+ ))
368
+
369
+ // Copy workspace assets if provided
370
+ if (copyAssets) {
371
+ updateProgress("Copying workspace assets...")
372
+ await copyAssets(fs, outputDir, progress)
373
+ }
374
+
375
+ // Generate entry point
376
+ updateProgress("Generating application code...")
377
+ const entryPointContent = generateAppJs({
378
+ gsMap: options.gsMap,
379
+ gsLibPath: buildGsLibPath,
380
+ env: options.env
381
+ })
382
+ await fs.writeFile(`${buildDir}/app.js`, entryPointContent)
383
+
384
+ // Copy and process HTML
385
+ updateProgress("Generating HTML file...")
386
+ await gsLibCopier.copyTextFile('public/index.html', `${outputDir}/index.html`, (content) => processHtml(content, options.title))
387
+
388
+ // Bundle
389
+ const stepRef = { value: step }
390
+ await bundleApp(`${buildDir}/app.js`, `${outputDir}/app.js`, buildGsLibPath, fs, resolvePlugin, esbuildInstance, progress, stepRef, totalSteps)
391
+ step = stepRef.value
392
+
393
+ // Cleanup
394
+ if (cleanAfterBuild) {
395
+ updateProgress("Cleaning up temporary files...")
396
+ if (fs.deleteDir) {
397
+ await fs.deleteDir(buildDir)
398
+ } else {
399
+ // Fallback: try to use Node.js fs if available (for CLI usage)
400
+ try {
401
+ const nodeFs = await import('fs/promises')
402
+ const path = await import('path')
403
+ const fullPath = path.resolve(process.cwd(), buildDir)
404
+ await nodeFs.rm(fullPath, { recursive: true, force: true })
405
+ } catch (error) {
406
+ // If Node.js fs is not available (browser context), skip cleanup
407
+ // The browser FileSystem should implement deleteDir
408
+ }
409
+ }
410
+ }
411
+
412
+ updateProgress("Build completed!")
413
+ }
414
+