File

src/app/features/file/couchdb-file.service.ts

Description

Stores the files in the CouchDB. See https://docs.couchdb.org/en/3.2.2-docs/intro/api.html?highlight=attachments#attachments Running upload and download processes are shown with progress bars.

Extends

FileService

Index

Properties
Methods

Constructor

constructor(sanitizer: DomSanitizer, databaseResolver: DatabaseResolverService, entityMapper: EntityMapperService, entities: EntityRegistry, syncState: SyncStateSubject, navigator: Navigator)
Parameters :
Name Type Optional
sanitizer DomSanitizer No
databaseResolver DatabaseResolverService No
entityMapper EntityMapperService No
entities EntityRegistry No
syncState SyncStateSubject No
navigator Navigator No

Methods

Protected getShowFileUrl
getShowFileUrl(entity: Entity, property: string)
Inherited from FileService
Defined in FileService:156
Parameters :
Name Type Optional
entity Entity No
property string No
Returns : string
loadFile
loadFile(entity: Entity, property: string, throwErrors: boolean)
Inherited from FileService
Defined in FileService:160
Parameters :
Name Type Optional Default value
entity Entity No
property string No
throwErrors boolean No false
Returns : Observable<SafeUrl>
removeAllFiles
removeAllFiles(entity: Entity)
Inherited from FileService
Defined in FileService:141
Parameters :
Name Type Optional
entity Entity No
Returns : Observable<any>
removeFile
removeFile(entity: Entity, property: string)
Inherited from FileService
Defined in FileService:114
Parameters :
Name Type Optional
entity Entity No
property string No
Returns : any
uploadFile
uploadFile(file: File, entity: Entity, property: string)
Inherited from FileService
Defined in FileService:50
Parameters :
Name Type Optional
file File No
entity Entity No
property string No
Returns : Observable<any>
Protected reportProgress
reportProgress(message: string, obs: Observable)
Inherited from FileService
Defined in FileService:149
Parameters :
Name Type Optional
message string No
obs Observable<HttpEvent | any> No
Returns : void
showFile
showFile(entity: Entity, property: string)
Inherited from FileService
Defined in FileService:104

If a file is available, downloads this file and shows it in a new tab.

Parameters :
Name Type Optional Description
entity Entity No
property string No

where a file previously has been uploaded

Returns : void

Properties

Protected dialog
Type : MatDialog
Default value : inject(MatDialog)
Inherited from FileService
Defined in FileService:31
Protected httpClient
Type : HttpClient
Default value : inject(HttpClient, { optional: true, })
Inherited from FileService
Defined in FileService:32
Protected snackbar
Type : MatSnackBar
Default value : inject(MatSnackBar)
Inherited from FileService
Defined in FileService:30
import { Inject, Injectable } from "@angular/core";
import { HttpStatusCode } from "@angular/common/http";
import {
  catchError,
  concatMap,
  last,
  map,
  shareReplay,
  tap,
} from "rxjs/operators";
import { from, Observable, of, throwError } from "rxjs";
import { Entity } from "../../core/entity/model/entity";
import { EntityMapperService } from "../../core/entity/entity-mapper/entity-mapper.service";
import { FileService } from "./file.service";
import { EntityRegistry } from "../../core/entity/database-entity.decorator";
import { Logging } from "../../core/logging/logging.service";
import { ObservableQueue } from "./observable-queue/observable-queue";
import { DomSanitizer, SafeUrl } from "@angular/platform-browser";
import { SyncStateSubject } from "../../core/session/session-type";
import { SyncState } from "../../core/session/session-states/sync-state.enum";
import { environment } from "../../../environments/environment";
import { NAVIGATOR_TOKEN } from "../../utils/di-tokens";
import { NotAvailableOfflineError } from "../../core/session/not-available-offline.error";
import { DatabaseResolverService } from "../../core/database/database-resolver.service";
import { SyncedPouchDatabase } from "app/core/database/pouchdb/synced-pouch-database";

/**
 * Stores the files in the CouchDB.
 * See {@link https://docs.couchdb.org/en/3.2.2-docs/intro/api.html?highlight=attachments#attachments}
 * Running upload and download processes are shown with progress bars.
 */
@Injectable()
export class CouchdbFileService extends FileService {
  private attachmentsUrl = `${environment.DB_PROXY_PREFIX}/${Entity.DATABASE}-attachments`;
  // TODO it seems like failed requests are executed again when a new one is done
  private requestQueue = new ObservableQueue();
  private cache: { [key: string]: Observable<string> } = {};

  constructor(
    private sanitizer: DomSanitizer,
    private databaseResolver: DatabaseResolverService,
    entityMapper: EntityMapperService,
    entities: EntityRegistry,
    syncState: SyncStateSubject,
    @Inject(NAVIGATOR_TOKEN) private navigator: Navigator,
  ) {
    super(entityMapper, entities, syncState);
  }

