rajah 10 kuukautta sitten
vanhempi
sitoutus
4da94f8781

+ 15 - 0
package-lock.json

@@ -27,6 +27,7 @@
         "ngx-bootstrap": "^19.0.2",
         "popper.js": "^1.16.1",
         "rxjs": "~7.8.0",
+        "spark-md5": "^3.0.2",
         "tslib": "^2.3.0",
         "vite": "^6.2.5",
         "zone.js": "~0.15.0"
@@ -38,6 +39,7 @@
         "@angular/localize": "^19.2.7",
         "@types/file-saver": "^2.0.7",
         "@types/jasmine": "~5.1.0",
+        "@types/spark-md5": "^3.0.5",
         "jasmine-core": "~5.5.0",
         "karma": "~6.4.0",
         "karma-chrome-launcher": "~3.2.0",
@@ -5397,6 +5399,13 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/spark-md5": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@types/spark-md5/-/spark-md5-3.0.5.tgz",
+      "integrity": "sha512-lWf05dnD42DLVKQJZrDHtWFidcLrHuip01CtnC2/S6AMhX4t9ZlEUj4iuRlAnts0PQk7KESOqKxeGE/b6sIPGg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/ws": {
       "version": "8.18.1",
       "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -13056,6 +13065,12 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/spark-md5": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz",
+      "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==",
+      "license": "(WTFPL OR MIT)"
+    },
     "node_modules/spdx-correct": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",

+ 2 - 0
package.json

@@ -29,6 +29,7 @@
     "ngx-bootstrap": "^19.0.2",
     "popper.js": "^1.16.1",
     "rxjs": "~7.8.0",
+    "spark-md5": "^3.0.2",
     "tslib": "^2.3.0",
     "vite": "^6.2.5",
     "zone.js": "~0.15.0"
@@ -40,6 +41,7 @@
     "@angular/localize": "^19.2.7",
     "@types/file-saver": "^2.0.7",
     "@types/jasmine": "~5.1.0",
+    "@types/spark-md5": "^3.0.5",
     "jasmine-core": "~5.5.0",
     "karma": "~6.4.0",
     "karma-chrome-launcher": "~3.2.0",

+ 1 - 2
src/app/composants/production-create/production-create.component.html

@@ -92,8 +92,7 @@
 		</div>
 		<div class="card-footer hstack">
 			<button type="button" class="btn bg-gradient btn-success btn-sm text-left" #boutonUploader type="submit" [disabled]="formRef.invalid"><i class="fa-solid fa-plus"></i>&nbsp;<span i18n>Créer</span></button>
-			<div #messageUpload class="form-text" style="padding-left:7px;"></div>
-			<div #messageErreur class="form-text ms-auto text-danger" style="padding-right:7px;"></div>
+			<div #labelMessage class="form-text" style="padding-left:10px;min-width:300px;"></div>
 		</div>
 	</div>
 </form>

+ 31 - 10
src/app/composants/production-create/production-create.component.ts

@@ -2,6 +2,7 @@ import { Component, OnInit, ViewChild, ElementRef, Renderer2 } from '@angular/co
 import { Router } from '@angular/router';
 import { FormsModule, NgForm } from '@angular/forms';
 import { HttpErrorResponse } from '@angular/common/http'
+import * as SparkMD5 from 'spark-md5';
 
 import { MenuComponent } from '../menu/menu.component';
 import { Production, ProductionEnum, ProductionTypeList } from '../../interfaces/production';
@@ -21,13 +22,13 @@ export class ProductionCreateComponent implements OnInit
 
   @ViewChild('formRef') productionForm!: NgForm;
   @ViewChild('boutonUploader', {static: false}) boutonUploader!: ElementRef;
-  @ViewChild('messageUpload', {static: false}) messageUpload!: ElementRef;
-  @ViewChild('messageErreur', {static: false}) messageErreur!: ElementRef;
+  @ViewChild('labelMessage', {static: false}) labelMessage!: ElementRef;
 
   production: Production = new Production();
   uploaderFichier: boolean = false;
   fichier!: any;
   reliquat: number = 0;
+  digestat: string = "";
   chunkIndex = 0;
 
   constructor(
@@ -57,8 +58,26 @@ export class ProductionCreateComponent implements OnInit
 
       this.fichier = et.files[0];
       this.uploaderFichier = true;
+      this.computeChecksumMd5(this.fichier).then(md5 => { this.digestat = md5 });
 		}
   }
