Skip to content

Commit

Permalink
Merge pull request #265 from e-picsa/ft-download
Browse files Browse the repository at this point in the history
feat(resources-tool): multiple file download and size display
  • Loading branch information
chrismclarke authored Apr 22, 2024
2 parents c0d3744 + a40315c commit 139a106
Show file tree
Hide file tree
Showing 18 changed files with 325 additions and 71 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { PicsaVideoPlayerModule } from '@picsa/shared/features';
import { PicsaTranslateModule } from '@picsa/shared/modules';
import { SizeMBPipe } from '@picsa/shared/pipes/sizeMB';

import { ResourcesMaterialModule } from '../material.module';
import { ResourceDownloadComponent } from './resource-download/resource-download.component';
import { ResourceDownloadMultipleComponent } from './resource-download-multiple/resource-download-multiple.component';
import {
ResourceItemCollectionComponent,
ResourceItemFileComponent,
Expand All @@ -19,11 +21,19 @@ const components = [
ResourceItemFileComponent,
ResourceItemLinkComponent,
ResourceItemVideoComponent,
ResourceDownloadMultipleComponent,
];

@NgModule({
imports: [CommonModule, PicsaTranslateModule, PicsaVideoPlayerModule, ResourcesMaterialModule, RouterModule],
exports: [...components, ResourcesMaterialModule, PicsaTranslateModule],
imports: [
CommonModule,
PicsaTranslateModule,
PicsaVideoPlayerModule,
ResourcesMaterialModule,
RouterModule,
SizeMBPipe,
],
exports: [...components, ResourcesMaterialModule, PicsaTranslateModule, SizeMBPipe],
declarations: components,
providers: [],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
@switch (downloadStatus) {

<!-- Ready -->
@case ('ready') {
<button mat-stroked-button color="primary" class="downloads-button" (click)="downloadAllResources()">
<mat-icon>download</mat-icon>
{{ 'Download All' | translate }}
{{ totalSize | sizeMB }} MB
</button>
}

<!-- Pending -->
@case ('pending') {
<button mat-stroked-button (click)="cancelDownload()">
<div class="download-progress">
<mat-progress-spinner
color="primary"
[mode]="downloadProgress > 0 ? 'determinate' : 'indeterminate'"
[value]="downloadProgress"
[diameter]="16"
>
</mat-progress-spinner>
<span>{{ downloadCount }} / {{ totalCount }}</span>

<mat-icon>close</mat-icon>
</div>
</button>
}

<!-- Complete -->
@case ('complete') {
<button mat-stroked-button disabled color="primary" class="downloads-button">
<mat-icon>download_done</mat-icon>
{{ 'Downloaded' | translate }}
</button>
} }
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.downloads-button {
display: flex;
align-items: center;
margin-left: auto;
}
.download-progress {
display: flex;
align-items: center;
gap: 16px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ResourceDownloadMultipleComponent } from './resource-download-multiple.component';

describe('ResourceDownloadMultipleComponent', () => {
let component: ResourceDownloadMultipleComponent;
let fixture: ComponentFixture<ResourceDownloadMultipleComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ResourceDownloadMultipleComponent],
}).compileComponents();

fixture = TestBed.createComponent(ResourceDownloadMultipleComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { RxDocument } from 'rxdb';
import { lastValueFrom, Subject, Subscription } from 'rxjs';

import { IResourceFile } from '../../schemas';
import { IDownloadStatus, ResourcesToolService } from '../../services/resources-tool.service';

@Component({
selector: 'resource-download-multiple',
templateUrl: './resource-download-multiple.component.html',
styleUrl: './resource-download-multiple.component.scss',
})
export class ResourceDownloadMultipleComponent implements OnDestroy {
private _resources: IResourceFile[];
private pendingDocs: RxDocument<IResourceFile>[] = [];

downloadStatus: IDownloadStatus;
downloadProgress = 0;
downloadCount = 0;
totalSize = 0;
totalCount = 0;

private componentDestroyed$ = new Subject();
private downloadSubscription?: Subscription;

@Input() hideOnComplete = false;

@Input() set resources(resources: IResourceFile[]) {
this._resources = resources;
this.totalCount = resources.length;
this.getPendingDownloads();
}

constructor(private service: ResourcesToolService) {}

ngOnDestroy(): void {
this.componentDestroyed$.next(true);
this.componentDestroyed$.complete();
}

/**
* Iterate over list of input resources, checking which already have attachments downloaded
* and generating summary of pending downloads with total size
*/
public async getPendingDownloads() {
let totalSize = 0;
let totalCount = 0;
let downloadCount = 0;
const pendingDocs: RxDocument<IResourceFile>[] = [];
for (const resource of this._resources) {
const dbDoc = await this.service.dbFiles.findOne(resource.id).exec();
if (dbDoc) {
totalCount++;
// check
const attachment = dbDoc.getAttachment(dbDoc.filename);
if (attachment) {
downloadCount++;
} else {
pendingDocs.push(dbDoc);
totalSize += dbDoc.size_kb;
}
}
}
this.totalCount = totalCount;
this.totalSize = totalSize;
this.downloadCount = downloadCount;
this.pendingDocs = pendingDocs;
this.downloadStatus = totalSize > 0 ? 'ready' : 'complete';
console.log('pending', { totalCount, totalSize, downloadCount, pendingDocs });
}

public async downloadAllResources() {
// recalc sizes to ensure pending docs correct (in case of single file download)
await this.getPendingDownloads();

// handle all downloads
this.downloadStatus = 'pending';

for (const doc of this.pendingDocs) {
await this.downloadNextResource(doc);
}
// refresh UI
await this.getPendingDownloads();
this.downloadStatus = 'complete';
return;
}

/**
* Trigger next download
* Updates download progress and waits until download complete
*/
private async downloadNextResource(doc: RxDocument<IResourceFile>) {
// Only download if pending status given (will be set to 'ready' if cancelled)
if (this.downloadStatus === 'pending') {
const { download$, progress$, status$ } = this.service.triggerResourceDownload(doc);
this.downloadSubscription = download$;
progress$.subscribe((progress) => (this.downloadProgress = progress));
const endStatus = await lastValueFrom(status$);
if (endStatus === 'complete') {
this.downloadCount++;
}
}
}
public async cancelDownload() {
// cancel active download
if (this.downloadSubscription) {
this.downloadSubscription.unsubscribe();
this.downloadSubscription = undefined;
}
// change the download status which will prevent nextResourceDownload trigger
this.downloadStatus = 'ready';
this.downloadProgress = 0;
await this.getPendingDownloads();
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
<button
mat-icon-button
(click)="downloadResource()"
*ngIf="downloadStatus === 'ready'"
[attr.data-style-variant]="styleVariant"
>
<mat-icon>download</mat-icon>
<!-- Ready -->
@if(downloadStatus==='ready'){
<button mat-icon-button (click)="downloadResource()" [attr.data-style-variant]="styleVariant">
<div class="download-button-inner">
<mat-icon style="font-size: 30px">download</mat-icon>
@if(showSize){
<span class="resource-size"> {{ resource.size_kb | sizeMB }} MB </span>
}
</div>
</button>
<div class="download-progress" *ngIf="downloadStatus === 'pending'">
}
<!-- Pending -->
@if(downloadStatus==='pending'){
<div class="download-progress">
<mat-progress-spinner
[class]="'spinner' + styleVariant"
color="primary"
Expand All @@ -30,11 +35,21 @@
<mat-icon>close</mat-icon>
</button>
</div>
}
<!-- Finalizing (write to disk) -->
@if(downloadStatus==='finalizing'){
<button mat-icon-button [attr.data-style-variant]="styleVariant">
<mat-icon>pending</mat-icon>
</button>
}

<!-- Complete -->
@if(downloadStatus==='complete'){
<button
mat-icon-button
*ngIf="downloadStatus === 'complete'"
[attr.data-style-variant]="styleVariant"
[style.visibility]="hideOnComplete ? 'hidden' : 'visible'"
>
<mat-icon>check</mat-icon>
<mat-icon>download_done</mat-icon>
</button>
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,22 @@ mat-progress-spinner[data-style-variant='white'] {
button[data-style-variant='white'] {
color: white;
}

// HACK - allow for custom icon + text placement within button that is 72px x 72px (12px padding)
.download-button-inner {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: absolute;
height: 72px;
width: 72px;
top: -12px;
left: -12px;
overflow: hidden;
}
.resource-size {
font-size: 16px;
margin-top: 8px;
margin-bottom: -4px;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,11 @@ import {
OnDestroy,
Output,
} from '@angular/core';
import { FileService } from '@picsa/shared/services/core/file.service';
import { _wait } from '@picsa/utils';
import { RxAttachment, RxDocument } from 'rxdb';
import { Subject, Subscription, takeUntil } from 'rxjs';

import { IResourceFile } from '../../schemas';
import { ResourcesToolService } from '../../services/resources-tool.service';

type IDownloadStatus = 'ready' | 'pending' | 'complete' | 'error';
import { IDownloadStatus, ResourcesToolService } from '../../services/resources-tool.service';

@Component({
selector: 'resource-download',
Expand All @@ -32,6 +28,9 @@ export class ResourceDownloadComponent implements OnDestroy {
private download$?: Subscription;
private componentDestroyed$ = new Subject();

/** Show size text */
@Input() showSize: boolean;

@Input() styleVariant: 'primary' | 'white' = 'primary';

@Input() size = 48;
Expand All @@ -48,11 +47,7 @@ export class ResourceDownloadComponent implements OnDestroy {
/** Emit downloaded file updates */
@Output() attachmentChange = new EventEmitter<RxAttachment<IResourceFile> | undefined>();

constructor(
private service: ResourcesToolService,
private fileService: FileService,
private cdr: ChangeDetectorRef
) {}
constructor(private service: ResourcesToolService, private cdr: ChangeDetectorRef) {}

public get sizePx() {
return `${this.size}px`;
Expand All @@ -79,36 +74,16 @@ export class ResourceDownloadComponent implements OnDestroy {
}

public downloadResource() {
this.downloadStatus = 'pending';
this.downloadProgress = 0;
let downloadData: Blob;
this.download$ = this.fileService.downloadFile(this.resource.url, 'blob').subscribe({
next: ({ progress, data }) => {
this.downloadProgress = progress;
// NOTE - might be called multiple times before completing so avoid persisting data here
if (progress === 100) {
downloadData = data as Blob;
}
this.cdr.markForCheck();
},
error: (error) => {
this.downloadStatus = 'error';
this.cdr.markForCheck();
console.error(error);
throw error;
},
complete: async () => {
// give small timeout to allow UI to update
await _wait(100);
this.persistDownload(downloadData);
},
const { download$, progress$, status$ } = this.service.triggerResourceDownload(this._dbDoc);
progress$.subscribe((progress) => {
this.downloadProgress = progress;
this.cdr.markForCheck();
});
}

private async persistDownload(data: Blob) {
await this.service.putFileAttachment(this._dbDoc, data);
this.downloadStatus = 'complete';
this.cdr.markForCheck();
status$.subscribe((status) => {
this.downloadStatus = status;
this.cdr.markForCheck();
});
this.download$ = download$;
}

/** Cancel ongoing download */
Expand Down
Loading

0 comments on commit 139a106

Please sign in to comment.