  uploadFile(file: File, entity: Entity, property: string): Observable<any> {
    if (!this.navigator.onLine) {
      return throwError(() => new NotAvailableOfflineError("File Attachments"));
    }

    const obs = this.requestQueue.add(
      this.runFileUpload(file, entity, property),
    );
    this.reportProgress($localize`Uploading "${file.name}"`, obs);
    this.cache[`${entity.getId()}/${property}`] = obs.pipe(
      last(),
      map(() => URL.createObjectURL(file)),
      shareReplay(),
    );
    return obs;
  }

  private runFileUpload(file: File, entity: Entity, property: string) {
    const attachmentPath = `${this.attachmentsUrl}/${entity.getId()}`;
    return this.ensureDocIsSynced().pipe(
      concatMap(() => this.getAttachmentsDocument(attachmentPath)),
      concatMap(({ _rev }) =>
        this.httpClient.put(`${attachmentPath}/${property}?rev=${_rev}`, file, {
          headers: { "ngsw-bypass": "" },
          reportProgress: true,
          observe: "events",
        }),
      ),
      // prevent http request to be executed multiple times (whenever .subscribe is called)
      shareReplay(),
    );
  }

  /**
   * For permission checks to work correctly, the Entity must be available server-side already.
   * Manually send this doc to the DB here because sync is only happening at slower intervals.
   * @private
   */
  private ensureDocIsSynced(): Observable<SyncState> {
    const mainDb = this.databaseResolver.getDatabase();
    let sync: () => Promise<any> = (mainDb as SyncedPouchDatabase).sync
      ? () => (mainDb as SyncedPouchDatabase).sync()
      : () => Promise.resolve();

    return from(sync()).pipe(map(() => this.syncState.value));
  }

  private getAttachmentsDocument(
    attachmentPath: string,
  ): Observable<{ _rev: string }> {
    return this.httpClient
      .get<{ _id: string; _rev: string }>(attachmentPath)
      .pipe(
        catchError((err) => {
          if (err.status === HttpStatusCode.NotFound) {
            return this.httpClient
              .put<{ rev: string }>(attachmentPath, {})
              .pipe(map((res) => ({ _rev: res.rev })));
          }
          throw err;
        }),
      );
  }

  removeFile(entity: Entity, property: string) {
    if (!this.navigator.onLine) {
      return throwError(() => new NotAvailableOfflineError("File Attachments"));
    }

    return this.requestQueue.add(this.runFileRemoval(entity, property));
  }

  private runFileRemoval(entity: Entity, property: string) {
    const path = `${entity.getId()}/${property}`;
    return this.httpClient
      .get<{ _rev: string }>(`${this.attachmentsUrl}/${entity.getId()}`)
      .pipe(
        concatMap(({ _rev }) =>
          this.httpClient.delete(`${this.attachmentsUrl}/${path}?rev=${_rev}`),
        ),
        tap(() => delete this.cache[path]),
        catchError((err) => {
          if (err.status === HttpStatusCode.NotFound) {
            return of({ ok: true });
          } else {
            throw err;
          }
        }),
      );
  }

  removeAllFiles(entity: Entity): Observable<any> {
    return this.requestQueue.add(this.runAllFilesRemoval(entity));
  }

  private runAllFilesRemoval(entity: Entity) {
    const attachmentPath = `${this.attachmentsUrl}/${entity.getId()}`;
    return this.httpClient
      .get<{ _rev: string }>(attachmentPath)
      .pipe(
        concatMap(({ _rev }) =>
          this.httpClient.delete(`${attachmentPath}?rev=${_rev}`),
        ),
      );
  }

  protected override getShowFileUrl(entity: Entity, property: string): string {
    return `${this.attachmentsUrl}/${entity.getId()}/${property}`;
  }

  loadFile(
    entity: Entity,
    property: string,
    throwErrors: boolean = false,
  ): Observable<SafeUrl> {
    const path = `${entity.getId()}/${property}`;
    if (!this.cache[path]) {
      this.cache[path] = this.httpClient
        .get(`${this.attachmentsUrl}/${path}`, {
          responseType: "blob",
        })
        .pipe(
          map((blob) => URL.createObjectURL(blob)),
          catchError((err) => {
            Logging.warn("Could not load file", entity?.getId(), property, err);

            if (throwErrors) {
              throw err;
            } else {
              return of("");
            }
          }),
          shareReplay(),
        );
    }
    return this.cache[path].pipe(
      map((url) => this.sanitizer.bypassSecurityTrustUrl(url)),
    );
  }
}

results matching ""

    No results matching ""