+  /** https://dev.to/qortex/compute-md5-checksum-for-a-file-in-typescript-59a4 */
+  computeChecksumMd5(file: File): Promise<string> {
+    return new Promise((resolve, reject) => {
+      const chunkSize = 2097152;
+      const spark = new SparkMD5.ArrayBuffer();
+      const fileReader = new FileReader();
+      let cursor = 0;
+
+      fileReader.onerror = function(): void { reject('MD5 computation failed - error reading the file'); };
+
+      function processChunk(chunk_start: number): void { const chunk_end = Math.min(file.size, chunk_start + chunkSize); fileReader.readAsArrayBuffer(file.slice(chunk_start, chunk_end)); }
+
+      fileReader.onload = function(e: any): void { spark.append(e.target.result); cursor += chunkSize; if (cursor < file.size) { processChunk(cursor); } else { resolve(spark.end()); } };
+
+      processChunk(0);
+    });
+  }
 
   onVignetteSelected(event: any)
   {
@@ -77,6 +96,7 @@ export class ProductionCreateComponent implements OnInit
 
   async saveArchive(id: number)
   {
+    const spark = new SparkMD5.ArrayBuffer();
     const chunkSize = 1024 * 1024;
     let start = 0;
 
@@ -87,12 +107,14 @@ export class ProductionCreateComponent implements OnInit
     {
       while (start < this.fichier.size)
       {
-        const chunk = this.fichier.slice(start, start + chunkSize);
+        const end = Math.min(this.fichier.size, start + chunkSize);
+        const chunk = this.fichier.slice(start, end);
 
         await this.productionService.uploadChunk(id, chunk, this.chunkIndex, this.fichier.name);
 
         this.reliquat += chunk.size;
-        this.setMessageUpload('&nbsp;' + Math.floor((this.reliquat*100)/this.fichier.size) + '%');
+        let pourcentage = Math.floor((this.reliquat*100)/this.fichier.size);
+        this.setMessage('<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100"><div class="progress-bar" style="width:' + pourcentage +'%">' + pourcentage + '%</div></div>', false);
 
         start += chunkSize;
         this.chunkIndex++;
@@ -101,9 +123,9 @@ export class ProductionCreateComponent implements OnInit
     catch (err:any) { console.error(err); }
     finally
     {
-      this.productionService.mergeChunks(id, this.fichier.name, this.chunkIndex).subscribe({
-        next: (msg) => { this.setBoutonUploadEnd(); if (msg.erreur) { this.setMessageErreur(msg.erreur); } else { this.goToListProduction(); } },
-        error: (e:HttpErrorResponse) => { this.setBoutonUploadEnd(); this.setMessageErreur(e.error.message); },
+      this.productionService.mergeChunks(id, this.fichier.name, this.chunkIndex, this.digestat).subscribe({
+        next: (msg) => { this.setBoutonUploadEnd(); if (msg.erreur) { this.setMessage(msg.erreur, true); } else { this.goToListProduction(); } },
+        error: (e:HttpErrorResponse) => { this.setBoutonUploadEnd(); this.setMessage(e.error.message, true); },
         complete: () => { }
       });
     }
@@ -114,12 +136,11 @@ export class ProductionCreateComponent implements OnInit
 
     this.productionService.createProduction(this.production).subscribe({
       next: async (ret) => { await this.saveArchive(Number('' + ret)); this.setBoutonUploadEnd(); this.goToListProduction(); },
-      error: (e:HttpErrorResponse) => { this.setBoutonUploadEnd(); this.setMessageErreur(e.error.message); },
+      error: (e:HttpErrorResponse) => { this.setBoutonUploadEnd(); this.setMessage(e.error.message, true); },
       complete: () => { }
     });
   }
-  private setMessageUpload(m: string) { if (this.messageUpload) { this.renderer.setProperty(this.messageUpload.nativeElement, 'innerHTML', m); } }
-  private setMessageErreur(m: string) { if (this.messageErreur) { this.renderer.setProperty(this.messageErreur.nativeElement, 'innerHTML', m); } }
+  private setMessage(m: string, e: boolean) { if (this.labelMessage) { this.renderer.setProperty(this.labelMessage.nativeElement, 'innerHTML', m); if (e) { this.renderer.addClass(this.labelMessage.nativeElement, 'text-danger'); } else { this.renderer.removeClass(this.labelMessage.nativeElement, 'text-danger'); } } }
   private setBoutonUploadStart() { if (this.boutonUploader && this.uploaderFichier) { this.renderer.setProperty(this.boutonUploader.nativeElement, 'innerHTML', '<i class="fa-solid fa-upload fa-fade"></i>&nbsp;' + $localize`Téléversement en cours`); } }
   private setBoutonUploadEnd() { if (this.boutonUploader) { this.renderer.setProperty(this.boutonUploader.nativeElement, 'innerHTML', '<i class="fa-solid fa-plus"></i>&nbsp;' + $localize`Créer`); }  }
 

+ 0 - 1
src/app/composants/production-list/production-list.component.css

@@ -1,2 +1 @@
-.prod_row { overflow:auto; }
 .prod_item { min-width:10rem;max-width:17rem; }

+ 1 - 1
src/app/composants/production-list/production-list.component.html

@@ -51,7 +51,7 @@
 					<small>{{ production.commentaire }}</small><br/>
 					 <small class="text-warning">{{ production.informationsPrivees }}</small>
 				 </div>
-				<div class="card-footer d-flex justify-content-between">
+				<div class="card-footer mt-auto d-flex justify-content-between">
 					<i class="fa-solid fa-download text-primary pointeur-souris" (click)="getFile(production.numeroProduction, production.nomArchive)" tooltip="{{ production.nomArchive }} (v{{ production.numeroVersion }})" placement="top" container="body"></i>
 					<i class="fa-solid fa-user-tie text-muted" style="margin-left:7px;" i18n-tootip tooltip="géré par {{ production.nomGestionnaire }}" placement="top" container="body"></i>
 				</div>

+ 1 - 2
src/app/composants/production-upload/production-upload.component.html

@@ -26,8 +26,7 @@
 		</div>
 		<div class="card-footer hstack">
 			<button type="button" #boutonUploader class="btn bg-gradient btn-success btn-sm text-left" data-bs-toggle="modal" data-bs-target="#modalModifier" [disabled]="!uploaderFichier"><i class="fa-solid fa-upload"></i>&nbsp;<span i18n>Téléverser</span></button>
-			<div #messageUpload class="form-text" style="padding-left:7px;"></div>
-			<div #messageErreur class="form-text ms-auto text-danger" style="padding-right:7px;"></div>
+			<div #labelMessage class="form-text" style="padding-left:10px;min-width:300px;"></div>
 		</div>
 	</div>
 </form>

+ 29 - 9
src/app/composants/production-upload/production-upload.component.ts

@@ -2,6 +2,7 @@ import { Component, OnInit, ViewChild, ElementRef, Renderer2 } from '@angular/co
 import { ActivatedRoute, Router } from '@angular/router';
 import { FormsModule, NgForm } from '@angular/forms';
 import { HttpClient, HttpErrorResponse } from '@angular/common/http'
+import * as SparkMD5 from 'spark-md5';
 
 import { Environnement } from '../../env';
 import { MenuComponent } from '../menu/menu.component';
@@ -16,8 +17,7 @@ export class ProductionUploadComponent implements OnInit
 
   @ViewChild('formRef') productionForm!: NgForm;
   @ViewChild('boutonUploader', {static: false}) boutonUploader!: ElementRef;
-  @ViewChild('messageUpload', {static: false}) messageUpload!: ElementRef;
-  @ViewChild('messageErreur', {static: false}) messageErreur!: ElementRef;
+  @ViewChild('labelMessage', {static: false}) labelMessage!: ElementRef;
 
   production: ProductionFile = new ProductionFile();
 
@@ -25,6 +25,7 @@ export class ProductionUploadComponent implements OnInit
   uploaderFichier: boolean = false;
   fichier!: any;
   reliquat: number = 0;
+  digestat: string = "";
 
   constructor(
     private productionService: ProductionService,
@@ -49,8 +50,26 @@ export class ProductionUploadComponent implements OnInit
     {
       this.fichier = et.files[0];
       this.uploaderFichier = true;
+      this.computeChecksumMd5(this.fichier).then(md5 => { this.digestat = md5; console.log(md5); });
 		}
   }
+  /** https://dev.to/qortex/compute-md5-checksum-for-a-file-in-typescript-59a4 */
+  computeChecksumMd5(file: File): Promise<string> {
+    return new Promise((resolve, reject) => {
+      const chunkSize = 2097152;
+      const spark = new SparkMD5.ArrayBuffer();
+      const fileReader = new FileReader();
+      let cursor = 0;
+
+      fileReader.onerror = function(): void { reject('MD5 computation failed - error reading the file'); };
+
+      function processChunk(chunk_start: number): void { const chunk_end = Math.min(file.size, chunk_start + chunkSize); fileReader.readAsArrayBuffer(file.slice(chunk_start, chunk_end)); }
+
+      fileReader.onload = function(e: any): void { spark.append(e.target.result); cursor += chunkSize; if (cursor < file.size) { processChunk(cursor); } else { resolve(spark.end()); } };
+
+      processChunk(0);
+    });
+  }
 
   async saveProduction()
   {
@@ -66,12 +85,14 @@ export class ProductionUploadComponent implements OnInit
     {
       while (start < this.fichier.size)
       {
-        const chunk = this.fichier.slice(start, start + chunkSize);
+        const end = Math.min(this.fichier.size, start + chunkSize);
+        const chunk = this.fichier.slice(start, end);
 
         await this.productionService.uploadChunk(this.numeroProduction, chunk, chunkIndex, this.fichier.name);
 
         this.reliquat += chunk.size;
-        this.setMessageUpload('&nbsp;' + Math.floor((this.reliquat*100)/this.fichier.size) + '%');
+        let pourcentage = Math.floor((this.reliquat*100)/this.fichier.size);
+        this.setMessage('<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100"><div class="progress-bar" style="width:' + pourcentage +'%">' + pourcentage + '%</div></div>', false);
 
         start += chunkSize;
         chunkIndex++;
@@ -80,15 +101,14 @@ export class ProductionUploadComponent implements OnInit
     catch (err:any) { console.error(err); }
     finally
     {
-      this.productionService.mergeChunks(this.numeroProduction, this.fichier.name, chunkIndex).subscribe({
-        next: (msg) => { this.setBoutonUploadEnd(); if (msg.erreur) { this.setMessageErreur(msg.erreur); } else { this.goToListProduction(); } },
-        error: (e:HttpErrorResponse) => { this.setBoutonUploadEnd(); this.setMessageErreur(e.error.message); },
+      this.productionService.mergeChunks(this.numeroProduction, this.fichier.name, chunkIndex, this.digestat).subscribe({
+        next: (msg) => { this.setBoutonUploadEnd(); if (msg.erreur) { this.setMessage(msg.erreur, true); } else { this.goToListProduction(); } },
+        error: (e:HttpErrorResponse) => { this.setBoutonUploadEnd(); this.setMessage(e.error.message, true); },
         complete: () => { }
       });
     }
   }
-  private setMessageUpload(m: string) { if (this.messageUpload) { this.renderer.setProperty(this.messageUpload.nativeElement, 'innerHTML', m); } }
-  private setMessageErreur(m: string) { if (this.messageErreur) { this.renderer.setProperty(this.messageErreur.nativeElement, 'innerHTML', m); } }
+  private setMessage(m: string, e: boolean) { if (this.labelMessage) { this.renderer.setProperty(this.labelMessage.nativeElement, 'innerHTML', m); if (e) { this.renderer.addClass(this.labelMessage.nativeElement, 'text-danger'); } else { this.renderer.removeClass(this.labelMessage.nativeElement, 'text-danger'); } } }
   private setBoutonUploadStart() { if (this.boutonUploader) { this.renderer.setProperty(this.boutonUploader.nativeElement, 'innerHTML', '<i class="fa-solid fa-upload fa-fade"></i>&nbsp;' + $localize`Téléversement en cours`); } }
   private setBoutonUploadEnd() { if (this.boutonUploader) { this.renderer.setProperty(this.boutonUploader.nativeElement, 'innerHTML', '<i class="fa-solid fa-upload"></i>&nbsp;' + $localize`Téléverser`); }  }
 

+ 1 - 1
src/app/composants/show-list/show-list.component.css

@@ -1,2 +1,2 @@
-.show_row { height:30rem;overflow-x:auto; }
+.show_row { overflow-x:auto; }
 .show_item { min-width:10rem;max-width:17rem; }

+ 1 - 1
src/app/composants/show-list/show-list.component.html

@@ -44,7 +44,7 @@
         <small>{{ production.commentaire }}</small><br/>
         <small class="text-warning">{{ production.informationsPrivees }}</small>
       </div>
-      <div class="card-footer d-flex justify-content-between" [class.border-danger]="production.etatMedia === 0">
+      <div class="card-footer mt-auto d-flex justify-content-between" [class.border-danger]="production.etatMedia === 0">
         <i class="fa-solid fa-download text-primary pointeur-souris" (click)="getFile(production.numeroProduction, production.nomArchive)" tooltip="{{ production.nomArchive }} (v{{ production.numeroVersion }})" placement="top" container="body"></i>
         <i class="fa-solid fa-user-tie text-muted" style="margin-left:7px;" i18n-tootip tooltip="géré par {{ production.nomGestionnaire }}" placement="top" container="body"></i>
         @if (production.etatMedia === 0) { <i class="fa-solid fa-square-xmark text-danger ms-auto pointeur-souris" style="margin-left:7px;" (click)="formPresentation(production.numeroProduction)" i18n-tootip tooltip="nécessite le média pour présentation" placement="top" container="body"></i> }

+ 1 - 1
src/app/composants/show-upload/show-upload.component.html

@@ -119,7 +119,7 @@
   	<div class="card-footer hstack">
       @if (etatInitial === 0) { <button type="button" #boutonUploader class="btn bg-gradient btn-success btn-sm text-left" data-bs-toggle="modal" data-bs-target="#modalModifier" [disabled]="formRef.invalid"><i class="fa-solid fa-plus"></i>&nbsp;<span i18n>Valider</span></button> }
       @else { <button type="button" #boutonUploader class="btn bg-gradient btn-warning btn-sm text-left" data-bs-toggle="modal" data-bs-target="#modalModifier" [disabled]="formRef.invalid"><i class="fa-solid fa-check"></i>&nbsp;<span i18n>Valider</span></button> }
-      <div #messageErreur class="form-text ms-auto text-danger" style="padding-right:7px;"></div>
+      <div #labelMessage class="form-text text-danger" style="padding-left:10px;min-width:300px;"></div>
   	</div>
   </div>
 

+ 4 - 3
src/app/composants/show-upload/show-upload.component.ts

@@ -17,7 +17,7 @@ export class ShowUploadComponent implements OnInit
 
   @ViewChild('formRef') productionForm!: NgForm;
   @ViewChild('boutonUploader', {static: false}) boutonUploader!: ElementRef;
-  @ViewChild('messageErreur', {static: false}) messageErreur!: ElementRef;
+  @ViewChild('labelMessage', {static: false}) labelMessage!: ElementRef;
 
   numeroProduction: number = 0;
 
@@ -65,14 +65,15 @@ export class ShowUploadComponent implements OnInit
 
   private saveMedia()
   {
+    this.setMessage('');
     this.setBoutonUploadStart();
     this.presentationService.uploadMediaFile(this.numeroProduction, this.media).subscribe({
       next: () => {},
-      error: (e:HttpErrorResponse) => { this.setBoutonUploadEnd(); this.setMessageErreur(e.error.message); },
+      error: (e:HttpErrorResponse) => { this.setBoutonUploadEnd(); this.setMessage(e.error.message); },
       complete: () => { this.setBoutonUploadEnd(); this.goToListPresentation(); }
     });
   }
-  private setMessageErreur(m: string) { if (this.messageErreur) { this.renderer.setProperty(this.messageErreur.nativeElement, 'innerHTML', m); } }
+  private setMessage(m: string) { if (this.labelMessage) { this.renderer.setProperty(this.labelMessage.nativeElement, 'innerHTML', m); } }
   private setBoutonUploadStart() { if (this.boutonUploader && this.uploaderFichier) { this.renderer.setProperty(this.boutonUploader.nativeElement, 'innerHTML', '<i class="fa-solid fa-upload fa-fade"></i>&nbsp;' + $localize`Téléversement en cours`); } }
   private setBoutonUploadEnd() { if (this.boutonUploader) { this.renderer.setProperty(this.boutonUploader.nativeElement, 'innerHTML', '<i class="fa-solid fa-check"></i>&nbsp;' + $localize`Valider`); }  }
 

+ 2 - 1
src/app/services/production.service.ts

@@ -54,12 +54,13 @@ export class ProductionService
 
     return await firstValueFrom(this.httpClient.post<Message>(`${this.baseURL}/upload-chunk/${id}`, formData));
   }
-  mergeChunks(id: number, fileName: string, chunkIndex: number): Observable<Message>
+  mergeChunks(id: number, fileName: string, chunkIndex: number, checksum: string): Observable<Message>
   {
     const formData = new FormData();
 
     formData.append('fileName', fileName);
     formData.append('lastChunkIndex', '' + chunkIndex);
+    formData.append('checksum', checksum);
 
     return this.httpClient.post<Message>(`${this.baseURL}/merge-chunks/${id}`, formData);
   }