diff --git a/scripts/langindex.json b/scripts/langindex.json index c741bd67b51..e02b27763c8 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1141,11 +1141,15 @@ "addon.notifications.therearentnotificationsyet": "local_moodlemobileapp", "addon.notifications.typeofnotification": "local_moodlemobileapp", "addon.notifications.unreadnotification": "message", + "addon.privatefiles.availableoffline": "local_moodlemobileapp", + "addon.privatefiles.confirmremoveselectedfiles": "local_moodlemobileapp", "addon.privatefiles.couldnotloadfiles": "local_moodlemobileapp", "addon.privatefiles.emptyfilelist": "local_moodlemobileapp", "addon.privatefiles.erroruploadnotworking": "local_moodlemobileapp", "addon.privatefiles.files": "moodle", + "addon.privatefiles.filedeletedsuccessfully": "local_moodlemobileapp", "addon.privatefiles.privatefiles": "moodle", + "addon.privatefiles.selectall": "local_moodlemobileapp", "addon.privatefiles.sitefiles": "moodle", "addon.qtype_essay.maxwordlimitboundary": "qtype_essay", "addon.qtype_essay.minwordlimitboundary": "qtype_essay", diff --git a/src/addons/privatefiles/components/file-actions.html b/src/addons/privatefiles/components/file-actions.html new file mode 100644 index 00000000000..7532a7539ec --- /dev/null +++ b/src/addons/privatefiles/components/file-actions.html @@ -0,0 +1,29 @@ +
+ + + + + + {{ filename }} + + + + + +
+ +
+ + + + + + + + diff --git a/src/addons/privatefiles/components/file-actions.scss b/src/addons/privatefiles/components/file-actions.scss new file mode 100644 index 00000000000..da555c7bcfe --- /dev/null +++ b/src/addons/privatefiles/components/file-actions.scss @@ -0,0 +1,8 @@ +hr { + background: var(--gray-300); +} + +ion-thumbnail { + --size: 1.5rem; + margin-inline-end: 0.5rem; +} diff --git a/src/addons/privatefiles/components/file-actions.ts b/src/addons/privatefiles/components/file-actions.ts new file mode 100644 index 00000000000..edd8d6913ee --- /dev/null +++ b/src/addons/privatefiles/components/file-actions.ts @@ -0,0 +1,51 @@ +// (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 { CoreSharedModule } from '@/core/shared.module'; +import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core'; +import { CoreModalComponent } from '@classes/modal-component'; + +@Component({ + selector: 'core-privatefiles-file-actions', + styleUrl: './file-actions.scss', + templateUrl: 'file-actions.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CoreSharedModule], +}) +export class AddonPrivateFilesFileActionsComponent extends CoreModalComponent + implements OnInit { + + @Input({ required: true }) selected = false; + @Input({ required: true }) filename = ''; + @Input({ required: true }) icon = ''; + + toggleValue = false; + + constructor(elementRef: ElementRef) { + super(elementRef); + } + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.toggleValue = this.selected; + } + +} + +export type AddonPrivateFilesFileActionsComponentParams = { + status: 'cancel' | 'deleteOnline' | 'deleteOffline' | 'download'; +}; diff --git a/src/addons/privatefiles/lang.json b/src/addons/privatefiles/lang.json index 585d7684db6..8e0c55b9042 100644 --- a/src/addons/privatefiles/lang.json +++ b/src/addons/privatefiles/lang.json @@ -4,5 +4,9 @@ "erroruploadnotworking": "Unfortunately it is currently not possible to upload files to your site.", "files": "Files", "privatefiles": "Private files", - "sitefiles": "Site files" + "sitefiles": "Site files", + "filedeletedsuccessfully": "You have deleted {{filename}} succesfully", + "availableoffline": "Available offline", + "confirmremoveselectedfiles": "This will permanently delete selected files. Are you sure about this action?", + "selectall": "Select all" } diff --git a/src/addons/privatefiles/pages/index/index.html b/src/addons/privatefiles/pages/index/index.html index 14080063a6d..9bbe54502e9 100644 --- a/src/addons/privatefiles/pages/index/index.html +++ b/src/addons/privatefiles/pages/index/index.html @@ -1,11 +1,24 @@ + @if (selectFilesEnabled()) { + + + } @else { + } -

{{ title }}

+

{{ selectFilesEnabled() ? (selectedFiles.length + ' ' + title) : title }}

+ + @if (selectFilesEnabled()) { + + + } +
@@ -28,6 +41,7 @@

{{ title }}

+ {{ 'core.quotausage' | translate:{$a: {used: spaceUsed, total: userQuotaReadable} } }} @@ -42,7 +56,11 @@

{{ title }}

{{file.filename}}
- + @@ -51,10 +69,20 @@

{{ title }}

