All files / app/service upload-cache.service.ts

95.45% Statements 63/66
82.35% Branches 28/34
100% Functions 23/23
98% Lines 49/50

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 12477x           77x                             77x         16x       16x     16x 16x 16x   20x               13x 12x 12x   13x 10x 9x     9x   9x 9x 9x   5x   5x 5x 5x 5x     5x               22x       4x 4x 2x         10x 10x 8x 8x 8x 8x 7x   7x 7x     7x   8x     8x 8x       8x 10x   6x       8x 11x 11x   8x      
import { HttpEventType, HttpResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { reaction } from 'mobx';
import { concat, defer, filter, finalize, map, Observable, of, shareReplay, switchMap, tap, toArray } from 'rxjs';
import { Ref } from '../model/ref';
import { Store } from '../store/store';
import { cacheMimeType, cacheUrlId, refCacheIds, rewriteCacheIds } from '../util/cache';
import { ProxyService } from './api/proxy.service';
import { RefService } from './api/ref.service';
 
/**
 * Restores cache files from uploaded zips on a Ref by Ref basis.
 * Since cache IDs cannot be chosen when uploading, each cache file is
 * uploaded as a new cache entry and the Ref is rewritten to use the new
 * cache ID. The old to new cache ID mapping is remembered so that each
 * cache file is only uploaded once, even when Refs are uploaded one at a
 * time. Mappings are dropped once no Ref in the upload list refers to them.
 */
@Injectable({
  providedIn: 'root',
})
export class UploadCacheService {
 
  /**
   * Map of origin and old cache ID to new cache ID.
   */
  private ids = new Map<string, string>();
  /**
   * In flight uploads, keyed by origin and old cache ID.
   */
  private pending = new Map<string, Observable<string | undefined>>();
 
  constructor(
    private store: Store,
    private proxy: ProxyService,
    private refs: RefService,
  ) {
    reaction(() => this.store.submit.refs, refs => this.prune(refs));
  }
 
  /**
   * Upload any cache files referenced by this Ref and rewrite the Ref to use
   * the new cache IDs.
   */
  restore$(ref: Ref, origin: string): Observable<Ref> {
    const ids = refCacheIds(ref).filter(id => {
      const key = this.key(id, origin);
      return this.ids.has(key) || this.pending.has(key) || this.store.submit.cacheFiles.has(id);
    });
    if (!ids.length) return of(ref);
    return concat(...ids.map(id => defer(() => this.upload$(id, ref, origin)).pipe(
      map(newId => [id, newId] as [string, string | undefined]),
    ))).pipe(
      toArray(),
      map(entries => new Map(entries.filter(([, newId]) => !!newId) as [string, string][])),
      switchMap(mapping => {
        const result = rewriteCacheIds(ref, mapping);
        const oldId = cacheUrlId(ref.url);
        if (!oldId || !mapping.has(oldId)) return of(result);
        // The Ref URL was rewritten to the newly created cache Ref
        return this.refs.get(result.url, origin).pipe(
          map(existing => {
            result.exists = true;
            result.modifiedString = existing.modifiedString;
            result.modified = existing.modified;
            Iif (existing.plugins?.['_plugin/cache']) {
              result.plugins = { ...result.plugins, '_plugin/cache': existing.plugins['_plugin/cache'] };
            }
            return result;
          }),
        );
      }),
    );
  }
 
  private key(id: string, origin: string) {
    return origin + ' ' + id;
  }
 
  private prune(refs: Ref[]) {
    const keep = new Set(refs.flatMap(refCacheIds));
    for (const key of [...this.ids.keys()]) {
      if (!keep.has(key.substring(key.lastIndexOf(' ') + 1))) this.ids.delete(key);
    }
  }
 
  private upload$(id: string, ref: Ref, origin: string): Observable<string | undefined> {
    const key = this.key(id, origin);
    if (this.ids.has(key)) return of(this.ids.get(key));
    Iif (this.pending.has(key)) return this.pending.get(key)!;
    const zipped = this.store.submit.cacheFiles.get(id)!;
    const upload$ = defer(() => zipped.async('blob')).pipe(
      switchMap(blob => this.proxy.save(new File([blob], this.fileName(id, ref), { type: this.mimeType(id, ref) }), origin)),
      filter(event => event.type === HttpEventType.Response),
      map(event => {
        const saved = (event as HttpResponse<Ref>).body!;
        return cacheUrlId(saved.url) || saved.plugins?.['_plugin/cache']?.id as string | undefined;
      }),
      tap(newId => {
        Eif (newId) this.ids.set(key, newId);
      }),
      finalize(() => this.pending.delete(key)),
      shareReplay(1),
    );
    this.pending.set(key, upload$);
    return upload$;
  }
 
  private fileName(id: string, ref: Ref): string {
    for (const r of [ref, ...this.store.submit.refs]) {
      if (cacheUrlId(r.url) === id && r.title) return r.title;
    }
    return id;
  }
 
  private mimeType(id: string, ref: Ref): string {
    for (const r of [ref, ...this.store.submit.refs]) {
      const cache = r.plugins?.['_plugin/cache'];
      Iif (cache?.id === id && cache.mimeType) return cache.mimeType;
    }
    return cacheMimeType(id);
  }
}