@lobb-js/lobb-ext-reports 0.1.33 → 0.3.1

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 (50) hide show
  1. package/.dockerignore +4 -1
  2. package/.env.example +10 -0
  3. package/.vscode/settings.json +5 -0
  4. package/CHANGELOG.md +94 -16
  5. package/README.md +1 -29
  6. package/extensions/reports/collectionRoutes.ts +10 -0
  7. package/extensions/reports/collections/charts.ts +47 -0
  8. package/extensions/reports/collections/index.ts +11 -0
  9. package/extensions/reports/collections/reports.ts +30 -0
  10. package/extensions/reports/controllers/chartsController.ts +78 -0
  11. package/extensions/reports/index.ts +24 -0
  12. package/extensions/reports/meta.ts +218 -0
  13. package/extensions/reports/migrations.ts +3 -0
  14. package/extensions/reports/openapi.ts +39 -0
  15. package/extensions/reports/relations.ts +17 -0
  16. package/extensions/reports/studio/tests/analytics.spec.ts +26 -0
  17. package/extensions/reports/studio/tests/package.json +1 -0
  18. package/extensions/reports/studio/tests/playwright.config.cjs +27 -0
  19. package/extensions/reports/studioExtension.json +1 -0
  20. package/extensions/reports/tests/configs/simple.ts +55 -0
  21. package/extensions/reports/tests/reports.test.ts +105 -0
  22. package/lobb.ts +55 -0
  23. package/package.json +31 -7
  24. package/scripts/postpublish.sh +12 -0
  25. package/scripts/prepublish.sh +17 -0
  26. package/studio/app.html +12 -0
  27. package/studio/routes/+layout.svelte +7 -0
  28. package/studio/routes/+layout.ts +1 -0
  29. package/studio/routes/[...path]/+page.svelte +6 -0
  30. package/svelte.config.js +23 -7
  31. package/todo.md +11 -0
  32. package/tsconfig.app.json +3 -3
  33. package/tsconfig.json +11 -5
  34. package/vite.config.ts +4 -10
  35. package/Dockerfile +0 -29
  36. package/components.json +0 -16
  37. package/extension.types.ts +0 -28
  38. package/index.html +0 -13
  39. package/nginx.conf +0 -17
  40. package/src/app.css +0 -124
  41. package/src/main.ts +0 -14
  42. /package/{src → extensions/reports/studio}/index.ts +0 -0
  43. /package/{src → extensions/reports/studio}/lib/components/dv_fields_buttons/query_ai_button/index.svelte +0 -0
  44. /package/{src → extensions/reports/studio}/lib/components/pages/reports/components/chart.svelte +0 -0
  45. /package/{src → extensions/reports/studio}/lib/components/pages/reports/components/charts/chartJs.svelte +0 -0
  46. /package/{src → extensions/reports/studio}/lib/components/pages/reports/components/charts/table.svelte +0 -0
  47. /package/{src → extensions/reports/studio}/lib/components/pages/reports/components/report.svelte +0 -0
  48. /package/{src → extensions/reports/studio}/lib/components/pages/reports/index.svelte +0 -0
  49. /package/{src → extensions/reports/studio}/lib/index.ts +0 -0
  50. /package/{src → extensions/reports/studio}/lib/utils.ts +0 -0