- + @if (showUpload && root !== 'site' && !path && !selectFilesEnabled()) { + + }
+ +@if (selectFilesEnabled()) { +
+ + {{ 'addon.privatefiles.selectall' | translate }} + +
+} diff --git a/src/addons/privatefiles/pages/index/index.scss b/src/addons/privatefiles/pages/index/index.scss new file mode 100644 index 00000000000..9c2040c00ab --- /dev/null +++ b/src/addons/privatefiles/pages/index/index.scss @@ -0,0 +1,4 @@ + +.addon-privatefiles-selectall { + padding: 0.3rem 0 0.3rem 1rem; +} diff --git a/src/addons/privatefiles/pages/index/index.ts b/src/addons/privatefiles/pages/index/index.ts index 0dc3cc7202c..37cbb4faff6 100644 --- a/src/addons/privatefiles/pages/index/index.ts +++ b/src/addons/privatefiles/pages/index/index.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit, signal } from '@angular/core'; import { Md5 } from 'ts-md5/dist/md5'; import { CoreNetwork } from '@services/network'; @@ -33,6 +33,14 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreNavigator } from '@services/navigator'; import { CoreTime } from '@singletons/time'; import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; +import { CoreLoadings } from '@services/loadings'; +import { CoreIonLoadingElement } from '@classes/ion-loading'; +import { CoreFile } from '@services/file'; +import { CoreModals } from '@services/modals'; +import { CoreFilepool } from '@services/filepool'; +import { CoreFileHelper } from '@services/file-helper'; +import { CoreToasts, ToastDuration } from '@services/toasts'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; /** * Page that displays the list of files. @@ -40,6 +48,7 @@ import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; @Component({ selector: 'page-addon-privatefiles-index', templateUrl: 'index.html', + styleUrl: './index.scss', }) export class AddonPrivateFilesIndexPage implements OnInit, OnDestroy { @@ -56,6 +65,9 @@ export class AddonPrivateFilesIndexPage implements OnInit, OnDestroy { files?: AddonPrivateFilesFile[]; // List of files. component!: string; // Component to link the file downloads to. filesLoaded = false; // Whether the files are loaded. + selectFilesEnabled = signal(false); + selectedFiles: AddonPrivateFilesFile[] = []; + selectAll = false; protected updateSiteObserver: CoreEventObserver; protected logView: () => void; @@ -279,4 +291,180 @@ export class AddonPrivateFilesIndexPage implements OnInit, OnDestroy { this.updateSiteObserver?.off(); } + /** + * Delete a private file. + * + * @param file File to remove. + * @param showLoading Show loading. + */ + async deleteFile(file: AddonPrivateFilesFile, showLoading = true): Promise { + let loading: CoreIonLoadingElement | undefined = undefined; + + if (showLoading) { + loading = await CoreLoadings.show(); + } + + try { + await AddonPrivateFiles.deleteFile(file); + await this.refreshFiles(); + } catch (error) { + await CoreDomUtils.showErrorModalDefault(error, 'An error occourred while file was being deleted.'); + } + + if (loading) { + loading.dismiss(); + } + } + + /** + * Delete private files. + */ + async deleteFiles(): Promise { + try { + await CoreDomUtils.showDeleteConfirm('addon.privatefiles.confirmremoveselectedfiles'); + } catch (error) { + if (!CoreDomUtils.isCanceledError(error)) { + throw error; + } + + return; + } + + const loading = await CoreLoadings.show(); + + for (const file of this.selectedFiles) { + await this.deleteFile(file, false); + } + + loading.dismiss(); + + const message = Translate.instant( + 'addon.privatefiles.filedeletedsuccessfully', + { filename: this.selectedFiles.length + ' ' + Translate.instant('addon.privatefiles.files') }, + ); + + this.selectedFiles = []; + this.selectFilesEnabled.set(false); + await CoreToasts.show({ message, translateMessage: false, duration: ToastDuration.SHORT }); + } + + /** + * File selection changes. + * + * @param event selection value. + * @param file File selection. + */ + selectedFileValueChanged(event: boolean, file: AddonPrivateFilesFile): void { + if (event) { + this.selectedFiles.push(file); + + return; + } + + this.selectedFiles = this.selectedFiles.filter(selectedFile => selectedFile !== file); + } + + /** + * Cancel file selection. + */ + cancelFileSelection(): void { + this.selectFilesEnabled.set(false); + this.selectedFiles = []; + } + + /** + * Open file management menu. + * + * @param file File to manage. + * @returns Promise done. + */ + async openManagementFileMenu(file: AddonPrivateFilesFile): Promise { + const siteId = CoreSites.getCurrentSiteId(); + const fileState = await CoreFilepool.getFileStateByUrl(siteId, file.fileurl, file.timemodified); + const isFileDownloaded = CoreFileHelper.isStateDownloaded(fileState); + const { AddonPrivateFilesFileActionsComponent } = await import('@addons/privatefiles/components/file-actions'); + + const icon = file.mimetype + ? CoreMimetypeUtils.getMimetypeIcon(file.mimetype) + : CoreMimetypeUtils.getFileIcon(file.filename); + + const { status } = await CoreModals.openSheet( + AddonPrivateFilesFileActionsComponent, + { selected: isFileDownloaded, filename: file.filename, icon }, + ); + + if (status === 'cancel') { + return; + } + + const loading = await CoreLoadings.show(); + + if (status === 'deleteOnline') { + try { + await CoreDomUtils.showDeleteConfirm('core.confirmdeletefile'); + } catch (error) { + if (!CoreDomUtils.isCanceledError(error)) { + throw error; + } + + return; + } + + await this.deleteFile(file); + const message = Translate.instant('addon.privatefiles.filedeletedsuccessfully', { filename: file.filename }); + await CoreToasts.show({ message, translateMessage: false, duration: ToastDuration.SHORT }); + + return loading.dismiss(); + } + + if (status === 'deleteOffline') { + try { + const filePath = await CoreFilepool.getFilePathByUrl(siteId, file.fileurl); + await CoreFile.removeFile(filePath); + file.downloadState = undefined; + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'core.errordeletefile', true); + } + + return loading.dismiss(); + } + + try { + await CoreFilepool.addToQueueByUrl( + siteId, + CoreFileHelper.getFileUrl(file), + this.component, + file.contextid, + file.timemodified, + undefined, + undefined, + 0, + file, + ); + + file.downloadState = 'downloaded'; + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'core.errordownloading', true); + } finally { + loading.dismiss(); + } + } + + /** + * Select all changes + * + * @param checked Select all toggle value. + */ + onSelectAllChanges(checked: boolean): void { + if (!this.files) { + return; + } + + for (const file of this.files) { + file.selected = checked; + } + + this.selectedFiles = checked ? [...this.files] : []; + } + } diff --git a/src/addons/privatefiles/services/privatefiles.ts b/src/addons/privatefiles/services/privatefiles.ts index c638bda9c2f..10c2541acaa 100644 --- a/src/addons/privatefiles/services/privatefiles.ts +++ b/src/addons/privatefiles/services/privatefiles.ts @@ -20,6 +20,8 @@ import { CoreWSExternalWarning } from '@services/ws'; import { CoreSite } from '@classes/sites/site'; import { makeSingleton } from '@singletons'; import { ContextLevel } from '@/core/constants'; +import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; +import { CoreFilepool } from '@services/filepool'; const ROOT_CACHE_KEY = 'mmaFiles:'; @@ -92,8 +94,10 @@ export class AddonPrivateFilesProvider { return []; } - return result.files.map((entry) => { + return await Promise.all(result.files.map(async (entry) => { entry.fileurl = entry.url; + const fileState = await CoreFilepool.getFileStateByUrl(site.id, entry.fileurl, entry.timemodified); + entry.downloadState = fileState; if (entry.isdir) { entry.imgPath = CoreMimetypeUtils.getFolderIcon(); @@ -102,7 +106,7 @@ export class AddonPrivateFilesProvider { } return entry; - }); + })); } @@ -388,6 +392,22 @@ export class AddonPrivateFilesProvider { return site.write('core_user_add_user_private_files', params, preSets); } + /** + * Delete a private file. + * + * @param file Private file to remove. + * @param siteId Site ID. + */ + async deleteFile({ filename, filepath }: AddonPrivateFilesFile, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const { draftitemid } = await site.write( + 'core_user_prepare_private_files_for_edition', + {}, + ); + await CoreFileUploader.deleteDraftFiles(draftitemid, [{ filename, filepath }]); + await site.write('core_user_update_private_files', { draftitemid }); + } + } export const AddonPrivateFiles = makeSingleton(AddonPrivateFilesProvider); @@ -409,6 +429,7 @@ export type AddonPrivateFilesFile = { filesize?: number; // File size. author?: string; // File owner. license?: string; // File license. + mimetype?: string; } & AddonPrivateFilesFileCalculatedData; /** @@ -417,6 +438,8 @@ export type AddonPrivateFilesFile = { export type AddonPrivateFilesFileCalculatedData = { fileurl: string; // File URL, using same name as CoreWSExternalFile. imgPath?: string; // Path to file icon's image. + downloadState?: string; + selected?: boolean; }; /** * Params of WS core_files_get_files. @@ -472,3 +495,12 @@ export type AddonPrivateFilesGetUserInfoWSResult = { type AddonPrivateFilesAddUserPrivateFilesWSParams = { draftid: number; // Draft area id. }; + +/** + * Body of core_user_prepare_private_files_for_edition WS response. + */ +type AddonPrivateFilesPreparePrivateFilesForEditionResponse = { + areaoptions: { name: string; value: string | number }[]; + draftitemid: number; + warnings?: CoreWSExternalWarning[]; +}; diff --git a/src/core/components/file/core-file.html b/src/core/components/file/core-file.html index e053a2fbaa9..6adb80190dc 100644 --- a/src/core/components/file/core-file.html +++ b/src/core/components/file/core-file.html @@ -1,10 +1,26 @@ - - + + + @if (showCheckbox) { + + } @else { + - -

{{fileName}}

+ } + + +

+ {{fileName}} + + @if (downloadState === 'downloaded') { + + } +

+ +

{{ fileSizeReadable }} ยท @@ -19,10 +35,16 @@