Differenzansicht 16g-pwa
im Vergleich zu 16-guards

← Zurück zur Ãœbersicht | Demo | Quelltext auf GitHub
angular.json CHANGED
@@ -32,7 +32,8 @@
32
  "tsConfig": "tsconfig.app.json",
33
  "assets": [
34
  "src/favicon.ico",
35
- "src/assets"
 
36
  ],
37
  "styles": [
38
  "src/styles.css"
@@ -53,7 +54,8 @@
53
  "maximumError": "4kb"
54
  }
55
  ],
56
- "outputHashing": "all"
 
57
  },
58
  "development": {
59
  "optimization": false,
@@ -91,7 +93,8 @@
91
  "tsConfig": "tsconfig.spec.json",
92
  "assets": [
93
  "src/favicon.ico",
94
- "src/assets"
 
95
  ],
96
  "styles": [
97
  "src/styles.css"
32
  "tsConfig": "tsconfig.app.json",
33
  "assets": [
34
  "src/favicon.ico",
35
+ "src/assets",
36
+ "src/manifest.webmanifest"
37
  ],
38
  "styles": [
39
  "src/styles.css"
54
  "maximumError": "4kb"
55
  }
56
  ],
57
+ "outputHashing": "all",
58
+ "serviceWorker": "ngsw-config.json"
59
  },
60
  "development": {
61
  "optimization": false,
93
  "tsConfig": "tsconfig.spec.json",
94
  "assets": [
95
  "src/favicon.ico",
96
+ "src/assets",
97
+ "src/manifest.webmanifest"
98
  ],
99
  "styles": [
100
  "src/styles.css"
ngsw-config.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "./node_modules/@angular/service-worker/config/schema.json",
3
+ "index": "/index.html",
4
+ "appData": {
5
+ "version": "1.1.0",
6
+ "changelog": "Updated Version"
7
+ },
8
+ "assetGroups": [
9
+ {
10
+ "name": "app",
11
+ "installMode": "prefetch",
12
+ "resources": {
13
+ "files": [
14
+ "/favicon.ico",
15
+ "/index.html",
16
+ "/manifest.webmanifest",
17
+ "/*.css",
18
+ "/*.js"
19
+ ]
20
+ }
21
+ },
22
+ {
23
+ "name": "assets",
24
+ "installMode": "lazy",
25
+ "updateMode": "prefetch",
26
+ "resources": {
27
+ "files": [
28
+ "/assets/**",
29
+ "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
30
+ ]
31
+ }
32
+ }
33
+ ],
34
+ "dataGroups": [
35
+ {
36
+ "name": "Books",
37
+ "urls": [
38
+ "/books",
39
+ "/books/search/**",
40
+ "/books/**"
41
+ ],
42
+ "cacheConfig": {
43
+ "strategy": "freshness",
44
+ "maxSize": 50,
45
+ "maxAge": "1d2h",
46
+ "timeout": "3s"
47
+ }
48
+ }
49
+ ]
50
+ }
package.json CHANGED
@@ -19,6 +19,7 @@
19
  "@angular/platform-browser": "^17.3.0",
20
  "@angular/platform-browser-dynamic": "^17.3.0",
21
  "@angular/router": "^17.3.0",
 
22
  "angular-date-value-accessor": "^3.0.0",
23
  "book-monkey5-styles": "^1.0.4",
24
  "rxjs": "~7.8.0",
@@ -46,4 +47,4 @@
46
  "karma-jasmine-html-reporter": "~2.1.0",
47
  "typescript": "~5.4.2"
48
  }
49
- }
19
  "@angular/platform-browser": "^17.3.0",
20
  "@angular/platform-browser-dynamic": "^17.3.0",
21
  "@angular/router": "^17.3.0",
22
+ "@angular/service-worker": "^17.3.0",
23
  "angular-date-value-accessor": "^3.0.0",
24
  "book-monkey5-styles": "^1.0.4",
25
  "rxjs": "~7.8.0",
47
  "karma-jasmine-html-reporter": "~2.1.0",
48
  "typescript": "~5.4.2"
49
  }
