optimize song usage
This commit is contained in:
15
README.md
15
README.md
@@ -8,6 +8,14 @@ If `songUsage` needs to be rebuilt from all existing shows, log in with a user t
|
|||||||
await window.wgeneratorAdmin.rebuildSongUsage()
|
await window.wgeneratorAdmin.rebuildSongUsage()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the `songIds` index on shows needs to be backfilled for tooltip usage in song details, run:
|
||||||
|
|
||||||
|
```js
|
||||||
|
await window.wgeneratorAdmin.rebuildShowSongIds()
|
||||||
|
```
|
||||||
|
|
||||||
|
The command logs progress in the browser console while it runs and prints the final summary when finished.
|
||||||
|
|
||||||
The migration:
|
The migration:
|
||||||
|
|
||||||
- resets `songUsage` for all users
|
- resets `songUsage` for all users
|
||||||
@@ -16,4 +24,11 @@ The migration:
|
|||||||
|
|
||||||
It returns a summary object with processed user, show and show-song counts.
|
It returns a summary object with processed user, show and show-song counts.
|
||||||
|
|
||||||
|
The show index migration:
|
||||||
|
|
||||||
|
- scans all shows and all `shows/{id}/songs` entries
|
||||||
|
- writes the distinct `songIds` array to each show document
|
||||||
|
|
||||||
|
It returns a summary object with processed show and show-song counts.
|
||||||
|
|
||||||
This is intended as a manual one-off migration and is read-heavy by design.
|
This is intended as a manual one-off migration and is read-heavy by design.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
[padding]="false"
|
[padding]="false"
|
||||||
heading="Meine Veranstaltungen"
|
heading="Meine Veranstaltungen"
|
||||||
>
|
>
|
||||||
@for (show of shows | sortBy: 'desc':'date'; track show) {
|
@for (show of shows | sortBy: 'desc':'date'; track trackBy($index, show)) {
|
||||||
<app-list-item
|
<app-list-item
|
||||||
[routerLink]="show.id"
|
[routerLink]="show.id"
|
||||||
[show]="show"
|
[show]="show"
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {TestBed} from '@angular/core/testing';
|
||||||
|
import {of} from 'rxjs';
|
||||||
|
import {ShowDataService} from './show-data.service';
|
||||||
|
import {ShowSongDataService} from './show-song-data.service';
|
||||||
|
import {ShowSongIndexService} from './show-song-index.service';
|
||||||
|
import {UserSessionService} from '../../../services/user/user-session.service';
|
||||||
|
import {User} from '../../../services/user/user';
|
||||||
|
|
||||||
|
describe('ShowSongIndexService', () => {
|
||||||
|
let service: ShowSongIndexService;
|
||||||
|
let showDataServiceSpy: jasmine.SpyObj<ShowDataService>;
|
||||||
|
let showSongDataServiceSpy: jasmine.SpyObj<ShowSongDataService>;
|
||||||
|
let sessionSpy: jasmine.SpyObj<UserSessionService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('ShowDataService', ['listRaw$', 'update']);
|
||||||
|
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['list$']);
|
||||||
|
sessionSpy = jasmine.createSpyObj<UserSessionService>('UserSessionService', ['update$'], {
|
||||||
|
user$: of({id: 'admin-1', name: 'Admin', role: 'admin', chordMode: 'onlyFirst', songUsage: {}} as User),
|
||||||
|
});
|
||||||
|
|
||||||
|
showDataServiceSpy.listRaw$.and.returnValue(of([{id: 'show-1'}, {id: 'show-2'}] as never) as unknown as ReturnType<ShowDataService['listRaw$']>);
|
||||||
|
showDataServiceSpy.update.and.resolveTo();
|
||||||
|
showSongDataServiceSpy.list$.and.callFake((showId: string) => {
|
||||||
|
if (showId === 'show-1') {
|
||||||
|
return of([{songId: 'song-1'}, {songId: 'song-2'}, {songId: 'song-1'}] as never) as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
return of([{songId: 'song-3'}] as never) as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{provide: ShowDataService, useValue: showDataServiceSpy},
|
||||||
|
{provide: ShowSongDataService, useValue: showSongDataServiceSpy},
|
||||||
|
{provide: UserSessionService, useValue: sessionSpy},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(ShowSongIndexService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rebuild the distinct songIds index for all shows', async () => {
|
||||||
|
await expectAsync(service.rebuildShowSongIds()).toBeResolvedTo({
|
||||||
|
showsProcessed: 2,
|
||||||
|
showSongsProcessed: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(showDataServiceSpy.update).toHaveBeenCalledWith('show-1', {songIds: ['song-1', 'song-2']});
|
||||||
|
expect(showDataServiceSpy.update).toHaveBeenCalledWith('show-2', {songIds: ['song-3']});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject index rebuilds for non-admin users', async () => {
|
||||||
|
Object.defineProperty(sessionSpy, 'user$', {
|
||||||
|
value: of({id: 'user-1', name: 'User', role: 'leader', chordMode: 'onlyFirst', songUsage: {}} as User),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectAsync(service.rebuildShowSongIds()).toBeRejectedWithError('Admin role required to rebuild show song ids.');
|
||||||
|
});
|
||||||
|
});
|
||||||
65
src/app/modules/shows/services/show-song-index.service.ts
Normal file
65
src/app/modules/shows/services/show-song-index.service.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {Injectable, inject} from '@angular/core';
|
||||||
|
import {firstValueFrom} from 'rxjs';
|
||||||
|
import {take} from 'rxjs/operators';
|
||||||
|
import {ShowDataService} from './show-data.service';
|
||||||
|
import {ShowSongDataService} from './show-song-data.service';
|
||||||
|
import {UserSessionService} from '../../../services/user/user-session.service';
|
||||||
|
|
||||||
|
export interface ShowSongIndexMigrationResult {
|
||||||
|
showsProcessed: number;
|
||||||
|
showSongsProcessed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationProgress {
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
showId: string;
|
||||||
|
showSongsProcessed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class ShowSongIndexService {
|
||||||
|
private session = inject(UserSessionService);
|
||||||
|
private showDataService = inject(ShowDataService);
|
||||||
|
private showSongDataService = inject(ShowSongDataService);
|
||||||
|
|
||||||
|
public async rebuildShowSongIds(onProgress?: (progress: MigrationProgress) => void): Promise<ShowSongIndexMigrationResult> {
|
||||||
|
const currentUser = await firstValueFrom(this.session.user$.pipe(take(1)));
|
||||||
|
if (!currentUser || !this.hasAdminRole(currentUser.role)) {
|
||||||
|
throw new Error('Admin role required to rebuild show song ids.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const shows = await firstValueFrom(this.showDataService.listRaw$());
|
||||||
|
let showSongsProcessed = 0;
|
||||||
|
let processed = 0;
|
||||||
|
|
||||||
|
for (const show of shows) {
|
||||||
|
const showSongs = await firstValueFrom(this.showSongDataService.list$(show.id));
|
||||||
|
const songIds = [...new Set(showSongs.map(showSong => showSong.songId).filter(Boolean))];
|
||||||
|
showSongsProcessed += showSongs.length;
|
||||||
|
await this.showDataService.update(show.id, {songIds});
|
||||||
|
processed += 1;
|
||||||
|
onProgress?.({
|
||||||
|
processed,
|
||||||
|
total: shows.length,
|
||||||
|
showId: show.id,
|
||||||
|
showSongsProcessed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showsProcessed: shows.length,
|
||||||
|
showSongsProcessed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasAdminRole(role: string | null | undefined): boolean {
|
||||||
|
if (!role) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return role.split(';').includes('admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ describe('ShowSongService', () => {
|
|||||||
const show = {id: 'show-1', order: ['show-song-1', 'show-song-2']} as unknown as Show;
|
const show = {id: 'show-1', order: ['show-song-1', 'show-song-2']} as unknown as Show;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
user$ = new BehaviorSubject<User | null>({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'letters', songUsage: {}});
|
user$ = new BehaviorSubject<User | null>({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'onlyFirst', songUsage: {}});
|
||||||
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['add', 'read$', 'list$', 'delete', 'update$']);
|
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['add', 'read$', 'list$', 'delete', 'update$']);
|
||||||
songDataServiceSpy = jasmine.createSpyObj<SongDataService>('SongDataService', ['read$']);
|
songDataServiceSpy = jasmine.createSpyObj<SongDataService>('SongDataService', ['read$']);
|
||||||
userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['incSongCount', 'decSongCount'], {
|
userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['incSongCount', 'decSongCount'], {
|
||||||
@@ -66,7 +66,7 @@ describe('ShowSongService', () => {
|
|||||||
songId: 'song-1',
|
songId: 'song-1',
|
||||||
key: 'G',
|
key: 'G',
|
||||||
keyOriginal: 'G',
|
keyOriginal: 'G',
|
||||||
chordMode: 'letters',
|
chordMode: 'onlyFirst',
|
||||||
addedLive: true,
|
addedLive: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -103,7 +103,7 @@ describe('ShowSongService', () => {
|
|||||||
await service.delete$('show-1', 'show-song-1', 0);
|
await service.delete$('show-1', 'show-song-1', 0);
|
||||||
|
|
||||||
expect(showSongDataServiceSpy.delete).toHaveBeenCalledWith('show-1', 'show-song-1');
|
expect(showSongDataServiceSpy.delete).toHaveBeenCalledWith('show-1', 'show-song-1');
|
||||||
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {order: ['show-song-2']});
|
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', jasmine.objectContaining({order: ['show-song-2']}));
|
||||||
expect(userServiceSpy.decSongCount).toHaveBeenCalledWith('song-1');
|
expect(userServiceSpy.decSongCount).toHaveBeenCalledWith('song-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {ShowSong} from './show-song';
|
|||||||
import {SongDataService} from '../../songs/services/song-data.service';
|
import {SongDataService} from '../../songs/services/song-data.service';
|
||||||
import {UserService} from '../../../services/user/user.service';
|
import {UserService} from '../../../services/user/user.service';
|
||||||
import {ShowService} from './show.service';
|
import {ShowService} from './show.service';
|
||||||
|
import {arrayRemove, arrayUnion} from '@angular/fire/firestore';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -27,8 +28,9 @@ export class ShowSongService {
|
|||||||
chordMode: user.chordMode,
|
chordMode: user.chordMode,
|
||||||
addedLive,
|
addedLive,
|
||||||
};
|
};
|
||||||
await this.userService.incSongCount(songId);
|
const showSongId = await this.showSongDataService.add(showId, data);
|
||||||
return await this.showSongDataService.add(showId, data);
|
await Promise.all([this.userService.incSongCount(songId), this.showService.update$(showId, {songIds: arrayUnion(songId) as never})]);
|
||||||
|
return showSongId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public read$ = (showId: string, songId: string): Observable<ShowSong | null> => this.showSongDataService.read$(showId, songId);
|
public read$ = (showId: string, songId: string): Observable<ShowSong | null> => this.showSongDataService.read$(showId, songId);
|
||||||
@@ -38,14 +40,19 @@ export class ShowSongService {
|
|||||||
public list = (showId: string): Promise<ShowSong[]> => firstValueFrom(this.list$(showId));
|
public list = (showId: string): Promise<ShowSong[]> => firstValueFrom(this.list$(showId));
|
||||||
|
|
||||||
public async delete$(showId: string, showSongId: string, index: number): Promise<void> {
|
public async delete$(showId: string, showSongId: string, index: number): Promise<void> {
|
||||||
const [showSong, show] = await Promise.all([this.read(showId, showSongId), firstValueFrom(this.showService.read$(showId))]);
|
const [showSong, show, showSongs] = await Promise.all([this.read(showId, showSongId), firstValueFrom(this.showService.read$(showId)), this.list(showId)]);
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
if (!showSong) return;
|
if (!showSong) return;
|
||||||
|
|
||||||
const order = [...show.order];
|
const order = [...show.order];
|
||||||
order.splice(index, 1);
|
order.splice(index, 1);
|
||||||
|
const hasSameSongStillInShow = showSongs.some(song => song.id !== showSongId && song.songId === showSong.songId);
|
||||||
|
|
||||||
await Promise.all([this.showSongDataService.delete(showId, showSongId), this.showService.update$(showId, {order}), this.userService.decSongCount(showSong.songId)]);
|
await Promise.all([
|
||||||
|
this.showSongDataService.delete(showId, showSongId),
|
||||||
|
this.showService.update$(showId, hasSameSongStillInShow ? {order} : {order, songIds: arrayRemove(showSong.songId) as never}),
|
||||||
|
this.userService.decSongCount(showSong.songId),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public update$ = async (showId: string, songId: string, data: Partial<ShowSong>): Promise<void> => await this.showSongDataService.update$(showId, songId, data);
|
public update$ = async (showId: string, songId: string, data: Partial<ShowSong>): Promise<void> => await this.showSongDataService.update$(showId, songId, data);
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ describe('ShowService', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
user$ = new BehaviorSubject<unknown>({id: 'user-1'});
|
user$ = new BehaviorSubject<unknown>({id: 'user-1'});
|
||||||
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('ShowDataService', ['read$', 'listPublicSince$', 'update', 'add'], {
|
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>(
|
||||||
list$: of(shows),
|
'ShowDataService',
|
||||||
});
|
['read$', 'listPublicSince$', 'update', 'add'],
|
||||||
|
{list$: of(shows) as unknown as ShowDataService['list$']}
|
||||||
|
);
|
||||||
showDataServiceSpy.read$.and.returnValue(of(shows[0]));
|
showDataServiceSpy.read$.and.returnValue(of(shows[0]));
|
||||||
showDataServiceSpy.listPublicSince$.and.returnValue(of([shows[1]]));
|
showDataServiceSpy.listPublicSince$.and.returnValue(of([shows[1]]));
|
||||||
showDataServiceSpy.update.and.resolveTo();
|
showDataServiceSpy.update.and.resolveTo();
|
||||||
@@ -97,6 +99,7 @@ describe('ShowService', () => {
|
|||||||
showType: type,
|
showType: type,
|
||||||
owner: 'user-1',
|
owner: 'user-1',
|
||||||
order: [],
|
order: [],
|
||||||
|
songIds: [],
|
||||||
public: true,
|
public: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -111,6 +114,7 @@ describe('ShowService', () => {
|
|||||||
showType: type,
|
showType: type,
|
||||||
owner: 'user-1',
|
owner: 'user-1',
|
||||||
order: [],
|
order: [],
|
||||||
|
songIds: [],
|
||||||
public: false,
|
public: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export class ShowService {
|
|||||||
...data,
|
...data,
|
||||||
owner: user.id,
|
owner: user.id,
|
||||||
order: [],
|
order: [],
|
||||||
|
songIds: [],
|
||||||
public: ShowService.SHOW_TYPE_PUBLIC.indexOf(data.showType) !== -1,
|
public: ShowService.SHOW_TYPE_PUBLIC.indexOf(data.showType) !== -1,
|
||||||
};
|
};
|
||||||
return await this.showDataService.add(calculatedData);
|
return await this.showDataService.add(calculatedData);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface Show {
|
|||||||
showType: string;
|
showType: string;
|
||||||
date: Timestamp;
|
date: Timestamp;
|
||||||
owner: string;
|
owner: string;
|
||||||
|
songIds?: string[];
|
||||||
public: boolean;
|
public: boolean;
|
||||||
reported: boolean;
|
reported: boolean;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {OwnerDirective} from '../../../services/user/owner.directive';
|
|||||||
import {ButtonComponent} from '../../../widget-modules/components/button/button.component';
|
import {ButtonComponent} from '../../../widget-modules/components/button/button.component';
|
||||||
import {MatMenu, MatMenuTrigger} from '@angular/material/menu';
|
import {MatMenu, MatMenuTrigger} from '@angular/material/menu';
|
||||||
import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe';
|
import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe';
|
||||||
|
import {UserService} from '../../../services/user/user.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-show',
|
selector: 'app-show',
|
||||||
@@ -88,6 +89,7 @@ export class ShowComponent implements OnInit, OnDestroy {
|
|||||||
private docxService = inject(DocxService);
|
private docxService = inject(DocxService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private cRef = inject(ChangeDetectorRef);
|
private cRef = inject(ChangeDetectorRef);
|
||||||
|
private userService = inject(UserService);
|
||||||
public dialog = inject(MatDialog);
|
public dialog = inject(MatDialog);
|
||||||
private guestShowService = inject(GuestShowService);
|
private guestShowService = inject(GuestShowService);
|
||||||
|
|
||||||
@@ -171,14 +173,14 @@ export class ShowComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onArchive(archived: boolean): void {
|
public onArchive(archived: boolean): void {
|
||||||
if (!archived && this.showId != null) void this.showService.update$(this.showId, {archived});
|
if (!archived && this.showId != null) void this.setArchiveState(false);
|
||||||
else {
|
else {
|
||||||
const dialogRef = this.dialog.open(ArchiveDialogComponent, {
|
const dialogRef = this.dialog.open(ArchiveDialogComponent, {
|
||||||
width: '350px',
|
width: '350px',
|
||||||
});
|
});
|
||||||
|
|
||||||
dialogRef.afterClosed().pipe(take(1)).subscribe((archive: boolean) => {
|
dialogRef.afterClosed().pipe(take(1)).subscribe((archive: boolean) => {
|
||||||
if (archive && this.showId != null) void this.showService.update$(this.showId, {archived});
|
if (archive && this.showId != null) void this.setArchiveState(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,6 +276,20 @@ export class ShowComponent implements OnInit, OnDestroy {
|
|||||||
const widthInCh = Math.max(3, longestLabelLength);
|
const widthInCh = Math.max(3, longestLabelLength);
|
||||||
return `${widthInCh}ch`;
|
return `${widthInCh}ch`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async setArchiveState(archived: boolean): Promise<void> {
|
||||||
|
if (!this.showId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Array<Promise<void | null>> = [this.showService.update$(this.showId, {archived})];
|
||||||
|
|
||||||
|
(this.showSongs ?? []).forEach(showSong => {
|
||||||
|
updates.push(archived ? this.userService.decSongCount(showSong.songId) : this.userService.incSongCount(showSong.songId));
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(updates);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Swiper {
|
export interface Swiper {
|
||||||
|
|||||||
@@ -40,7 +40,12 @@
|
|||||||
@if (song.origin) {
|
@if (song.origin) {
|
||||||
<div>Quelle: {{ song.origin }}</div>
|
<div>Quelle: {{ song.origin }}</div>
|
||||||
}
|
}
|
||||||
<div>Wie oft verwendet: {{ songCount$ | async }}</div>
|
<div
|
||||||
|
[matTooltip]="songUsageTooltip$ | async"
|
||||||
|
matTooltipPosition="above"
|
||||||
|
>
|
||||||
|
Wie oft verwendet: {{ songCount$ | async }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (user$ | async; as user) {
|
@if (user$ | async; as user) {
|
||||||
@@ -81,7 +86,7 @@
|
|||||||
Zu Veranstaltung hinzufügen
|
Zu Veranstaltung hinzufügen
|
||||||
</app-button>
|
</app-button>
|
||||||
<mat-menu #menu="matMenu">
|
<mat-menu #menu="matMenu">
|
||||||
@for (show of privateShows$|async; track show) {
|
@for (show of privateShows$|async; track show.id) {
|
||||||
<app-button (click)="addSongToShow(show, song)">
|
<app-button (click)="addSongToShow(show, song)">
|
||||||
{{ show.date.toDate() | date: "dd.MM.yyyy" }} {{ show.showType | showType }}
|
{{ show.date.toDate() | date: "dd.MM.yyyy" }} {{ show.showType | showType }}
|
||||||
</app-button>
|
</app-button>
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
|
|||||||
import {SongComponent} from './song.component';
|
import {SongComponent} from './song.component';
|
||||||
import {of} from 'rxjs';
|
import {of} from 'rxjs';
|
||||||
import {ActivatedRoute} from '@angular/router';
|
import {ActivatedRoute} from '@angular/router';
|
||||||
|
import {SongService} from '../services/song.service';
|
||||||
|
import {FileDataService} from '../services/file-data.service';
|
||||||
|
import {UserService} from '../../../services/user/user.service';
|
||||||
|
import {ShowService} from '../../shows/services/show.service';
|
||||||
|
import {ShowSongService} from '../../shows/services/show-song.service';
|
||||||
|
|
||||||
describe('SongComponent', () => {
|
describe('SongComponent', () => {
|
||||||
let component: SongComponent;
|
let component: SongComponent;
|
||||||
@@ -13,9 +18,28 @@ describe('SongComponent', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
|
const songServiceSpy = jasmine.createSpyObj<SongService>('SongService', ['read$']);
|
||||||
|
const fileDataServiceSpy = jasmine.createSpyObj<FileDataService>('FileDataService', ['read$']);
|
||||||
|
const userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['incSongCount', 'decSongCount'], {
|
||||||
|
user$: of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'onlyFirst', songUsage: {'4711': 2}}),
|
||||||
|
});
|
||||||
|
const showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['list$', 'update$']);
|
||||||
|
const showSongServiceSpy = jasmine.createSpyObj<ShowSongService>('ShowSongService', ['new$']);
|
||||||
|
|
||||||
|
songServiceSpy.read$.and.returnValue(of({id: '4711', title: 'Test Song', number: '1', text: '', showType: '', flags: ''} as never));
|
||||||
|
fileDataServiceSpy.read$.and.returnValue(of([]));
|
||||||
|
showServiceSpy.list$.and.returnValue(of([]));
|
||||||
|
|
||||||
void TestBed.configureTestingModule({
|
void TestBed.configureTestingModule({
|
||||||
imports: [SongComponent],
|
imports: [SongComponent],
|
||||||
providers: [{provide: ActivatedRoute, useValue: mockActivatedRoute}],
|
providers: [
|
||||||
|
{provide: ActivatedRoute, useValue: mockActivatedRoute},
|
||||||
|
{provide: SongService, useValue: songServiceSpy},
|
||||||
|
{provide: FileDataService, useValue: fileDataServiceSpy},
|
||||||
|
{provide: UserService, useValue: userServiceSpy},
|
||||||
|
{provide: ShowService, useValue: showServiceSpy},
|
||||||
|
{provide: ShowSongService, useValue: showSongServiceSpy},
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {SongTypePipe} from '../../../widget-modules/pipes/song-type-translater/s
|
|||||||
import {LegalOwnerPipe} from '../../../widget-modules/pipes/legal-owner-translator/legal-owner.pipe';
|
import {LegalOwnerPipe} from '../../../widget-modules/pipes/legal-owner-translator/legal-owner.pipe';
|
||||||
import {StatusPipe} from '../../../widget-modules/pipes/status-translater/status.pipe';
|
import {StatusPipe} from '../../../widget-modules/pipes/status-translater/status.pipe';
|
||||||
import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe';
|
import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe';
|
||||||
|
import {MatTooltip} from '@angular/material/tooltip';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-song',
|
selector: 'app-song',
|
||||||
@@ -48,6 +49,7 @@ import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/s
|
|||||||
LegalOwnerPipe,
|
LegalOwnerPipe,
|
||||||
StatusPipe,
|
StatusPipe,
|
||||||
ShowTypePipe,
|
ShowTypePipe,
|
||||||
|
MatTooltip,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SongComponent implements OnInit {
|
export class SongComponent implements OnInit {
|
||||||
@@ -63,10 +65,14 @@ export class SongComponent implements OnInit {
|
|||||||
public files$: Observable<File[] | null> | null = null;
|
public files$: Observable<File[] | null> | null = null;
|
||||||
public user$: Observable<User | null> | null = null;
|
public user$: Observable<User | null> | null = null;
|
||||||
public songCount$: Observable<number> | null = null;
|
public songCount$: Observable<number> | null = null;
|
||||||
|
public songUsageShows$: Observable<Show[]> | null = null;
|
||||||
|
public songUsageTooltip$: Observable<string> | null = null;
|
||||||
public faEdit = faEdit;
|
public faEdit = faEdit;
|
||||||
public faDelete = faTrash;
|
public faDelete = faTrash;
|
||||||
public faFileCirclePlus = faFileCirclePlus;
|
public faFileCirclePlus = faFileCirclePlus;
|
||||||
public privateShows$ = this.showService.list$().pipe(map(show => show.filter(_ => !_.published).sort((a, b) => b.date.toMillis() - a.date.toMillis())));
|
public privateShows$ = this.showService.list$().pipe(map(show => show.filter(_ => !_.published).sort((a, b) => b.date.toMillis() - a.date.toMillis())));
|
||||||
|
private dateFormatter = new Intl.DateTimeFormat('de-DE', {day: '2-digit', month: '2-digit', year: 'numeric'});
|
||||||
|
private showTypePipe = new ShowTypePipe();
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
const userService = this.userService;
|
const userService = this.userService;
|
||||||
@@ -98,6 +104,33 @@ export class SongComponent implements OnInit {
|
|||||||
}),
|
}),
|
||||||
distinctUntilChanged()
|
distinctUntilChanged()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.songUsageShows$ = combineLatest([this.userService.user$, this.showService.list$(), song$]).pipe(
|
||||||
|
map(([user, shows, song]) => {
|
||||||
|
if (!user || !song) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return shows
|
||||||
|
.filter(show => show.owner === user.id)
|
||||||
|
.filter(show => (show.songIds ?? []).includes(song.id))
|
||||||
|
.sort((a, b) => b.date.toMillis() - a.date.toMillis());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.songUsageTooltip$ = combineLatest([this.songCount$, this.songUsageShows$]).pipe(
|
||||||
|
map(([count, shows]) => {
|
||||||
|
if (count === 0) {
|
||||||
|
return 'Noch in keiner Show verwendet.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shows.length === 0) {
|
||||||
|
return 'Verwendungen vorhanden, aber Show-Zuordnung noch nicht indexiert.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return shows.map(show => `${this.dateFormatter.format(show.date.toDate())} - ${this.showTypePipe.transform(show.showType)}`).join('\n');
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getFlags = (flags: string): string[] => {
|
public getFlags = (flags: string): string[] => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class DbCollection<T> {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public add(data: Partial<T>): Promise<DocumentReference<T>> {
|
public add(data: Partial<T>): Promise<DocumentReference<T>> {
|
||||||
return addDoc(this.ref as CollectionReference<T>, data as WithFieldValue<T>);
|
return runInInjectionContext(this.environmentInjector, () => addDoc(this.ref as CollectionReference<T>, data as WithFieldValue<T>));
|
||||||
}
|
}
|
||||||
|
|
||||||
public valueChanges(options?: {idField?: string}): Observable<T[]> {
|
public valueChanges(options?: {idField?: string}): Observable<T[]> {
|
||||||
@@ -38,7 +38,7 @@ export class DbCollection<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get ref(): CollectionReference<DocumentData> {
|
private get ref(): CollectionReference<DocumentData> {
|
||||||
return collection(this.fs, this.path);
|
return runInInjectionContext(this.environmentInjector, () => collection(this.fs, this.path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,15 +50,15 @@ export class DbDocument<T> {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public set(data: Partial<T>): Promise<void> {
|
public set(data: Partial<T>): Promise<void> {
|
||||||
return setDoc(this.ref as DocumentReference<T>, data as WithFieldValue<T>);
|
return runInInjectionContext(this.environmentInjector, () => setDoc(this.ref as DocumentReference<T>, data as WithFieldValue<T>));
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(data: Partial<T>): Promise<void> {
|
public update(data: Partial<T>): Promise<void> {
|
||||||
return updateDoc(this.ref, data as Partial<DocumentData>);
|
return runInInjectionContext(this.environmentInjector, () => updateDoc(this.ref, data as Partial<DocumentData>));
|
||||||
}
|
}
|
||||||
|
|
||||||
public delete(): Promise<void> {
|
public delete(): Promise<void> {
|
||||||
return deleteDoc(this.ref);
|
return runInInjectionContext(this.environmentInjector, () => deleteDoc(this.ref));
|
||||||
}
|
}
|
||||||
|
|
||||||
public collection<U>(subPath: string): DbCollection<U> {
|
public collection<U>(subPath: string): DbCollection<U> {
|
||||||
@@ -73,7 +73,7 @@ export class DbDocument<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get ref(): DocumentReference<DocumentData> {
|
private get ref(): DocumentReference<DocumentData> {
|
||||||
return doc(this.fs, this.path);
|
return runInInjectionContext(this.environmentInjector, () => doc(this.fs, this.path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +103,9 @@ export class DbService {
|
|||||||
return this.col(ref).valueChanges({idField: 'id'});
|
return this.col(ref).valueChanges({idField: 'id'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return runInInjectionContext(this.environmentInjector, () => {
|
||||||
const q = query(collection(this.fs, ref), ...queryConstraints);
|
const q = query(collection(this.fs, ref), ...queryConstraints);
|
||||||
return runInInjectionContext(this.environmentInjector, () => collectionData(q, {idField: 'id'}) as Observable<T[]>);
|
return collectionData(q, {idField: 'id'}) as Observable<T[]>;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ describe('UserSongUsageService', () => {
|
|||||||
sessionSpy.update$.and.resolveTo();
|
sessionSpy.update$.and.resolveTo();
|
||||||
showDataServiceSpy.listRaw$.and.returnValue(
|
showDataServiceSpy.listRaw$.and.returnValue(
|
||||||
of([
|
of([
|
||||||
{id: 'show-1', owner: 'user-1'},
|
{id: 'show-1', owner: 'user-1', archived: false},
|
||||||
{id: 'show-2', owner: 'user-2'},
|
{id: 'show-2', owner: 'user-2', archived: true},
|
||||||
] as never)
|
] as never)
|
||||||
);
|
);
|
||||||
showSongDataServiceSpy.list$.and.callFake((showId: string) =>
|
showSongDataServiceSpy.list$.and.callFake((showId: string) =>
|
||||||
@@ -66,11 +66,11 @@ describe('UserSongUsageService', () => {
|
|||||||
await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({
|
await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({
|
||||||
usersProcessed: 2,
|
usersProcessed: 2,
|
||||||
showsProcessed: 2,
|
showsProcessed: 2,
|
||||||
showSongsProcessed: 4,
|
showSongsProcessed: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sessionSpy.update$).toHaveBeenCalledWith('user-1', {songUsage: {'song-1': 2, 'song-2': 1}});
|
expect(sessionSpy.update$).toHaveBeenCalledWith('user-1', {songUsage: {'song-1': 2, 'song-2': 1}});
|
||||||
expect(sessionSpy.update$).toHaveBeenCalledWith('user-2', {songUsage: {'song-3': 1}});
|
expect(sessionSpy.update$).toHaveBeenCalledWith('user-2', {songUsage: {}});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject song usage rebuilds for non-admin users', async () => {
|
it('should reject song usage rebuilds for non-admin users', async () => {
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export class UserSongUsageService {
|
|||||||
|
|
||||||
let showSongsProcessed = 0;
|
let showSongsProcessed = 0;
|
||||||
for (const show of shows) {
|
for (const show of shows) {
|
||||||
|
if (show.archived) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const ownerId = show.owner;
|
const ownerId = show.owner;
|
||||||
if (!ownerId) {
|
if (!ownerId) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import {of} from 'rxjs';
|
|||||||
import {UserService} from './user.service';
|
import {UserService} from './user.service';
|
||||||
import {UserSessionService} from './user-session.service';
|
import {UserSessionService} from './user-session.service';
|
||||||
import {UserSongUsageService} from './user-song-usage.service';
|
import {UserSongUsageService} from './user-song-usage.service';
|
||||||
|
import {ShowSongIndexService} from '../../modules/shows/services/show-song-index.service';
|
||||||
|
|
||||||
describe('UserService', () => {
|
describe('UserService', () => {
|
||||||
let service: UserService;
|
let service: UserService;
|
||||||
let sessionSpy: jasmine.SpyObj<UserSessionService>;
|
let sessionSpy: jasmine.SpyObj<UserSessionService>;
|
||||||
let songUsageSpy: jasmine.SpyObj<UserSongUsageService>;
|
let songUsageSpy: jasmine.SpyObj<UserSongUsageService>;
|
||||||
|
let showSongIndexSpy: jasmine.SpyObj<ShowSongIndexService>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
sessionSpy = jasmine.createSpyObj<UserSessionService>(
|
sessionSpy = jasmine.createSpyObj<UserSessionService>(
|
||||||
@@ -20,6 +22,7 @@ describe('UserService', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
songUsageSpy = jasmine.createSpyObj<UserSongUsageService>('UserSongUsageService', ['incSongCount', 'decSongCount', 'rebuildSongUsage']);
|
songUsageSpy = jasmine.createSpyObj<UserSongUsageService>('UserSongUsageService', ['incSongCount', 'decSongCount', 'rebuildSongUsage']);
|
||||||
|
showSongIndexSpy = jasmine.createSpyObj<ShowSongIndexService>('ShowSongIndexService', ['rebuildShowSongIds']);
|
||||||
|
|
||||||
sessionSpy.currentUser.and.resolveTo({id: 'user-1'} as never);
|
sessionSpy.currentUser.and.resolveTo({id: 'user-1'} as never);
|
||||||
sessionSpy.getUserbyId.and.resolveTo({id: 'user-2'} as never);
|
sessionSpy.getUserbyId.and.resolveTo({id: 'user-2'} as never);
|
||||||
@@ -34,11 +37,13 @@ describe('UserService', () => {
|
|||||||
songUsageSpy.incSongCount.and.resolveTo();
|
songUsageSpy.incSongCount.and.resolveTo();
|
||||||
songUsageSpy.decSongCount.and.resolveTo();
|
songUsageSpy.decSongCount.and.resolveTo();
|
||||||
songUsageSpy.rebuildSongUsage.and.resolveTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3});
|
songUsageSpy.rebuildSongUsage.and.resolveTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3});
|
||||||
|
showSongIndexSpy.rebuildShowSongIds.and.resolveTo({showsProcessed: 2, showSongsProcessed: 3});
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
{provide: UserSessionService, useValue: sessionSpy},
|
{provide: UserSessionService, useValue: sessionSpy},
|
||||||
{provide: UserSongUsageService, useValue: songUsageSpy},
|
{provide: UserSongUsageService, useValue: songUsageSpy},
|
||||||
|
{provide: ShowSongIndexService, useValue: showSongIndexSpy},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,9 +105,11 @@ describe('UserService', () => {
|
|||||||
await service.incSongCount('song-1');
|
await service.incSongCount('song-1');
|
||||||
await service.decSongCount('song-2');
|
await service.decSongCount('song-2');
|
||||||
await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3});
|
await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3});
|
||||||
|
await expectAsync(service.rebuildShowSongIds()).toBeResolvedTo({showsProcessed: 2, showSongsProcessed: 3});
|
||||||
|
|
||||||
expect(songUsageSpy.incSongCount).toHaveBeenCalledWith('song-1');
|
expect(songUsageSpy.incSongCount).toHaveBeenCalledWith('song-1');
|
||||||
expect(songUsageSpy.decSongCount).toHaveBeenCalledWith('song-2');
|
expect(songUsageSpy.decSongCount).toHaveBeenCalledWith('song-2');
|
||||||
expect(songUsageSpy.rebuildSongUsage).toHaveBeenCalled();
|
expect(songUsageSpy.rebuildSongUsage).toHaveBeenCalled();
|
||||||
|
expect(showSongIndexSpy.rebuildShowSongIds).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {Observable} from 'rxjs';
|
|||||||
import {User} from './user';
|
import {User} from './user';
|
||||||
import {SongUsageMigrationResult, UserSongUsageService} from './user-song-usage.service';
|
import {SongUsageMigrationResult, UserSongUsageService} from './user-song-usage.service';
|
||||||
import {UserSessionService} from './user-session.service';
|
import {UserSessionService} from './user-session.service';
|
||||||
|
import {MigrationProgress, ShowSongIndexMigrationResult, ShowSongIndexService} from '../../modules/shows/services/show-song-index.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -10,6 +11,7 @@ import {UserSessionService} from './user-session.service';
|
|||||||
export class UserService {
|
export class UserService {
|
||||||
private session = inject(UserSessionService);
|
private session = inject(UserSessionService);
|
||||||
private songUsage = inject(UserSongUsageService);
|
private songUsage = inject(UserSongUsageService);
|
||||||
|
private showSongIndex = inject(ShowSongIndexService);
|
||||||
|
|
||||||
public users$ = this.session.users$;
|
public users$ = this.session.users$;
|
||||||
|
|
||||||
@@ -34,4 +36,6 @@ export class UserService {
|
|||||||
public incSongCount = (songId: string): Promise<void | null> => this.songUsage.incSongCount(songId);
|
public incSongCount = (songId: string): Promise<void | null> => this.songUsage.incSongCount(songId);
|
||||||
public decSongCount = (songId: string): Promise<void | null> => this.songUsage.decSongCount(songId);
|
public decSongCount = (songId: string): Promise<void | null> => this.songUsage.decSongCount(songId);
|
||||||
public rebuildSongUsage = (): Promise<SongUsageMigrationResult> => this.songUsage.rebuildSongUsage();
|
public rebuildSongUsage = (): Promise<SongUsageMigrationResult> => this.songUsage.rebuildSongUsage();
|
||||||
|
public rebuildShowSongIds = (onProgress?: (progress: MigrationProgress) => void): Promise<ShowSongIndexMigrationResult> =>
|
||||||
|
this.showSongIndex.rebuildShowSongIds(onProgress);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<mat-option>
|
<mat-option>
|
||||||
<ngx-mat-select-search [formControl]="filteredSongsControl"></ngx-mat-select-search>
|
<ngx-mat-select-search [formControl]="filteredSongsControl"></ngx-mat-select-search>
|
||||||
</mat-option>
|
</mat-option>
|
||||||
@for (song of filteredSongs(); track song) {
|
@for (song of filteredSongs(); track song.id) {
|
||||||
<mat-option [value]="song.id">{{ song.title }}</mat-option>
|
<mat-option [value]="song.id">{{ song.title }}</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
|
|||||||
15
src/main.ts
15
src/main.ts
@@ -18,6 +18,7 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
wgeneratorAdmin?: {
|
wgeneratorAdmin?: {
|
||||||
rebuildSongUsage(): Promise<unknown>;
|
rebuildSongUsage(): Promise<unknown>;
|
||||||
|
rebuildShowSongIds(): Promise<unknown>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +50,20 @@ bootstrapApplication(AppComponent, {
|
|||||||
const userService = appRef.injector.get(UserService);
|
const userService = appRef.injector.get(UserService);
|
||||||
window.wgeneratorAdmin = {
|
window.wgeneratorAdmin = {
|
||||||
rebuildSongUsage: () => userService.rebuildSongUsage(),
|
rebuildSongUsage: () => userService.rebuildSongUsage(),
|
||||||
|
rebuildShowSongIds: async () => {
|
||||||
|
console.info('[wgeneratorAdmin] rebuildShowSongIds started');
|
||||||
|
const result = await userService.rebuildShowSongIds(progress => {
|
||||||
|
console.info(
|
||||||
|
`[wgeneratorAdmin] rebuildShowSongIds ${progress.processed}/${progress.total} shows processed`,
|
||||||
|
{
|
||||||
|
showId: progress.showId,
|
||||||
|
showSongsProcessed: progress.showSongsProcessed,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
console.info('[wgeneratorAdmin] rebuildShowSongIds finished', result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.catch(err => console.error(err));
|
.catch(err => console.error(err));
|
||||||
|
|||||||
20
src/types/qrcode.d.ts
vendored
Normal file
20
src/types/qrcode.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
declare module 'qrcode' {
|
||||||
|
export interface QRCodeToDataURLOptions {
|
||||||
|
type?: string;
|
||||||
|
quality?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
color?: {
|
||||||
|
dark?: string;
|
||||||
|
light?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDataURL(text: string, options?: QRCodeToDataURLOptions): Promise<string>;
|
||||||
|
|
||||||
|
const QRCode: {
|
||||||
|
toDataURL: typeof toDataURL;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QRCode;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user