import {i18n} from './i18n'; import {css, html} from 'lit-element'; import {ScopedElementsMixin} from '@open-wc/scoped-elements'; // import JSONLD from 'vpu-common/jsonld'; import VPULitElement from 'vpu-common/vpu-lit-element'; import * as commonUtils from "vpu-common/utils"; import {Icon, MiniSpinner} from 'vpu-common'; import * as commonStyles from 'vpu-common/styles'; import {NextcloudFilePicker} from "./vpu-nextcloud-file-picker"; import {classMap} from 'lit-html/directives/class-map.js'; function mimeTypesToAccept(mimeTypes) { // Some operating systems can't handle mime types and // need file extensions, this tries to add them for some.. let mapping = { 'application/pdf': ['.pdf'], 'application/zip': ['.zip'], }; let accept = []; mimeTypes.split(',').forEach((mime) => { accept.push(mime); if (mime.trim() in mapping) { accept = accept.concat(mapping[mime.trim()]); } }); return accept.join(','); } /** * KnowledgeBaseWebPageElementView web component */ export class FileUpload extends ScopedElementsMixin(VPULitElement) { constructor() { super(); this.lang = 'de'; this.url = ''; this.nextcloudAuthUrl = ''; this.nextcloudWebDavUrl = ''; this.dropArea = null; this.allowedMimeTypes = '*/*'; this.text = ''; this.buttonLabel = ''; this.uploadInProgress = false; this.multipleUploadInProgress = false; this.alwaysSendFile = false; this.isDeferred = false; this.queuedFiles = []; this.queuedFilesCount = 0; this.disabled = false; this.decompressZip = false; this._queueKey = 0; } static get scopedElements() { return { 'vpu-icon': Icon, 'vpu-mini-spinner': MiniSpinner, 'vpu-nextcloud-file-picker': NextcloudFilePicker, }; } /** * See: https://lit-element.polymer-project.org/guide/properties#initialize */ static get properties() { return { lang: { type: String }, url: { type: String }, allowedMimeTypes: { type: String, attribute: 'allowed-mime-types' }, nextcloudAuthUrl: { type: String, attribute: 'nextcloud-auth-url' }, nextcloudWebDavUrl: { type: String, attribute: 'nextcloud-web-dav-url' }, text: { type: String }, buttonLabel: { type: String, attribute: 'button-label' }, uploadInProgress: { type: Boolean, attribute: false }, multipleUploadInProgress: { type: Boolean, attribute: false }, alwaysSendFile: { type: Boolean, attribute: 'always-send-file' }, isDeferred: { type: Boolean, attribute: 'deferred' }, queuedFilesCount: { type: Number, attribute: false }, disabled: { type: Boolean }, decompressZip: { type: Boolean, attribute: 'decompress-zip' }, }; } update(changedProperties) { changedProperties.forEach((oldValue, propName) => { switch (propName) { case "lang": i18n.changeLanguage(this.lang); break; case "queuedFilesCount": const data = { "queuedFilesCount": this.queuedFilesCount, "queuedFiles": this.queuedFiles }; const event = new CustomEvent("vpu-fileupload-queued-files-changed", { "detail": data, bubbles: true, composed: true }); this.dispatchEvent(event); break; } }); super.update(changedProperties); } connectedCallback() { super.connectedCallback(); this.updateComplete.then(() => { this.dropArea = this._('#dropArea'); ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { this.dropArea.addEventListener(eventName, this.preventDefaults, false) }); ['dragenter', 'dragover'].forEach(eventName => { this.dropArea.addEventListener(eventName, this.highlight.bind(this), false) }); ['dragleave', 'drop'].forEach(eventName => { this.dropArea.addEventListener(eventName, this.unhighlight.bind(this), false) }); this.dropArea.addEventListener('drop', this.handleDrop.bind(this), false); this._('#fileElem').addEventListener('change', this.handleChange.bind(this)); }); } preventDefaults (e) { e.preventDefault(); e.stopPropagation(); } highlight(e) { if (this.uploadInProgress) { return; } this.dropArea.classList.add('highlight') } unhighlight(e) { this.dropArea.classList.remove('highlight') } handleDrop(e) { if (this.uploadInProgress || this.disabled) { return; } let dt = e.dataTransfer; console.dir(dt); let files = dt.files; this.handleFiles(files); } async handleChange(e) { let fileElem = this._('#fileElem'); if (fileElem.files.length === 0) { return; } await this.handleFiles(fileElem.files); // reset the element's value so the user can upload the same file(s) again fileElem.value = ''; } /** * Handles files that were dropped to or selected in the component * * @param files * @returns {Promise<void>} */ async handleFiles(files) { console.log('handleFiles: files.length = ' + files.length); this.multipleUploadInProgress = true; this.dispatchEvent(new CustomEvent("vpu-fileupload-all-start", { "detail": {}, bubbles: true, composed: true })); // we need to copy the files to another array or else they will be gone in the setTimeout function! let tempFilesToHandle = []; await commonUtils.asyncArrayForEach(files, async (file) => { if (file.size === 0) { console.log('file \'' + file.name + '\' has size=0 and is denied!') return; } // check if we want to decompress the zip and queue the contained files if (this.decompressZip && file.type === "application/zip") { // add decompressed files to tempFilesToHandle tempFilesToHandle = tempFilesToHandle.concat(await this.decompressZIP(file)); return; } else if (this.allowedMimeTypes && !this.checkFileType(file)) { return; } tempFilesToHandle.push(file); }); // the browsers don't render updates to the dom while these files are handled! // if we set a small delay the dom changes will be rendered setTimeout(async () => { // we need to wait for each upload until we start the next one await commonUtils.asyncArrayForEach(tempFilesToHandle, async (file) => this.isDeferred ? this.queueFile(file) : this.uploadFile(file)); this.multipleUploadInProgress = false; this.dispatchEvent(new CustomEvent("vpu-fileupload-all-finished", { "detail": {}, bubbles: true, composed: true })); }, 100); } checkFileType(file) { // check if file is allowed const [fileMainType, fileSubType] = file.type.split('/'); const mimeTypes = this.allowedMimeTypes.split(','); let deny = true; mimeTypes.forEach((str) => { const [mainType, subType] = str.split('/'); deny = deny && ((mainType !== '*' && mainType !== fileMainType) || (subType !== '*' && subType !== fileSubType)); }); if (deny) { console.log(`mime type ${file.type} of file '${file.name}' is not compatible with ${this.allowedMimeTypes}`); return false; } return true; } /** * Decompress files synchronously * * @param file * @returns {Promise<[]>} */ async decompressZIP(file) { // see: https://stuk.github.io/jszip/ let JSZip = (await import('jszip/dist/jszip.js')).default; let filesToHandle = []; // load zip file await JSZip.loadAsync(file) .then(async (zip) => { // we are not using zip.forEach because we need to handle those files synchronously which // isn't supported by JSZip (see https://github.com/Stuk/jszip/issues/281) // using zip.files directly works great! await commonUtils.asyncObjectForEach(zip.files, async (zipEntry) => { // skip directory entries if (zipEntry.dir) { return; } await zipEntry.async("blob") .then(async (blob) => { // get mime type of Blob, see https://github.com/Stuk/jszip/issues/626 const mimeType = await commonUtils.getMimeTypeOfFile(blob); // create new file with name and mime type const zipEntryFile = new File([blob], zipEntry.name, { type: mimeType }); // check mime type if (!this.checkFileType(zipEntryFile)) { return; } filesToHandle.push(zipEntryFile); }, (e) => { // handle the error console.error("Decompressing of file in " + file.name + " failed: " + e.message); }); }); }, function (e) { // handle the error console.error("Loading of " + file.name + " failed: " + e.message); }); return filesToHandle; } async sendFinishedEvent(response, file, sendFile = false) { if (response === undefined) { return; } let data = { fileName: file.name, status: response.status, json: {"hydra:description": ""} }; try { await response.json().then((json) => { data.json = json; }); } catch (e) {} if (sendFile) { data.file = file; } const event = new CustomEvent("vpu-fileupload-file-finished", { "detail": data, bubbles: true, composed: true }); this.dispatchEvent(event); } sendStartEvent(file) { let data = { fileName: file.name, fileSize: file.size, }; this.dispatchEvent(new CustomEvent("vpu-fileupload-file-start", { "detail": data, bubbles: true, composed: true })); } /** * @param file * @returns {Promise<number>} key of the queued item */ async queueFile(file) { this._queueKey++; const key = this._queueKey; this.queuedFiles[key] = file; this.updateQueuedFilesCount(); const data = {"file": file}; const event = new CustomEvent("vpu-fileupload-file-queued", { "detail": data, bubbles: true, composed: true }); this.dispatchEvent(event); return key; } /** * Takes a file off of the queue * * @param key */ takeFileFromQueue(key) { const file = this.queuedFiles[key]; delete this.queuedFiles[key]; this.updateQueuedFilesCount(); return file; } uploadOneQueuedFile() { const file = this.takeFileFromQueue(); return this.uploadFile(file); } getQueuedFile(key) { return this.queuedFiles[key]; } getQueuedFiles() { return this.queuedFiles; } clearQueuedFiles() { this.queuedFiles = []; this.queuedFilesCount = 0; } updateQueuedFilesCount() { return this.queuedFilesCount = Object.keys(this.queuedFiles).length; } getQueuedFilesCount() { return this.queuedFilesCount; } /** * @param file * @param params * @returns {Promise<void>} */ async uploadFile(file, params = {}) { this.uploadInProgress = true; this.sendStartEvent(file); let url = new URL(this.url) url.search = new URLSearchParams(params).toString(); let formData = new FormData(); formData.append('file', file); // I got a 60s timeout in Google Chrome and found no way to increase that await fetch(url, { method: 'POST', headers: { 'Authorization': 'Bearer ' + window.VPUAuthToken, }, body: formData }) .then((response) => { /* Done. Inform the user */ console.log(`Status: ${response.status} for file ${file.name}`); this.sendFinishedEvent(response, file, response.status !== 201 || this.alwaysSendFile); }) .catch((response) => { /* Error. Inform the user */ console.log(`Error status: ${response.status} for file ${file.name}`); this.sendFinishedEvent(response, file, true); }); this.uploadInProgress = false; } static get styles() { // language=css return css` ${commonStyles.getGeneralCSS()} ${commonStyles.getButtonCSS()} #dropArea { border: var(--FUBorderWidth, 2px) var(--FUBorderStyle, dashed) var(--FUBBorderColor, black); border-radius: var(--FUBorderRadius, 0); width: var(--FUWidth, auto); margin: var(--FUMargin, 0px); padding: var(--FUPadding, 20px); } #dropArea.highlight { border-color: var(--FUBorderColorHighlight, purple); } p { margin-top: 0; } #fileElem { display: none; } #nextcloud-file-picker { display: inline-block; margin-left: 8px; } #nextcloud-file-picker.hidden { display: none; } .block { margin-bottom: 10px; } `; } render() { let allowedMimeTypes = this.allowedMimeTypes; if (this.decompressZip) { allowedMimeTypes += ",application/zip"; } return html` <div id="dropArea"> <div title="${this.uploadInProgress ? i18n.t('upload-disabled-title') : ''}"> <div class="block"> ${this.text || i18n.t('intro')} </div> <input ?disabled="${this.uploadInProgress || this.disabled}" type="file" id="fileElem" multiple accept="${mimeTypesToAccept(allowedMimeTypes)}" name='file'> <label class="button is-primary" for="fileElem" ?disabled="${this.disabled}"> <vpu-icon style="display: ${this.uploadInProgress ? "inline-block" : "none"}" name="lock"></vpu-icon> ${this.buttonLabel || i18n.t('upload-label')} </label> <vpu-nextcloud-file-picker id="nextcloud-file-picker" class="${classMap({hidden: this.nextcloudWebDavUrl === "" || this.nextcloudAuthUrl === ""})}" ?disabled="${this.uploadInProgress || this.disabled}" lang="${this.lang}" auth-url="${this.nextcloudAuthUrl}" web-dav-url="${this.nextcloudWebDavUrl}" @vpu-nextcloud-file-picker-file-downloaded="${(event) => { this.queueFile(event.detail.file); }}"></vpu-nextcloud-file-picker> <vpu-mini-spinner style="display: ${this.multipleUploadInProgress ? "inline-block" : "none"}"></vpu-mini-spinner> </div> </div> `; } }