50
+ }
src/app/admin/book-create/book-create.component.spec.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { BookCreateComponent } from './book-create.component';
4
+
5
+ describe('BookCreateComponent', () => {
6
+ let component: BookCreateComponent;
7
+ let fixture: ComponentFixture<BookCreateComponent>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ declarations: [ BookCreateComponent ]
12
+ })
13
+ .compileComponents();
14
+
15
+ fixture = TestBed.createComponent(BookCreateComponent);
16
+ component = fixture.componentInstance;
17
+ fixture.detectChanges();
18
+ });
19
+
20
+ it('should create', () => {
21
+ expect(component).toBeTruthy();
22
+ });
23
+ });
src/app/admin/book-edit/book-edit.component.spec.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { BookEditComponent } from './book-edit.component';
4
+
5
+ describe('BookEditComponent', () => {
6
+ let component: BookEditComponent;
7
+ let fixture: ComponentFixture<BookEditComponent>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ declarations: [ BookEditComponent ]
12
+ })
13
+ .compileComponents();
14
+
15
+ fixture = TestBed.createComponent(BookEditComponent);
16
+ component = fixture.componentInstance;
17
+ fixture.detectChanges();
18
+ });
19
+
20
+ it('should create', () => {
21
+ expect(component).toBeTruthy();
22
+ });
23
+ });
src/app/admin/book-form/book-form.component.spec.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { BookFormComponent } from './book-form.component';
4
+
5
+ describe('BookFormComponent', () => {
6
+ let component: BookFormComponent;
7
+ let fixture: ComponentFixture<BookFormComponent>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ declarations: [ BookFormComponent ]
12
+ })
13
+ .compileComponents();
14
+
15
+ fixture = TestBed.createComponent(BookFormComponent);
16
+ component = fixture.componentInstance;
17
+ fixture.detectChanges();
18
+ });
19
+
20
+ it('should create', () => {
21
+ expect(component).toBeTruthy();
22
+ });
23
+ });
src/app/admin/form-errors/form-errors.component.spec.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { FormErrorsComponent } from './form-errors.component';
4
+
5
+ describe('FormErrorsComponent', () => {
6
+ let component: FormErrorsComponent;
7
+ let fixture: ComponentFixture<FormErrorsComponent>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ declarations: [ FormErrorsComponent ]
12
+ })
13
+ .compileComponents();
14
+
15
+ fixture = TestBed.createComponent(FormErrorsComponent);
16
+ component = fixture.componentInstance;
17
+ fixture.detectChanges();
18
+ });
19
+
20
+ it('should create', () => {
21
+ expect(component).toBeTruthy();
22
+ });
23
+ });
src/app/admin/shared/async-validators.service.spec.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { TestBed } from '@angular/core/testing';
2
+
3
+ import { AsyncValidatorsService } from './async-validators.service';
4
+
5
+ describe('AsyncValidatorsService', () => {
6
+ let service: AsyncValidatorsService;
7
+
8
+ beforeEach(() => {
9
+ TestBed.configureTestingModule({});
10
+ service = TestBed.inject(AsyncValidatorsService);
11
+ });
12
+
13
+ it('should be created', () => {
14
+ expect(service).toBeTruthy();
15
+ });
16
+ });
src/app/app.component.html CHANGED
@@ -9,6 +9,12 @@
9
  <button class="red"
10
  (click)="auth.logout()"
11
  *ngIf="auth.isAuthenticated">Logout</button>
 
 
 
 
 
 
12
  </div>
13
  </nav>
14
 
9
  <button class="red"
10
  (click)="auth.logout()"
11
  *ngIf="auth.isAuthenticated">Logout</button>
12
+ <button
13
+ (click)="requestSubscription()"
14
+ [class.green]="permission === 'granted'"
15
+ [class.red]="permission === 'denied'"
16
+ [disabled]="!permission"
17
+ aria-label="Notifications">!</button>
18
  </div>
19
  </nav>
20
 
src/app/app.component.ts CHANGED
@@ -1,6 +1,13 @@
1
  import { Component } from '@angular/core';
 
2
 
3
  import { AuthService } from './shared/auth.service';
 
 
 
 
 
 
4
 
5
  @Component({
6
  selector: 'bm-root',
@@ -8,5 +15,62 @@ import { AuthService } from './shared/auth.service';
8
  styleUrls: ['./app.component.css']
9
  })
