Files
Kavita/UI/Web/src/app/_services/annotation.service.ts
T
Joe Milazzo 0bbb0ff28f Massive UI Cleanup (#4466)
Co-authored-by: KindlyFire <10267586+kindlyfire@users.noreply.github.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Alexey <lewadedun@gmail.com>
Co-authored-by: Anon Bitardov <timurvolga23+weblate@gmail.com>
Co-authored-by: Ferran <ferrancette@gmail.com>
Co-authored-by: Gneb <goozi12345@gmail.com>
Co-authored-by: Robin Stolpe <robinstolpe@slashmad.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
Co-authored-by: Tijl Van den Brugghen <contact@tijlvdb.me>
2026-02-28 11:19:00 -08:00

219 lines
7.4 KiB
TypeScript

import {computed, inject, Injectable, signal} from '@angular/core';
import {environment} from "../../environments/environment";
import {HttpClient, HttpParams} from "@angular/common/http";
import {Annotation} from '../book-reader/_models/annotations/annotation';
import {TextResonse} from "../_types/text-response";
import {asyncScheduler, map, of, tap} from "rxjs";
import {switchMap, throttleTime} from "rxjs/operators";
import {AccountService} from "./account.service";
import {MessageHubService} from "./message-hub.service";
import {RgbaColor} from "../book-reader/_models/annotations/highlight-slot";
import {Router} from "@angular/router";
import {SAVER, Saver} from "../_providers/saver.provider";
import {download} from "../shared/_models/download";
import {DEBOUNCE_TIME} from "../shared/_services/download.service";
import {FilterV2} from "../_models/metadata/v2/filter-v2";
import {AnnotationsFilterField, AnnotationsSortField} from "../_models/metadata/v2/annotations-filter";
import {UtilityService} from "../shared/_services/utility.service";
import {PaginatedResult} from "../_models/pagination";
/**
* Represents any modification (create/delete/edit) that occurs to annotations
*/
export interface AnnotationEvent {
pageNumber: number;
type: 'create' | 'delete' | 'edit';
annotation: Annotation;
}
@Injectable({
providedIn: 'root'
})
export class AnnotationService {
private readonly httpClient = inject(HttpClient);
private readonly accountService = inject(AccountService);
private readonly utilityService = inject(UtilityService);
private readonly messageHub = inject(MessageHubService);
private readonly router = inject(Router);
private readonly baseUrl = environment.apiUrl;
private readonly save = inject<Saver>(SAVER);
private _annotations = signal<Annotation[]>([]);
/**
* Annotations for a given book
*/
public readonly annotations = this._annotations.asReadonly();
private _events = signal<AnnotationEvent | null>(null);
public readonly events = this._events.asReadonly();
public readonly slots = computed(() => {
const currentUser = this.accountService.currentUser();
return currentUser?.preferences?.bookReaderHighlightSlots ?? [];
});
updateSlotColor(index: number, color: RgbaColor) {
const user = this.accountService.currentUser();
if (!user) return of([]);
const preferences = user.preferences;
preferences.bookReaderHighlightSlots[index].color = color;
return this.accountService.updatePreferences(preferences).pipe(
map((p) => p.bookReaderHighlightSlots)
);
}
getAllAnnotations(chapterId: number) {
return this.httpClient.get<Array<Annotation>>(this.baseUrl + 'annotation/all?chapterId=' + chapterId).pipe(map(annotations => {
this._annotations.set(annotations);
return annotations;
}));
}
getAllAnnotationsFiltered(filter: FilterV2<AnnotationsFilterField, AnnotationsSortField>, pageNum?: number, itemsPerPage?: number) {
const params = this.utilityService.addPaginationIfExists(new HttpParams(), pageNum, itemsPerPage);
return this.httpClient.post<PaginatedResult<Annotation>[]>(this.baseUrl + 'annotation/all-filtered', filter, {observe: 'response', params}).pipe(
map((res: any) => {
return this.utilityService.createPaginatedResult<Annotation>(res);
}),
);
}
getAnnotationsForSeries(seriesId: number) {
return this.httpClient.get<Array<Annotation>>(this.baseUrl + 'annotation/all-for-series?seriesId=' + seriesId);
}
createAnnotation(data: Annotation) {
return this.httpClient.post<Annotation>(this.baseUrl + 'annotation/create', data).pipe(
tap(newAnnotation => {
this._events.set({
pageNumber: newAnnotation.pageNumber,
type: 'create',
annotation: newAnnotation
});
}),
switchMap(newAnnotation => this.getAllAnnotations(newAnnotation.chapterId))
);
}
updateAnnotation(data: Annotation) {
return this.httpClient.post<Annotation>(this.baseUrl + 'annotation/update', data).pipe(
switchMap(newAnnotation => this.getAllAnnotations(data.chapterId)),
tap(_ => {
this._events.set({
pageNumber: data.pageNumber,
type: 'edit',
annotation: data
});
}),
);
}
getAnnotation(annotationId: number) {
return this.httpClient.get<Annotation>(this.baseUrl + `annotation/${annotationId}`);
}
/**
* Deletes an annotation without it needing to be loading in the signal.
* Used in the ViewEditAnnotationDrawer. Event is still fired.
* @param annotation
*/
deleteAnnotation(annotation: Annotation) {
const id = annotation.id;
return this.httpClient.delete(this.baseUrl + `annotation?annotationId=${id}`, TextResonse).pipe(tap(_ => {
const annotations = this._annotations();
this._annotations.set(annotations.filter(a => a.id !== id));
this._events.set({
pageNumber: annotation.pageNumber,
type: 'delete',
annotation: annotation
});
}));
}
delete(id: number) {
const filtered = this.annotations().filter(a => a.id === id);
if (filtered.length === 0) return of();
const annotationToDelete = filtered[0];
return this.deleteAnnotation(annotationToDelete);
}
/**
* While this method will update the services annotations list. No events will be sent out.
* Deletion on the callers' side should be handled in the rxjs chain.
* @param ids
*/
bulkDelete(ids: number[]) {
return this.httpClient.post(this.baseUrl + "annotation/bulk-delete", ids).pipe(
tap(() => {
this._annotations.update(x => x.filter(a => !ids.includes(a.id)));
}),
);
}
/**
* Routes to the book reader with the annotation in view
* @param item
*/
navigateToAnnotation(item: Annotation) {
this.router.navigate(['/library', item.libraryId, 'series', item.seriesId, 'book', item.chapterId], { queryParams: { annotation: item.id } });
}
exportFilter(filter: FilterV2<AnnotationsFilterField, AnnotationsSortField>, pageNum?: number, itemsPerPage?: number) {
const params = this.utilityService.addPaginationIfExists(new HttpParams(), pageNum, itemsPerPage);
return this.httpClient.post(this.baseUrl + 'annotation/export-filter', filter, {
observe: 'events',
responseType: 'blob',
reportProgress: true,
params}).
pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, decodeURIComponent(filename));
})
);
}
exportAnnotations(ids?: number[]) {
return this.httpClient.post(this.baseUrl + 'annotation/export', ids, {observe: 'events', responseType: 'blob', reportProgress: true}).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, decodeURIComponent(filename));
})
);
}
/**
* Does not emit an event
* @param ids
*/
likeAnnotations(ids: number[]) {
const userId = this.accountService.currentUser()?.id;
if (!userId) return of();
return this.httpClient.post(this.baseUrl + 'annotation/like', ids);
}
/**
* Does not emit an event
* @param ids
*/
unLikeAnnotations(ids: number[]) {
const userId = this.accountService.currentUser()?.id;
if (!userId) return of();
return this.httpClient.post(this.baseUrl + 'annotation/unlike', ids);
}
}