diff --git a/src/core/components/attachments/attachments.ts b/src/core/components/attachments/attachments.ts new file mode 100644 index 000000000..6bcfd8666 --- /dev/null +++ b/src/core/components/attachments/attachments.ts @@ -0,0 +1,155 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit } from '@angular/core'; +import { FileEntry } from '@ionic-native/file/ngx'; + +import { CoreFileUploader, CoreFileUploaderTypeList } from '@features/fileuploader/services/fileuploader'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreWSExternalFile } from '@services/ws'; +import { Translate } from '@singletons'; +import { CoreApp } from '@services/app'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreFileUploaderHelper } from '@features/fileuploader/services/fileuploader-helper'; + +/** + * Component to render attachments, allow adding more and delete the current ones. + * + * All the changes done will be applied to the "files" input array, no file will be uploaded. The component using this + * component should be the one uploading and moving the files. + * + * All the files added will be copied to the app temporary folder, so they should be deleted after uploading them + * or if the user cancels the action. + * + * + * + */ +@Component({ + selector: 'core-attachments', + templateUrl: 'core-attachments.html', +}) +export class CoreAttachmentsComponent implements OnInit { + + @Input() files?: (CoreWSExternalFile | FileEntry)[]; // List of attachments. New attachments will be added to this array. + @Input() maxSize?: number; // Max size for attachments. -1 means unlimited, 0 means user max size, not defined means unknown. + @Input() maxSubmissions?: number; // Max number of attachments. -1 means unlimited, not defined means unknown limit. + @Input() component?: string; // Component the downloaded files will be linked to. + @Input() componentId?: string | number; // Component ID. + @Input() allowOffline?: boolean | string; // Whether to allow selecting files in offline. + @Input() acceptedTypes?: string; // List of supported filetypes. If undefined, all types supported. + @Input() required?: boolean; // Whether to display the required mark. + + maxSizeReadable?: string; + maxSubmissionsReadable?: string; + unlimitedFiles?: boolean; + fileTypes?: CoreFileUploaderTypeList; + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.files = this.files || []; + this.maxSize = this.maxSize !== null ? Number(this.maxSize) : NaN; + + if (this.maxSize === 0) { + const currentSite = CoreSites.instance.getCurrentSite(); + const siteInfo = currentSite?.getInfo(); + + if (siteInfo?.usermaxuploadfilesize) { + this.maxSize = siteInfo.usermaxuploadfilesize; + this.maxSizeReadable = CoreTextUtils.instance.bytesToSize(this.maxSize, 2); + } else { + this.maxSizeReadable = Translate.instance.instant('core.unknown'); + } + } else if (this.maxSize > 0) { + this.maxSizeReadable = CoreTextUtils.instance.bytesToSize(this.maxSize, 2); + } else if (this.maxSize === -1) { + this.maxSizeReadable = Translate.instance.instant('core.unlimited'); + } else { + this.maxSizeReadable = Translate.instance.instant('core.unknown'); + } + + if (this.maxSubmissions === undefined || this.maxSubmissions < 0) { + this.maxSubmissionsReadable = this.maxSubmissions === undefined ? + Translate.instance.instant('core.unknown') : undefined; + this.unlimitedFiles = true; + } else { + this.maxSubmissionsReadable = String(this.maxSubmissions); + } + + this.acceptedTypes = this.acceptedTypes?.trim(); + + if (this.acceptedTypes && this.acceptedTypes != '*') { + this.fileTypes = CoreFileUploader.instance.prepareFiletypeList(this.acceptedTypes); + } + } + + /** + * Add a new attachment. + */ + async add(): Promise { + const allowOffline = !!this.allowOffline && this.allowOffline !== 'false'; + + if (!allowOffline && !CoreApp.instance.isOnline()) { + CoreDomUtils.instance.showErrorModal('core.fileuploader.errormustbeonlinetoupload', true); + + return; + } + + const mimetypes = this.fileTypes && this.fileTypes.mimetypes; + + try { + const result = await CoreFileUploaderHelper.instance.selectFile(this.maxSize, allowOffline, undefined, mimetypes); + + this.files?.push(result); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error selecting file.'); + } + } + + /** + * Delete a file from the list. + * + * @param index The index of the file. + * @param askConfirm Whether to ask confirm. + */ + async delete(index: number, askConfirm?: boolean): Promise { + + + if (askConfirm) { + try { + await CoreDomUtils.instance.showDeleteConfirm('core.confirmdeletefile'); + } catch { + // User cancelled. + return; + } + } + + // Remove the file from the list. + this.files?.splice(index, 1); + } + + /** + * A file was renamed. + * + * @param index Index of the file. + * @param data The data received. + */ + renamed(index: number, data: { file: FileEntry }): void { + this.files![index] = data.file; + } + +} diff --git a/src/core/components/attachments/core-attachments.html b/src/core/components/attachments/core-attachments.html new file mode 100644 index 000000000..a0d6143d0 --- /dev/null +++ b/src/core/components/attachments/core-attachments.html @@ -0,0 +1,38 @@ + + + + {{ 'core.maxsizeandattachments' | translate:{$a: {size: maxSizeReadable, attachments: maxSubmissionsReadable} } }} + + {{ 'core.maxfilesize' | translate:{$a: maxSizeReadable} }} + + + + + +

{{ 'core.fileuploader.filesofthesetypes' | translate }}

+
    +
  • + {{typeInfo.name}} {{typeInfo.extlist}} +
  • +
+
+
+
+ + + + + + + +
+ + + + + {{ 'core.fileuploader.addfiletext' | translate }} + diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 9b0bcd00c..0d676ac5e 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -48,6 +48,9 @@ import { CoreNavigationBarComponent } from './navigation-bar/navigation-bar'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; +import { CoreAttachmentsComponent } from './attachments/attachments'; +import { CoreFilesComponent } from './files/files'; +import { CoreLocalFileComponent } from './local-file/local-file'; @NgModule({ declarations: [ @@ -78,6 +81,9 @@ import { CorePipesModule } from '@pipes/pipes.module'; CoreSendMessageFormComponent, CoreTimerComponent, CoreNavigationBarComponent, + CoreAttachmentsComponent, + CoreFilesComponent, + CoreLocalFileComponent, ], imports: [ CommonModule, @@ -115,6 +121,9 @@ import { CorePipesModule } from '@pipes/pipes.module'; CoreSendMessageFormComponent, CoreTimerComponent, CoreNavigationBarComponent, + CoreAttachmentsComponent, + CoreFilesComponent, + CoreLocalFileComponent, ], }) export class CoreComponentsModule {} diff --git a/src/core/components/files/core-files.html b/src/core/components/files/core-files.html new file mode 100644 index 000000000..d16b3f24c --- /dev/null +++ b/src/core/components/files/core-files.html @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/src/core/components/files/files.ts b/src/core/components/files/files.ts new file mode 100644 index 000000000..0a3e6f4f3 --- /dev/null +++ b/src/core/components/files/files.ts @@ -0,0 +1,85 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit, DoCheck, KeyValueDiffers } from '@angular/core'; +import { FileEntry } from '@ionic-native/file/ngx'; + +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; + +/** + * Component to render a file list. + * + * + * + */ +@Component({ + selector: 'core-files', + templateUrl: 'core-files.html', +}) +export class CoreFilesComponent implements OnInit, DoCheck { + + @Input() files?: (CoreWSExternalFile | FileEntry)[]; // List of files. + @Input() component?: string; // Component the downloaded files will be linked to. + @Input() componentId?: string | number; // Component ID. + @Input() alwaysDownload?: boolean | string; // Whether it should always display the refresh button when the file is downloaded. + @Input() canDownload?: boolean | string = true; // Whether file can be downloaded. + @Input() showSize?: boolean | string = true; // Whether show filesize. + @Input() showTime?: boolean | string = true; // Whether show file time modified. + @Input() showInline = false; // If true, it will reorder and try to show inline files first. + + contentText?: string; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected differ: any; // To detect changes in the data input. + + constructor(differs: KeyValueDiffers) { + this.differ = differs.find([]).create(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (CoreUtils.instance.isTrueOrOne(this.showInline) && this.files) { + this.renderInlineFiles(); + } + } + + /** + * Detect and act upon changes that Angular can’t or won’t detect on its own (objects and arrays). + */ + ngDoCheck(): void { + if (CoreUtils.instance.isTrueOrOne(this.showInline) && this.files) { + // Check if there's any change in the files array. + const changes = this.differ.diff(this.files); + if (changes) { + this.renderInlineFiles(); + } + } + } + + /** + * Calculate contentText based on fils that can be rendered inline. + */ + protected renderInlineFiles(): void { + this.contentText = this.files!.reduce((previous, file) => { + const text = CoreMimetypeUtils.instance.getEmbeddedHtml(file); + + return text ? previous + '
' + text : previous; + }, ''); + } + +} diff --git a/src/core/components/local-file/core-local-file.html b/src/core/components/local-file/core-local-file.html new file mode 100644 index 000000000..af831e867 --- /dev/null +++ b/src/core/components/local-file/core-local-file.html @@ -0,0 +1,33 @@ +
+ + {{fileExtension}} + + + +

{{fileName}}

+ +

{{ size }}

+

{{ timemodified }}

+
+ + + + + +
+ + + + + + + + + + + +
+
+
diff --git a/src/core/components/local-file/local-file.ts b/src/core/components/local-file/local-file.ts new file mode 100644 index 000000000..66f6500ac --- /dev/null +++ b/src/core/components/local-file/local-file.ts @@ -0,0 +1,213 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, OnInit, EventEmitter, ViewChild, ElementRef } from '@angular/core'; +import { FileEntry } from '@ionic-native/file/ngx'; + +import { CoreIonLoadingElement } from '@classes/ion-loading'; +import { CoreFile } from '@services/file'; +import { CoreFileHelper } from '@services/file-helper'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Component to handle a local file. Only files inside the app folder can be managed. + * + * Shows the file name, icon (depending on extension), size and time modified. + * Also, if managing is enabled it will also show buttons to rename and delete the file. + */ +@Component({ + selector: 'core-local-file', + templateUrl: 'core-local-file.html', +}) +export class CoreLocalFileComponent implements OnInit { + + @Input() file?: FileEntry; // A fileEntry retrieved using CoreFileProvider.getFile or similar. + @Input() manage?: boolean | string; // Whether the user can manage the file (edit and delete). + @Input() overrideClick?: boolean | string; // Whether the default item click should be overridden. + @Output() onDelete = new EventEmitter(); // Will notify when the file is deleted. + @Output() onRename = new EventEmitter<{ file: FileEntry }>(); // Will notify when the file is renamed. + @Output() onClick = new EventEmitter(); // Will notify when the file is clicked. Only if overrideClick is true. + + @ViewChild('nameForm') formElement?: ElementRef; + + fileName?: string; + fileIcon?: string; + fileExtension?: string; + size?: string; + timemodified?: string; + newFileName = ''; + editMode = false; + relativePath?: string; + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + this.manage = CoreUtils.instance.isTrueOrOne(this.manage); + + if (!this.file) { + return; + } + + this.loadFileBasicData(this.file); + + // Get the size and timemodified. + const metadata = await CoreFile.instance.getMetadata(this.file); + if (metadata.size >= 0) { + this.size = CoreTextUtils.instance.bytesToSize(metadata.size, 2); + } + + this.timemodified = CoreTimeUtils.instance.userDate(metadata.modificationTime.getTime(), 'core.strftimedatetimeshort'); + + } + + /** + * Load the basic data for the file. + */ + protected loadFileBasicData(file: FileEntry): void { + this.fileName = file.name; + this.fileIcon = CoreMimetypeUtils.instance.getFileIcon(file.name); + this.fileExtension = CoreMimetypeUtils.instance.getFileExtension(file.name); + + // Let's calculate the relative path for the file. + this.relativePath = CoreFile.instance.removeBasePath(file.toURL()); + if (!this.relativePath) { + // Didn't find basePath, use fullPath but if the user tries to manage the file it'll probably fail. + this.relativePath = file.fullPath; + } + } + + /** + * File clicked. + * + * @param e Click event. + */ + async fileClicked(e: Event): Promise { + if (this.editMode) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + if (CoreUtils.instance.isTrueOrOne(this.overrideClick) && this.onClick.observers.length) { + this.onClick.emit(); + + return; + } + + if (!CoreFileHelper.instance.isOpenableInApp(this.file!)) { + try { + await CoreFileHelper.instance.showConfirmOpenUnsupportedFile(); + } catch (error) { + return; // Cancelled, stop. + } + } + + CoreUtils.instance.openFile(this.file!.toURL()); + } + + /** + * Activate the edit mode. + * + * @param e Click event. + */ + activateEdit(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + this.editMode = true; + this.newFileName = this.file!.name; + } + + /** + * Rename the file. + * + * @param newName New name. + * @param e Click event. + */ + async changeName(newName: string, e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + + if (newName == this.file!.name) { + // Name hasn't changed, stop. + this.editMode = false; + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading(); + const fileAndDir = CoreFile.instance.getFileAndDirectoryFromPath(this.relativePath!); + const newPath = CoreTextUtils.instance.concatenatePaths(fileAndDir.directory, newName); + + try { + // Check if there's a file with this name. + await CoreFile.instance.getFile(newPath); + + // There's a file with this name, show error and stop. + CoreDomUtils.instance.showErrorModal('core.errorfileexistssamename', true); + } catch { + try { + // File doesn't exist, move it. + const fileEntry = await CoreFile.instance.moveFile(this.relativePath!, newPath); + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId()); + + this.editMode = false; + this.file = fileEntry; + this.loadFileBasicData(this.file); + this.onRename.emit({ file: this.file }); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorrenamefile', true); + } + } finally { + modal.dismiss(); + } + } + + /** + * Delete the file. + * + * @param e Click event. + */ + async deleteFile(e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + + let modal: CoreIonLoadingElement | undefined; + + try { + // Ask confirmation. + await CoreDomUtils.instance.showDeleteConfirm('core.confirmdeletefile'); + + modal = await CoreDomUtils.instance.showModalLoading('core.deleting', true); + + await CoreFile.instance.removeFile(this.relativePath!); + + this.onDelete.emit(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordeletefile', true); + } finally { + modal?.dismiss(); + } + } + +}