@@ -0,0 +1,17 @@
1
+ import type { Lobb, RelationsConfig } from "@lobb-js/core";
2
+
3
+ export function relations(lobb: Lobb): RelationsConfig {
4
+ const relations: RelationsConfig = [
5
+ {
6
+ from: {
7
+ collection: "reports_charts",
8
+ field: "report_id",
9
+ },
10
+ to: {
11
+ collection: "reports_dashboards",
12
+ field: "id",
13
+ },
14
+ },
15
+ ];
16
+ return relations;
17
+ }
@@ -0,0 +1,26 @@
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ const ANALYTICS_URL = "/studio/extensions/reports/analytics";
4
+
5
+ test.describe("Analytics page", () => {
6
+ test.beforeEach(async ({ page }) => {
7
+ await page.goto(ANALYTICS_URL);
8
+ });
9
+
10
+ test("shows the analytics page", async ({ page }) => {
11
+ await expect(page).toHaveURL(new RegExp(ANALYTICS_URL));
12
+ });
13
+
14
+ test("shows empty state when no report is selected", async ({ page }) => {
15
+ await expect(page.getByText("No report selected")).toBeVisible();
16
+ await expect(
17
+ page.getByText("Please select a report to view the details or create a new one")
18
+ ).toBeVisible();
19
+ });
20
+
21
+ test("shows create a report button in empty state", async ({ page }) => {
22
+ await expect(
23
+ page.getByRole("button", { name: "Create a report" }).first()
24
+ ).toBeVisible();
25
+ });
26
+ });
@@ -0,0 +1 @@
1
+ {"type": "commonjs"}
@@ -0,0 +1,27 @@
1
+ const { defineConfig, devices } = require("@playwright/test");
2
+
3
+ module.exports = defineConfig({
4
+ testDir: __dirname,
5
+ testMatch: "**/*.spec.ts",
6
+ fullyParallel: true,
7
+ retries: 0,
8
+ expect: { timeout: 15000 },
9
+ use: {
10
+ baseURL: "http://localhost:3000",
11
+ trace: "on-first-retry",
12
+ headless: true,
13
+ },
14
+ projects: [
15
+ {
16
+ name: "chromium",
17
+ use: { ...devices["Desktop Chrome"] },
18
+ },
19
+ ],
20
+ // Automatically start the dev server before running tests
21
+ webServer: {
22
+ command: "bun run dev",
23
+ url: "http://localhost:3000/studio",
24
+ reuseExistingServer: true,
25
+ cwd: "../../../../", // packages/reports-ext root
26
+ },
27
+ });
@@ -0,0 +1,55 @@
1
+ import type { Config } from "@lobb-js/core";
2
+ import extension from "../../index.ts";
3
+
4
+ export const simpleConfig: Config = {
5
+ project: {
6
+ name: "Lobb",
7
+ force_sync: true,
8
+ },
9
+ database: {
10
+ host: "localhost",
11
+ port: 5432,
12
+ username: "test",
13
+ password: "test",
14
+ database: "*",
15
+ },
16
+ web_server: {
17
+ host: "0.0.0.0",
18
+ port: 0,
19
+ cors: {
20
+ origin: "*",
21
+ },
22
+ },
23
+ extensions: [
24
+ extension(),
25
+ ],
26
+ collections: {
27
+ donations: {
28
+ indexes: {},
29
+ fields: {
30
+ id: {
31
+ type: "integer",
32
+ },
33
+ donor_name: {
34
+ type: "string",
35
+ length: 255,
36
+ validators: {
37
+ required: true,
38
+ },
39
+ },
40
+ amount: {
41
+ type: "decimal",
42
+ validators: {
43
+ required: true,
44
+ },
45
+ },
46
+ created_at: {
47
+ type: "date",
48
+ pre_processors: {
49
+ default: "{{ now }}",
50
+ },
51
+ },
52
+ },
53
+ },
54
+ },
55
+ };
@@ -0,0 +1,105 @@
1
+ import { Lobb } from "@lobb-js/core";
2
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
3
+ import { faker } from "@faker-js/faker";
4
+ import { simpleConfig } from "./configs/simple.ts";
5
+
6
+ describe("Reports operations", () => {
7
+ let lobb: Lobb;
8
+ let baseUrl: string;
9
+
10
+ beforeAll(async () => {
11
+ lobb = await Lobb.init(simpleConfig);
12
+ baseUrl = `http://127.0.0.1:${lobb.webServer.port}`;
13
+ });
14
+
15
+ afterAll(async () => {
16
+ await lobb.close();
17
+ });
18
+
19
+ it("should execute the query of the Report successfully", async () => {
20
+ const response1 = await fetch(
21
+ `${baseUrl}/api/collections/reports_dashboards`,
22
+ {
23
+ method: "POST",
24
+ body: JSON.stringify({
25
+ data: {
26
+ name: "test report",
27
+ description: "test description",
28
+ },
29
+ }),
30
+ },
31
+ );
32
+ const result2 = await response1.json();
33
+ const reportId = result2.data.id;
34
+
35
+ // creating a chart
36
+ const response = await fetch(
37
+ `${baseUrl}/api/collections/reports_charts`,
38
+ {
39
+ method: "POST",
40
+ body: JSON.stringify({
41
+ data: {
42
+ report_id: reportId,
43
+ title: "test report",
44
+ description: "test description",
45
+ chart: `async function chart(query: QueryFn) {
46
+ const entries = await query("SELECT EXTRACT(MONTH FROM created_at) AS month, CAST(SUM(amount) AS DECIMAL) AS totalDonations FROM donations GROUP BY EXTRACT(MONTH FROM created_at) ORDER BY month ASC");
47
+ return {
48
+ type: 'table',
49
+ input: {
50
+ data: entries
51
+ }
52
+ };
53
+ }`,
54
+ },
55
+ }),
56
+ },
57
+ );
58
+ const result1 = await response.json();
59
+ const chartId = result1.data.id;
60
+
61
+ // create donations
62
+ faker.seed(1);
63
+ const donations = Array.from({ length: 50 }, () => ({
64
+ donor_name: faker.person.fullName(),
65
+ amount: faker.finance.amount({
66
+ min: 10,
67
+ max: 1000,
68
+ }),
69
+ created_at: faker.date.between({
70
+ from: "2023-01-01",
71
+ to: "2023-12-31",
72
+ }).toISOString(),
73
+ }));
74
+ await lobb.collectionService.createMany({
75
+ collectionName: "donations",
76
+ data: donations,
77
+ });
78
+
79
+ // execute the query and get the results
80
+ const response2 = await fetch(
81
+ `${baseUrl}/api/collections/reports_charts/${chartId}?action=run_query`,
82
+ );
83
+ const result = await response2.json();
84
+
85
+ expect(result).toEqual({
86
+ type: "table",
87
+ input: {
88
+ data: [
89
+ { month: "1", totaldonations: "755.23" },
90
+ { month: "2", totaldonations: "1629.79" },
91
+ { month: "3", totaldonations: "3286.90" },
92
+ { month: "4", totaldonations: "2506.34" },
93
+ { month: "5", totaldonations: "471.05" },
94
+ { month: "6", totaldonations: "396.11" },
95
+ { month: "7", totaldonations: "3451.62" },
96
+ { month: "8", totaldonations: "3245.77" },
97
+ { month: "9", totaldonations: "1641.96" },
98
+ { month: "10", totaldonations: "703.91" },
99
+ { month: "11", totaldonations: "4452.67" },
100
+ { month: "12", totaldonations: "2028.52" },
101
+ ],
102
+ },
103
+ });
104
+ });
105
+ });
package/lobb.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { Lobb } from "@lobb-js/core";
2
+ import { extension } from "./extensions/reports/index.ts";
3
+
4
+ Lobb.init({
5
+ project: {
6
+ name: "Reports Extension Test",
7
+ force_sync: true,
8
+ },
9
+ database: {
10
+ host: "localhost",
11
+ port: 5432,
12
+ username: "test",
13
+ password: "test",
14
+ database: "reports_ext_dev",
15
+ },
16
+ web_server: {
17
+ host: "0.0.0.0",
18
+ port: 3000,
19
+ cors: {
20
+ origin: "*",
21
+ },
22
+ },
23
+ extensions: [
24
+ extension(),
25
+ ],
26
+ collections: {
27
+ donations: {
28
+ indexes: {},
29
+ fields: {
30
+ id: {
31
+ type: "integer",
32
+ },
33
+ donor_name: {
34
+ type: "string",
35
+ length: 255,
36
+ validators: {
37
+ required: true,
38
+ },
39
+ },
40
+ amount: {
41
+ type: "decimal",
42
+ validators: {
43
+ required: true,
44
+ },
45
+ },
46
+ created_at: {
47
+ type: "date",
48
+ pre_processors: {
49
+ default: "{{ now }}",
50
+ },
51
+ },
52
+ },
53
+ },
54
+ },
55
+ });
package/package.json CHANGED
@@ -1,31 +1,55 @@
1
1
  {
2
2
  "name": "@lobb-js/lobb-ext-reports",
3
- "version": "0.1.33",
3
+ "version": "0.3.1",
4
+ "license": "AGPL-3.0-only",
4
5
  "type": "module",
5
6
  "publishConfig": {
6
7
  "access": "public"
7
8
  },
8
9
  "exports": {
9
- ".": "./src/index.ts"
10
+ ".": "./extensions/reports/index.ts",
11
+ "./studio": {
12
+ "svelte": "./dist/index.js",
13
+ "types": "./dist/index.d.ts"
14
+ }
10
15
  },
11
16
  "scripts": {
12
- "dev": "vite",
13
- "build": "vite build",
17
+ "test": "bun run test:lobb && bun run test:studio",
18
+ "test:lobb": "bun test extensions/reports/tests",
19
+ "test:studio": "bun --bun playwright test --config extensions/reports/studio/tests/playwright.config.cjs",
20
+ "postinstall": "bun --bun playwright install chromium",
21
+ "dev": "bun run lobb.ts",
22
+ "start": "LOBB_MODE=prod bun run lobb.ts",
23
+ "prepare": "svelte-kit sync || echo ''",
14
24
  "preview": "vite preview",
15
- "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
25
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
26
+ "prepublishOnly": "./scripts/prepublish.sh",
27
+ "postpublish": "./scripts/postpublish.sh",
28
+ "package": "svelte-package --input extensions/reports/studio",
29
+ "dev:studio": "vite dev",
30
+ "build:studio": "vite build"
16
31
  },
17
32
  "dependencies": {
33
+ "@lobb-js/core": "0.13.1",
18
34
  "chart.js": "^4.4.8",
19
- "lodash-es": "^4.17.21"
35
+ "hono": "^4.7.0",
36
+ "lodash-es": "^4.17.21",
37
+ "openapi-types": "^12.1.3"
20
38
  },
21
39
  "devDependencies": {
22
- "@lobb-js/studio": "0.3.3",
40
+ "@faker-js/faker": "^9.6.0",
41
+ "@playwright/test": "^1.58.2",
42
+ "@lobb-js/studio": "0.7.1",
23
43
  "@lucide/svelte": "^0.563.1",
44
+ "@sveltejs/adapter-node": "^5.5.4",
45
+ "@sveltejs/kit": "^2.55.0",
24
46
  "@sveltejs/vite-plugin-svelte": "6.2.1",
47
+ "@sveltejs/package": "^2.5.7",
25
48
  "@tailwindcss/vite": "^4.1.18",
26
49
  "@tsconfig/svelte": "^5.0.6",
27
50
  "@types/lodash-es": "^4.17.12",
28
51
  "@types/node": "^24.10.1",
52
+ "bun-types": "latest",
29
53
  "clsx": "^2.1.1",
30
54
  "svelte": "^5.49.1",
31
55
  "svelte-check": "^4.3.4",
@@ -0,0 +1,12 @@
1
+ #!/bin/bash
2
+
3
+ # Postpublish script for @lobb-js/lobb-ext-reports
4
+ # Reverts package.json exports and cleans dist to avoid uncommitted changes
5
+
6
+ echo "📝 Reverting package.json exports to development mode..."
7
+ jq '."exports"["./studio"] = "./extensions/reports/studio/index.ts"' package.json > package.json.tmp && mv package.json.tmp package.json
8
+
9
+ echo "🧹 Cleaning dist directory..."
10
+ rm -rf dist
11
+
12
+ echo "✅ Postpublish complete"
@@ -0,0 +1,17 @@
1
+ #!/bin/bash
2
+
3
+ # Prepublish script for @lobb-js/lobb-ext-reports
4
+ # Builds the studio package and updates exports for publishing
5
+
6
+ echo "📦 Building studio package..."
7
+ bun run package
8
+
9
+ if [ $? -ne 0 ]; then
10
+ echo "❌ Build failed"
11
+ exit 1
12
+ fi
13
+
14
+ echo "📝 Updating package.json exports for publishing..."
15
+ jq '."exports"["./studio"] = {"svelte": "./dist/index.js", "types": "./dist/index.d.ts"}' package.json > package.json.tmp && mv package.json.tmp package.json
16
+
17
+ echo "✅ Prepublish complete"
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" href="%sveltekit.assets%/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ %sveltekit.head%
8
+ </head>
9
+ <body data-sveltekit-preload-data="hover">
10
+ <div style="display: contents">%sveltekit.body%</div>
11
+ </body>
12
+ </html>
@@ -0,0 +1,7 @@
1
+ <script lang="ts">
2
+ import "@lobb-js/studio/app.css";
3
+
4
+ let { children } = $props();
5
+ </script>
6
+
7
+ {@render children()}
@@ -0,0 +1 @@
1
+ export const ssr = false;
@@ -0,0 +1,6 @@
1
+ <script lang="ts">
2
+ import { Studio } from "@lobb-js/studio";
3
+ import { env } from "$env/dynamic/public";
4
+ </script>
5
+
6
+ <Studio lobbUrl={env.PUBLIC_LOBB_URL} />
package/svelte.config.js CHANGED
@@ -1,8 +1,24 @@
1
- import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
1
+ import adapter from '@sveltejs/adapter-node';
2
2
 
3
- /** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
4
- export default {
5
- // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
6
- // for more information about preprocessors
7
- preprocess: vitePreprocess(),
8
- }
3
+ /** @type {import('@sveltejs/kit').Config} */
4
+ const config = {
5
+ kit: {
6
+ adapter: adapter(),
7
+ paths: {
8
+ base: '/studio'
9
+ },
10
+ files: {
11
+ lib: 'studio/lib',
12
+ routes: 'studio/routes',
13
+ appTemplate: 'studio/app.html',
14
+ assets: 'public',
15
+ hooks: {
16
+ server: 'studio/hooks.server',
17
+ client: 'studio/hooks.client',
18
+ },
19
+ params: 'studio/params',
20
+ }
21
+ }
22
+ };
23
+
24
+ export default config;
package/todo.md ADDED
@@ -0,0 +1,11 @@
1
+ # high priority
2
+
3
+ - make it have two collections. `reports` and `charts`
4
+ - create the endpoint that runs the query of the report
5
+ - you would have global configuration that can effect all reports. or local
6
+ configuration that effects a single report
7
+ - there will be a sidebar with created reports. reports is a page that contains
8
+ multiple widgets of reports
9
+ - some widgets can be expanded to see more details and control over it
10
+ - support realtime by specifying a script that implements it efficienlty instead
11
+ of executing the hole query each time
package/tsconfig.app.json CHANGED
@@ -19,9 +19,9 @@
19
19
  "moduleDetection": "force",
20
20
  "baseUrl": ".",
21
21
  "paths": {
22
- "$lib": ["./src/lib"],
23
- "$lib/*": ["./src/lib/*"]
22
+ "$lib": ["./studio/lib"],
23
+ "$lib/*": ["./studio/lib/*"]
24
24
  }
25
25
  },
26
- "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
26
+ "include": ["studio/**/*.ts", "studio/**/*.js", "studio/**/*.svelte"]
27
27
  }