10
  export class AppComponent {
11
- constructor(public auth: AuthService) {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  }
1
  import { Component } from '@angular/core';
2
+ import { SwUpdate } from '@angular/service-worker';
3
 
4
  import { AuthService } from './shared/auth.service';
5
+ import { WebNotificationService } from './shared/web-notification.service';
6
+
7
+ interface AppData {
8
+ version: string;
9
+ changelog: string;
10
+ }
11
 
12
  @Component({
13
  selector: 'bm-root',
15
  styleUrls: ['./app.component.css']
16
  })
17
  export class AppComponent {
18
+ permission?: NotificationPermission;
19
+
20
+ constructor(
21
+ public auth: AuthService,
22
+ private swUpdate: SwUpdate,
23
+ private notificationService: WebNotificationService
24
+ ) {
25
+ this.swUpdate.versionUpdates.subscribe(e => {
26
+ switch (e.type) {
27
+ case 'VERSION_DETECTED': {
28
+ console.log(
29
+ 'Downloading new app version:',
30
+ e.version.appData
31
+ );
32
+ break;
33
+ }
34
+
35
+ case 'VERSION_READY': {
36
+ const current = e.currentVersion.appData as AppData;
37
+ const latest = e.latestVersion.appData as AppData;
38
+
39
+ const from = current.version;
40
+ const to = latest.version;
41
+ const changes = latest.changelog;
42
+
43
+ const confirmText = `Update from ${from} to ${to}. Changes: ${changes}. Install?`;
44
+
45
+ if (window.confirm(confirmText)) {
46
+ window.location.reload();
47
+ }
48
+ break;
49
+ }
50
+
51
+ case 'VERSION_INSTALLATION_FAILED': {
52
+ console.log(
53
+ `Failed to install ${e.version.appData}:`,
54
+ e.error
55
+ );
56
+ break;
57
+ }
58
+ }
59
+ });
60
+
61
+ if (this.notificationService.isEnabled) {
62
+ this.setPermission();
63
+ }
64
+ }
65
+
66
+ private setPermission() {
67
+ if ('Notification' in window) {
68
+ this.permission = Notification.permission;
69
+ }
70
+ }
71
+
72
+ requestSubscription() {
73
+ this.notificationService.requestSubscription()
74
+ .subscribe(() => this.setPermission());
75
+ }
76
  }
src/app/app.module.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { NgModule } from '@angular/core';
2
  import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
3
  import { BrowserModule } from '@angular/platform-browser';
 
4
 
5
  import { AppRoutingModule } from './app-routing.module';
6
  import { AppComponent } from './app.component';
@@ -18,6 +19,12 @@ import { AuthInterceptor } from './shared/auth.interceptor';
18
  BrowserModule,
19
  AppRoutingModule,
20
  HttpClientModule,
 
 
 
 
 
 
21
  ],
22
  providers: [
23
  {
1
+ import { NgModule, isDevMode } from '@angular/core';
2
  import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
3
  import { BrowserModule } from '@angular/platform-browser';
4
+ import { ServiceWorkerModule } from '@angular/service-worker';
5
 
6
  import { AppRoutingModule } from './app-routing.module';
7
  import { AppComponent } from './app.component';
19
  BrowserModule,
20
  AppRoutingModule,
21
  HttpClientModule,
22
+ ServiceWorkerModule.register('ngsw-worker.js', {
23
+ enabled: !isDevMode(),
24
+ // Register the ServiceWorker as soon as the application is stable
25
+ // or after 30 seconds (whichever comes first).
26
+ registrationStrategy: 'registerWhenStable:30000'
27
+ })
28
  ],
29
  providers: [
30
  {
src/app/books/book-details/book-details.component.spec.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { BookDetailsComponent } from './book-details.component';
4
+
5
+ describe('BookDetailsComponent', () => {
6
+ let component: BookDetailsComponent;
7
+ let fixture: ComponentFixture<BookDetailsComponent>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ declarations: [ BookDetailsComponent ]
12
+ })
13
+ .compileComponents();
14
+
15
+ fixture = TestBed.createComponent(BookDetailsComponent);
16
+ component = fixture.componentInstance;
17
+ fixture.detectChanges();
18
+ });
19
+
20
+ it('should create', () => {
21
+ expect(component).toBeTruthy();
22
+ });
23
+ });
src/app/books/book-list/book-list.component.spec.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { BookListComponent } from './book-list.component';
4
+
5
+ describe('BookListComponent', () => {
6
+ let component: BookListComponent;
7
+ let fixture: ComponentFixture<BookListComponent>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ declarations: [ BookListComponent ]
12
+ })
13
+ .compileComponents();
14
+
15
+ fixture = TestBed.createComponent(BookListComponent);
16
+ component = fixture.componentInstance;
17
+ fixture.detectChanges();
18
+ });
19
+
20
+ it('should create', () => {
21
+ expect(component).toBeTruthy();
22
+ });
23
+ });
src/app/books/shared/loggedin-only.directive.spec.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
1
+ import { LoggedinOnlyDirective } from './loggedin-only.directive';
2
+
3
+ describe('LoggedinOnlyDirective', () => {
4
+ it('should create an instance', () => {
5
+ const directive = new LoggedinOnlyDirective();
6
+ expect(directive).toBeTruthy();
7
+ });
8
+ });
src/app/home/home.component.spec.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { NO_ERRORS_SCHEMA } from '@angular/core';
2
  import { ComponentFixture, TestBed } from '@angular/core/testing';
