Added TTS chat filters.

This commit is contained in:
Tom 2024-12-28 01:37:44 +00:00
parent 0afa2138b4
commit 275069697f
30 changed files with 722 additions and 192 deletions

View File

@ -1,19 +1,20 @@
import { CommonModule, DatePipe, isPlatformBrowser } from '@angular/common';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, OnInit, Inject, PLATFORM_ID, NgZone, OnDestroy } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';
import { FormsModule } from '@angular/forms'
import { HermesClientService } from './hermes-client.service';
import { AuthUserGuard } from './shared/auth/auth.user.guard'
import { Subscription } from 'rxjs';
import { PolicyComponent } from "./policies/policy/policy.component";
import { NavigationComponent } from "./navigation/navigation.component";
import EventService from './shared/services/EventService';
import { ApiAuthenticationService } from './shared/services/api/api-authentication.service';
import { PoliciesModule } from './policies/policies.module';
import { TtsFiltersModule } from './tts-filters/tts-filters.module';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, CommonModule, FormsModule, PolicyComponent, NavigationComponent],
imports: [RouterOutlet, CommonModule, FormsModule, PoliciesModule, TtsFiltersModule, NavigationComponent],
providers: [AuthUserGuard],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
@ -22,7 +23,6 @@ export class AppComponent implements OnInit, OnDestroy {
private isBrowser: boolean;
private ngZone: NgZone;
private subscriptions: Subscription[];
pipe = new DatePipe('en-US')
constructor(private auth: ApiAuthenticationService, private client: HermesClientService, private events: EventService, private router: Router, ngZone: NgZone, @Inject(PLATFORM_ID) private platformId: Object) {

View File

@ -4,6 +4,7 @@ import { AuthUserGuard } from './shared/auth/auth.user.guard';
import { LoginComponent } from './login/login.component';
import { TtsLoginComponent } from './tts-login/tts-login.component';
import { TwitchAuthCallbackComponent } from './twitch-auth-callback/twitch-auth-callback.component';
import { FiltersComponent } from './tts-filters/filters/filters.component';
export const routes: Routes = [
{
@ -11,6 +12,11 @@ export const routes: Routes = [
component: PolicyComponent,
canActivate: [AuthUserGuard],
},
{
path: 'filters',
component: FiltersComponent,
canActivate: [AuthUserGuard],
},
{
path: 'login',
component: LoginComponent,

View File

@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { DatePipe } from '@angular/common';
import { HermesSocketService } from './hermes-socket.service';
import EventService from './shared/services/EventService';
import { Observable } from 'rxjs';
export interface Message {
d: object,
@ -13,7 +14,8 @@ export interface Message {
providedIn: 'root'
})
export class HermesClientService {
pipe = new DatePipe('en-US')
pipe = new DatePipe('en-US');
session_id: string|undefined;
connected: boolean;
logged_in: boolean;
@ -44,10 +46,15 @@ export class HermesClientService {
this.connected = false;
this.logged_in = false;
this.session_id = undefined;
this.socket.close();
this.events.emit('tts_logoff', null);
}
public first(predicate: (data: any) => boolean): Observable<any> | null {
return this.socket.first(predicate);
}
private send(op: number, data: any) {
if (op != 0)
console.log("TX:", data);
@ -85,6 +92,17 @@ export class HermesClientService {
});
}
public createTTSFilter(search: string, replace: string) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "create_tts_filter",
data: { search, replace },
});
}
public deletePolicy(id: string) {
if (!this.logged_in)
return;
@ -92,19 +110,29 @@ export class HermesClientService {
this.send(3, {
request_id: null,
type: "delete_policy",
data: {
id
},
data: { id },
});
}
public fetchPolicies() {
public deleteTTSFilter(id: string) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "get_policies",
type: "delete_tts_filter",
data: { id },
nounce: this.session_id,
});
}
public fetchFilters() {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "get_tts_word_filters",
data: null,
});
}
@ -120,6 +148,17 @@ export class HermesClientService {
});
}
public fetchPolicies() {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "get_policies",
data: null,
});
}
public heartbeat() {
const date = new Date()
this.send(0, {
@ -147,6 +186,18 @@ export class HermesClientService {
});
}
public updateTTSFilter(id: string, search: string, replace: string) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "update_tts_filter",
data: { id, search, replace },
nounce: this.session_id,
});
}
private listen() {
return this.socket.subscribe({
next: (message: any) => {
@ -156,12 +207,13 @@ export class HermesClientService {
console.log("Heartbeat received. Potential connection problem?");
break;
case 2: // Login Ack
console.log("Login successful.");
console.log("Login successful.", message.d.session_id);
this.logged_in = true;
this.session_id = message.d.session_id;
this.events.emit('tts_login_ack', null);
break;
case 4: // Request Ack
console.log("Request received.");
console.log("Request ack received.");
break;
}
if (message.op in this.subscriptions) {
@ -170,7 +222,15 @@ export class HermesClientService {
action(message.d);
}
},
error: (err: any) => console.error('Websocket error', err),
error: (err: any) => {
console.error('Websocket error', err);
if (err.type == 'close') {
this.connected = false;
this.logged_in = false;
this.socket.close();
this.events.emit('tts_logoff', null);
}
},
complete: () => console.log('Websocket disconnected.')
});
}

View File

@ -1,15 +1,14 @@
import { Component, OnInit, Injectable } from '@angular/core';
import { OnInit, Injectable } from '@angular/core';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { catchError, tap, switchAll } from 'rxjs/operators';
import { EMPTY, Observer, Subject } from 'rxjs';
import { catchError, filter, first, timeout } from 'rxjs/operators';
import { environment } from '../environments/environment';
import { Observable, throwError } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class HermesSocketService implements OnInit {
private WS_ENDPOINT = environment.WSS_ENDPOINT;
private socket: WebSocketSubject<any> | undefined = undefined
constructor() { }
@ -23,6 +22,13 @@ export class HermesSocketService implements OnInit {
}
}
public first(predicate: (data: any) => boolean): Observable<any>|null {
if (!this.socket || this.socket.closed)
return null;
return this.socket.pipe(timeout(3000), catchError((e) => throwError(() => 'No response after 3 seconds.')), first(predicate));
}
private getNewWebSocket() {
return webSocket({
url: environment.WSS_ENDPOINT
@ -31,21 +37,21 @@ export class HermesSocketService implements OnInit {
public sendMessage(msg: any) {
if (!this.socket || this.socket.closed)
return
return;
this.socket.next(msg);
}
public subscribe(subscriptions: any) {
if (!this.socket || this.socket.closed)
return
return;
return this.socket.subscribe(subscriptions);
}
public close() {
if (!this.socket || this.socket.closed)
return
return;
this.socket.complete();
}

View File

@ -25,5 +25,13 @@
Policies
</a>
</li>
<li>
<a
routerLink="/filters"
routerLinkActive="active"
*ngIf="isLoggedIn() && isTTSLoggedIn()">
Filters
</a>
</li>
</ul>
</nav>

View File

@ -29,7 +29,7 @@ a {
}
a:hover {
background-color: #FCFCFC;
background-color: #FAFAFA;
}
a.active {

View File

@ -1,12 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PolicyComponent } from './policy/policy.component';
import { PolicyTableComponent } from './policy-table/policy-table.component';
import { PolicyAddFormComponent } from './policy-add-form/policy-add-form.component';
@NgModule({
declarations: [],
imports: [
CommonModule
PolicyComponent, PolicyTableComponent, PolicyAddFormComponent
]
})
export class PoliciesModule { }

View File

@ -1,10 +1,9 @@
import { AsyncPipe } from '@angular/common';
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
import { Component } from '@angular/core';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { Policy } from '../../shared/models/policy';
import EventService from '../../shared/services/EventService';
import { map, Observable, startWith } from 'rxjs';
import { HermesClientService } from '../../hermes-client.service';
@ -15,7 +14,7 @@ const Policies = [
{ path: "tts.chat.bits.read", description: "To read chat messages with bits via TTS" },
{ path: "tts.chat.messages.read", description: "To read chat messages via TTS" },
{ path: "tts.chat.redemptions.read", description: "To read channel point redemption messages via TTS" },
//{ path: "tts.chat.subscriptions.read", description: "To read chat messages from subscriptions via TTS" },
{ path: "tts.chat.subscriptions.read", description: "To read chat messages from subscriptions via TTS" },
{ path: "tts.commands", description: "To execute commands for TTS" },
{ path: "tts.commands.nightbot", description: "To use !nightbot command" },
{ path: "tts.commands.obs", description: "To use !obs command" },

View File

@ -19,7 +19,7 @@
</ng-container>
<ng-container matColumnDef="usage">
<th mat-header-cell *matHeaderCellDef>Usage per span</th>
<th mat-header-cell *matHeaderCellDef>Usage Rate</th>
<td mat-cell *matCellDef="let policy">
@if (policy.editing) {
<input type="number" [(ngModel)]="policy.usage" (keypress)="($event.charCode >= 48 && $event.charCode < 58)" />

View File

@ -1,7 +1,3 @@
div {
background-color: black;
}
h4 {
text-align: center;
}

View File

@ -1,10 +1,8 @@
import { Component, Inject, NgZone, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { PolicyAddFormComponent } from "../policy-add-form/policy-add-form.component";
import { PolicyTableComponent } from "../policy-table/policy-table.component";
import { Policy, PolicyScope } from '../../shared/models/policy';
import { DatePipe, isPlatformBrowser } from '@angular/common';
import { OAuthService } from 'angular-oauth2-oidc';
import { Subscription } from 'rxjs';
import { isPlatformBrowser } from '@angular/common';
import { HermesClientService } from '../../hermes-client.service';
import { Router, RouterModule } from '@angular/router';
@ -17,14 +15,10 @@ import { Router, RouterModule } from '@angular/router';
})
export class PolicyComponent implements OnInit, OnDestroy {
private isBrowser: boolean;
private ngZone: NgZone;
private subscription: Subscription | undefined;
items: Policy[];
pipe = new DatePipe('en-US')
constructor(private client: HermesClientService, private oauthService: OAuthService, private router: Router, ngZone: NgZone, @Inject(PLATFORM_ID) private platformId: Object) {
this.ngZone = ngZone;
constructor(private client: HermesClientService, private router: Router, @Inject(PLATFORM_ID) private platformId: Object) {
this.isBrowser = isPlatformBrowser(this.platformId)
this.items = []
@ -42,12 +36,8 @@ export class PolicyComponent implements OnInit, OnDestroy {
this.router.navigate(["/tts-login"]);
return;
}
this.subscription = this.client.connect();
}
ngOnDestroy() {
if (this.subscription)
this.subscription.unsubscribe()
}
}

View File

@ -0,0 +1,13 @@
export enum FilterFlag {
None = 0,
IgnoreCase = 1,
}
export interface Filter {
id: string;
search: string;
replace: string;
user_id: string;
flag: FilterFlag;
is_regex: boolean;
}

View File

@ -25,7 +25,7 @@ export class ApiAuthenticationService {
}
getImpersonatedId() {
return this.user.impersonation?.id;
return this.user?.impersonation?.id;
}
getUsername() {
@ -45,7 +45,6 @@ export class ApiAuthenticationService {
'Authorization': 'Bearer ' + jwt
}
}).subscribe((data: any) => {
console.log('jwt validation', data);
this.updateAuthenticated(data?.authenticated, data?.user);
});
}

View File

@ -0,0 +1,15 @@
<h2 mat-dialog-title>TTS Filter</h2>
<mat-dialog-content>
<mat-form-field>
<mat-label>Search</mat-label>
<input matInput [(ngModel)]="search" />
</mat-form-field>
<mat-form-field>
<mat-label>Replace</mat-label>
<input matInput [(ngModel)]="replace" />
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button (click)="onCancelClick()">Cancel</button>
<button mat-button [mat-dialog-close]="onSaveClick()" cdkFocusInitial>Save</button>
</mat-dialog-actions>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FilterItemEditComponent } from './filter-item-edit.component';
describe('FilterItemEditComponent', () => {
let component: FilterItemEditComponent;
let fixture: ComponentFixture<FilterItemEditComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FilterItemEditComponent]
})
.compileComponents();
fixture = TestBed.createComponent(FilterItemEditComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,42 @@
import { Component, inject, model } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogActions, MatDialogClose, MatDialogTitle, MatDialogContent } from '@angular/material/dialog';
import { Filter } from '../../shared/models/filter';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'tts-filter-item-edit',
standalone: true,
imports: [
FormsModule,
MatButtonModule,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle,
MatFormFieldModule,
MatInputModule,
],
templateUrl: './filter-item-edit.component.html',
styleUrl: './filter-item-edit.component.scss'
})
export class FilterItemEditComponent {
readonly dialogRef = inject(MatDialogRef<FilterItemEditComponent>);
readonly data = inject<Filter>(MAT_DIALOG_DATA);
readonly search = model(this.data.search);
readonly replace = model(this.data.replace);
readonly flag = model(this.data.flag);
onSaveClick(): Filter {
this.data.search = this.search();
this.data.replace = this.replace();
this.data.flag = this.flag();
return this.data;
}
onCancelClick(): void {
this.dialogRef.close();
}
}

View File

@ -0,0 +1,18 @@
<ul>
<li>
{{item.search}}
</li>
<li>
{{item.replace}}
</li>
<li>
<mat-menu #filterMenu>
<button mat-menu-item (click)="openDialog()">Edit</button>
<button mat-menu-item (click)="onDelete.emit(item)">Delete</button>
</mat-menu>
<button mat-icon-button [matMenuTriggerFor]="filterMenu">
<mat-icon>more_vert</mat-icon>
</button>
</li>
</ul>

View File

@ -0,0 +1,33 @@
input {
display: inline;
font-size: large;
row-gap: 2em;
}
ul {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
li {
list-style-type: none;
white-space: pre;
text-align: justify;
text-wrap: wrap;
> button {
background: #dddddd;
border-radius: 50%;
:hover {
border-radius: 50%;
}
}
}
li:nth-child(1),
li:nth-child(2) {
flex: 1;
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FilterItemComponent } from './filter-item.component';
describe('FilterItemComponent', () => {
let component: FilterItemComponent;
let fixture: ComponentFixture<FilterItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FilterItemComponent]
})
.compileComponents();
fixture = TestBed.createComponent(FilterItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,50 @@
import { Component, EventEmitter, inject, Input, OnInit, Output, signal } from '@angular/core';
import { Filter, FilterFlag } from '../../shared/models/filter';
import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon';
import { MatDialog } from '@angular/material/dialog';
import { FilterItemEditComponent } from '../filter-item-edit/filter-item-edit.component';
import { MatButtonModule } from '@angular/material/button';
import { HermesClientService } from '../../hermes-client.service';
@Component({
selector: 'tts-filter-item',
standalone: true,
imports: [MatButtonModule, MatCardModule, MatMenuModule, MatIconModule],
templateUrl: './filter-item.component.html',
styleUrl: './filter-item.component.scss'
})
export class FilterItemComponent implements OnInit {
@Input() item: Filter = { id: "", user_id: "", search: "", replace: "", flag: FilterFlag.None, is_regex: false };
@Output() onDelete = new EventEmitter<Filter>();
readonly client = inject(HermesClientService);
readonly dialog = inject(MatDialog);
private loaded = false;
ngOnInit(): void {
this.loaded = true;
}
openDialog(): void {
if (!this.loaded)
return;
const dialogRef = this.dialog.open(FilterItemEditComponent, {
data: { id: this.item.id, search: this.item.search, replace: this.item.replace },
});
dialogRef.afterClosed().subscribe(result => {
if (result !== undefined) {
console.log('update filter', result);
this.client.first(d => d.op == 4 && d.d.request.type == 'update_tts_filter' && d.d.data.id == this.item.id)
?.subscribe(_ => {
this.item.search = result.search;
this.item.replace = result.replace;
});
this.client.updateTTSFilter(this.item.id, result.search, result.replace);
}
});
}
}

View File

@ -0,0 +1,18 @@
<div>
<ul>
<li>
<ul class="header">
<li>Search</li>
<li>Replace</li>
<li></li>
</ul>
</li>
@for (filter of filters; track $index) {
<li>
<tts-filter-item
[item]="filter"
(onDelete)="deleteFilter($event)" />
</li>
}
</ul>
</div>

View File

@ -0,0 +1,40 @@
ul {
margin: 0;
padding: 0;
}
li {
display: block;
list-style-type: none;
padding: 0.75em 1em;
border-bottom: 1px solid #aaaaaa;
}
li:first-child {
padding: 0 1em;
border-bottom: 0 solid #aaaaaa;
}
li:last-child {
border-bottom: 0 solid #aaaaaa;
}
ul.header {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
li {
border-bottom: 0 solid #aaaaaa;
font-weight: 500;
list-style-type: none;
white-space: pre;
text-align: justify;
}
li:nth-child(1),
li:nth-child(2) {
flex: 1;
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FilterListComponent } from './filter-list.component';
describe('FilterListComponent', () => {
let component: FilterListComponent;
let fixture: ComponentFixture<FilterListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FilterListComponent]
})
.compileComponents();
fixture = TestBed.createComponent(FilterListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,22 @@
import { Component, EventEmitter, inject, Input, Output } from '@angular/core';
import { FilterItemComponent } from '../filter-item/filter-item.component';
import { Filter, FilterFlag } from '../../shared/models/filter';
import { HermesClientService } from '../../hermes-client.service';
@Component({
selector: 'tts-filter-list',
standalone: true,
imports: [FilterItemComponent],
templateUrl: './filter-list.component.html',
styleUrl: './filter-list.component.scss'
})
export class FilterListComponent {
@Input() filters: Filter[] = [];
client = inject(HermesClientService);
deleteFilter(e: any): void {
console.log('deleting', e);
this.client.deleteTTSFilter(e.id);
this.filters = this.filters.filter(f => f.id != e.id);
}
}

View File

@ -0,0 +1,14 @@
<main>
<article>
<h3>Filters</h3>
<div>
<button mat-fab aria-label="Add a filter" (click)="openDialog()">
<mat-icon>add</mat-icon>
</button>
</div>
</article>
<div>
<tts-filter-list
[filters]="items" />
</div>
</main>

View File

@ -0,0 +1,5 @@
article {
display: flex;
justify-content: space-between;
width: 70%;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FiltersComponent } from './filters.component';
describe('FiltersComponent', () => {
let component: FiltersComponent;
let fixture: ComponentFixture<FiltersComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FiltersComponent]
})
.compileComponents();
fixture = TestBed.createComponent(FiltersComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,91 @@
import { Component, inject, Inject, Input, OnDestroy, OnInit, PLATFORM_ID, signal } from '@angular/core';
import { FilterListComponent } from "../filter-list/filter-list.component";
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { HermesClientService } from '../../hermes-client.service';
import { Filter } from '../../shared/models/filter';
import { isPlatformBrowser } from '@angular/common';
import { Router } from '@angular/router';
import { FilterItemEditComponent } from '../filter-item-edit/filter-item-edit.component';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
@Component({
selector: 'filters',
standalone: true,
imports: [FilterListComponent, MatButtonModule, MatIconModule],
templateUrl: './filters.component.html',
styleUrl: './filters.component.scss'
})
export class FiltersComponent implements OnInit, OnDestroy {
private isBrowser: boolean;
readonly dialog = inject(MatDialog);
items: Filter[];
constructor(private client: HermesClientService, private router: Router, @Inject(PLATFORM_ID) private platformId: Object) {
this.isBrowser = isPlatformBrowser(this.platformId);
this.items = []
this.client.subscribe(4, d => {
const type = d.request.type;
console.log('filters', type, d.data);
if (type == 'get_tts_word_filters') {
this.items = d.data;
return;
}
if (d.request.nounce == client.session_id) {
console.log('from us. ignore.');
return;
}
if (type == 'create_tts_filter') {
console.log('create filter', d.data);
this.items = [d.data, ...this.items];
} else if (type == 'delete_tts_filter') {
console.log('delete filter', d.data);
this.items = this.items.filter(i => i.id != d.data.id);
} else if (type == 'update_tts_filter') {
console.log('update filter', d.data);
const filter = this.items.find(f => f.id == d.data.id);
if (filter == null)
return;
filter.search = d.data.search;
filter.replace = d.data.replace;
filter.flag = d.data.flag || 0;
}
});
this.client.fetchFilters();
}
ngOnInit(): void {
if (!this.isBrowser)
return;
if (!this.client.logged_in) {
this.router.navigate(["/tts-login"]);
return;
}
}
ngOnDestroy() {
}
openDialog(): void {
const dialogRef = this.dialog.open(FilterItemEditComponent, {
data: { search: '', replace: '' },
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result !== undefined) {
console.log('filters create result', result);
this.client.first(d => d.op == 4 && d.d.request.type == 'create_tts_filter' && d.d.data.search == result.search && d.d.data.replace == result.replace)
?.subscribe(d => {
console.log('adding filter', d.d.data);
this.items = [d.d.data, ...this.items];
});
this.client.createTTSFilter(result.search, result.replace);
}
});
}
}

View File

@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { FiltersComponent } from './filters/filters.component';
import { FilterListComponent } from './filter-list/filter-list.component';
@NgModule({
declarations: [],
imports: [
FiltersComponent, FilterListComponent
]
})
export class TtsFiltersModule { }