package/tsconfig.json CHANGED
@@ -1,7 +1,13 @@
1
1
  {
2
- "files": [],
3
- "references": [
4
- { "path": "./tsconfig.app.json" },
5
- { "path": "./tsconfig.node.json" }
6
- ]
2
+ "extends": "./.svelte-kit/tsconfig.json",
3
+ "compilerOptions": {
4
+ "allowJs": true,
5
+ "checkJs": true,
6
+ "esModuleInterop": true,
7
+ "forceConsistentCasingInFileNames": true,
8
+ "resolveJsonModule": true,
9
+ "skipLibCheck": true,
10
+ "sourceMap": true,
11
+ "strict": true
12
+ }
7
13
  }
package/vite.config.ts CHANGED
@@ -1,14 +1,8 @@
1
- import { defineConfig } from "vite";
2
- import { svelte } from "@sveltejs/vite-plugin-svelte";
3
- import { lobbStudioPlugins } from "@lobb-js/studio/vite-plugins";
1
+ import { sveltekit } from '@sveltejs/kit/vite';
2
+ import { defineConfig } from 'vite';
4
3
  import tailwindcss from "@tailwindcss/vite";
4
+ import { lobbStudioPlugins } from '@lobb-js/studio/vite-plugins';
5
5
 
6
- // https://vite.dev/config/
7
6
  export default defineConfig({
8
- base: '/studio',
9
- plugins: [
10
- ...lobbStudioPlugins(),
11
- tailwindcss(),
12
- svelte(),
13
- ],
7
+ plugins: [tailwindcss(), sveltekit(), ...lobbStudioPlugins()],
14
8
  });