3
 
4
  import { HomeComponent } from './home.component';
@@ -9,8 +8,7 @@ describe('HomeComponent', () => {
9
 
10
  beforeEach(async () => {
11
  await TestBed.configureTestingModule({
12
- declarations: [ HomeComponent ],
13
- schemas: [NO_ERRORS_SCHEMA]
14
  })
15
  .compileComponents();
16
 
 
1
  import { ComponentFixture, TestBed } from '@angular/core/testing';
2
 
3
  import { HomeComponent } from './home.component';
8
 
9
  beforeEach(async () => {
10
  await TestBed.configureTestingModule({
11
+ declarations: [ HomeComponent ]
 
12
  })
13
  .compileComponents();
14
 
src/app/search/search.component.spec.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { SearchComponent } from './search.component';
4
+
5
+ describe('SearchComponent', () => {
6
+ let component: SearchComponent;
7
+ let fixture: ComponentFixture<SearchComponent>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ declarations: [ SearchComponent ]
12
+ })
13
+ .compileComponents();
14
+
15
+ fixture = TestBed.createComponent(SearchComponent);
16
+ component = fixture.componentInstance;
17
+ fixture.detectChanges();
18
+ });
19
+
20
+ it('should create', () => {
21
+ expect(component).toBeTruthy();
22
+ });
23
+ });
src/app/shared/book-store.service.spec.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { TestBed } from '@angular/core/testing';
2
+
3
+ import { BookStoreService } from './book-store.service';
4
+
5
+ describe('BookStoreService', () => {
6
+ let service: BookStoreService;
7
+
8
+ beforeEach(() => {
9
+ TestBed.configureTestingModule({});
10
+ service = TestBed.inject(BookStoreService);
11
+ });
12
+
13
+ it('should be created', () => {
14
+ expect(service).toBeTruthy();
15
+ });
16
+ });
src/app/shared/web-notification.service.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable } from '@angular/core';
2
+ import { HttpClient } from '@angular/common/http';
3
+ import { SwPush } from '@angular/service-worker';
4
+ import { concatMap, from, Observable } from 'rxjs';
5
+ import { Router } from '@angular/router';
6
+
7
+ @Injectable({
8
+ providedIn: 'root',
9
+ })
10
+ export class WebNotificationService {
11
+ readonly VAPID_PUBLIC_KEY = 'BGk2Rx3DEjXdRv9qP8aKrypFoNjISAZ54l-3V05xpPOV-5ZQJvVH9OB9Rz5Ug7H_qH6CEr40f4Pi3DpjzYLbfCA';
12
+ private baseUrl = 'https://api5.angular-buch.com/notifications';
13
+
14
+ constructor(private http: HttpClient, private swPush: SwPush, private router: Router) {
15
+ // when user clicks a notification
16
+ this.swPush.notificationClicks.subscribe(e => {
17
+ // if ISBN is given, navigate to detail page
18
+ const data = e.notification.data;
19
+ if (data?.book?.isbn) {
20
+ this.router.navigate(['/books', data.book.isbn])
21
+ }
22
+ });
23
+ }
24
+
25
+ get isEnabled() {
26
+ return this.swPush.isEnabled;
27
+ }
28
+
29
+ // request a subscription from the browser and register it on the server
30
+ requestSubscription(): Observable<unknown> {
31
+ const request = this.swPush.requestSubscription({
32
+ serverPublicKey: this.VAPID_PUBLIC_KEY
33
+ });
34
+
35
+ return from(request).pipe(
36
+ concatMap(sub => this.registerOnServer(sub))
37
+ );
38
+ }
39
+
40
+ private registerOnServer(params: PushSubscription) {
41
+ return this.http.post(this.baseUrl, params);
42
+ }
43
+ }
src/index.html CHANGED
@@ -6,10 +6,18 @@
6
  <base href="/">
7
  <meta name="viewport" content="width=device-width, initial-scale=1">
8
  <link rel="icon" type="image/x-icon" href="favicon.ico">
 
 
 
 
 
 
 
9
  </head>
10
  <body>
11
  <bm-root>
12
  <div class="loader">Loading ...</div>
13
  </bm-root>
 
14
  </body>
15
  </html>
6
  <base href="/">
7
  <meta name="viewport" content="width=device-width, initial-scale=1">
8
  <link rel="icon" type="image/x-icon" href="favicon.ico">
9
+ <link rel="manifest" href="manifest.webmanifest">
10
+ <meta name="theme-color" content="#1976d2">
11
+ <meta name="apple-mobile-web-app-status-bar-style" content="default">
12
+ <meta name="apple-mobile-web-app-capable" content="yes">
13
+ <link rel="apple-touch-startup-image" href="assets/icons/apple-splash-screen.png">
14
+ <link rel="apple-touch-icon" href="assets/icons/icon-512x512.png">
15
+ <link rel="apple-touch-icon" sizes="152x152" href="assets/icons/icon-152x152.png">
16
  </head>
17
  <body>
18
  <bm-root>
19
  <div class="loader">Loading ...</div>
20
  </bm-root>
21
+ <noscript>Please enable JavaScript to continue using this application.</noscript>
22
  </body>
23
  </html>
src/manifest.webmanifest ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "book-monkey",
3
+ "short_name": "book-monkey",
4
+ "theme_color": "#1976d2",
5
+ "background_color": "#fafafa",
6
+ "display": "standalone",
7
+ "scope": "./",
8
+ "start_url": "./",
9
+ "icons": [
10
+ {
11
+ "src": "assets/icons/icon-72x72.png",
12
+ "sizes": "72x72",
13
+ "type": "image/png",
14
+ "purpose": "maskable any"
15
+ },
16
+ {
17
+ "src": "assets/icons/icon-96x96.png",
18
+ "sizes": "96x96",
19
+ "type": "image/png",
20
+ "purpose": "maskable any"
21
+ },
22
+ {
23
+ "src": "assets/icons/icon-128x128.png",
24
+ "sizes": "128x128",
25
+ "type": "image/png",
26
+ "purpose": "maskable any"
27
+ },
28
+ {
29
+ "src": "assets/icons/icon-144x144.png",
30
+ "sizes": "144x144",
31
+ "type": "image/png",
32
+ "purpose": "maskable any"
33
+ },
34
+ {
35
+ "src": "assets/icons/icon-152x152.png",
36
+ "sizes": "152x152",
37
+ "type": "image/png",
38
+ "purpose": "maskable any"
39
+ },
40
+ {
41
+ "src": "assets/icons/icon-192x192.png",
42
+ "sizes": "192x192",
43
+ "type": "image/png",
44
+ "purpose": "maskable any"
45
+ },
46
+ {
47
+ "src": "assets/icons/icon-384x384.png",
48
+ "sizes": "384x384",
49
+ "type": "image/png",
50
+ "purpose": "maskable any"
51
+ },
52
+ {
53
+ "src": "assets/icons/icon-512x512.png",
54
+ "sizes": "512x512",
55
+ "type": "image/png",
56
+ "purpose": "maskable any"
57
+ }
58
+ ]
59
+ }