package/Dockerfile DELETED
@@ -1,29 +0,0 @@
1
- # Build stage
2
- FROM node:22-alpine AS build
3
-
4
- WORKDIR /app
5
-
6
- # Copy package files.
7
- COPY package*.json ./
8
-
9
- # Install dependencies
10
- RUN npm install
11
-
12
- # Copy source code
13
- COPY . .
14
-
15
- # Build the application
16
- RUN npm run build
17
-
18
- # Production stage
19
- FROM nginx:alpine
20
-
21
- # Copy built assets
22
- COPY --from=build /app/dist /usr/share/nginx/html
23
-
24
- # Copy nginx config
25
- COPY nginx.conf /etc/nginx/conf.d/default.conf
26
-
27
- EXPOSE 80
28
-
29
- CMD ["nginx", "-g", "daemon off;"]
package/components.json DELETED
@@ -1,16 +0,0 @@
1
- {
2
- "$schema": "https://shadcn-svelte.com/schema.json",
3
- "tailwind": {
4
- "css": "src/app.css",
5
- "baseColor": "slate"
6
- },
7
- "aliases": {
8
- "components": "$lib/components",
9
- "utils": "$lib/utils",
10
- "ui": "$lib/components/ui",
11
- "hooks": "$lib/hooks",
12
- "lib": "$lib"
13
- },
14
- "typescript": true,
15
- "registry": "https://shadcn-svelte.com/registry"
16
- }
@@ -1,28 +0,0 @@
1
- // TODO: Import these types from @lobb-js/studio once available
2
- export interface Extension {
3
- name: string;
4
- onStartup?: (utils: ExtensionUtils) => void;
5
- components?: Record<string, any>;
6
- dashboardNavs?: {
7
- top?: NavItem[];
8
- middle?: NavItem[];
9
- bottom?: NavItem[];
10
- };
11
- }
12
-
13
- export interface NavItem {
14
- label: string;
15
- icon?: any;
16
- href?: string;
17
- onclick?: () => void;
18
- navs?: NavItem[];
19
- }
20
-
21
- export interface ExtensionUtils {
22
- components: {
23
- Icons: Record<string, any>;
24
- };
25
- location: {
26
- navigate: (path: string) => void;
27
- };
28
- }
package/index.html DELETED
@@ -1,13 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>Lobb Studio</title>
8
- </head>
9
- <body>
10
- <div id="app"></div>
11
- <script type="module" src="/src/main.ts"></script>
12
- </body>
13
- </html>
package/nginx.conf DELETED
@@ -1,17 +0,0 @@
1
- server {
2
- listen 80;
3
- server_name localhost;
4
- root /usr/share/nginx/html;
5
- index index.html;
6
-
7
- # Strip /studio prefix (Traefik forwards full path without stripping)
8
- location /studio {
9
- rewrite ^/studio/?(.*)$ /$1 break;
10
- try_files $uri $uri/ /index.html;
11
- }
12
-
13
- # Fallback for direct/local access
14
- location / {
15
- try_files $uri $uri/ /index.html;
16
- }
17
- }