diff --git a/scripts/langindex.json b/scripts/langindex.json index f9a8e8b3c83..ac33a29f89b 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -90,8 +90,8 @@ "addon.blog.associatewithmodule": "blog", "addon.blog.associations": "blog", "addon.blog.blog": "blog", - "addon.blog.blogentries": "blog", "addon.blog.blogdeleteconfirm": "blog", + "addon.blog.blogentries": "blog", "addon.blog.entrybody": "blog", "addon.blog.entrytitle": "blog", "addon.blog.errorloadentries": "local_moodlemobileapp", @@ -1566,6 +1566,8 @@ "core.confirmleaveunknownchanges": "local_moodlemobileapp", "core.confirmloss": "local_moodlemobileapp", "core.confirmopeninbrowser": "local_moodlemobileapp", + "core.confirmremoveselectedfile": "local_moodlemobileapp", + "core.confirmremoveselectedfiles": "local_moodlemobileapp", "core.connectionlost": "local_moodlemobileapp", "core.considereddigitalminor": "moodle", "core.contactsupport": "local_moodlemobileapp", @@ -1809,6 +1811,7 @@ "core.expand": "moodle", "core.explanationdigitalminor": "moodle", "core.favourites": "moodle", + "core.filedeletedsuccessfully": "local_moodlemobileapp", "core.filename": "repository", "core.filenameexist": "local_moodlemobileapp", "core.filenotfound": "resource", @@ -2390,6 +2393,7 @@ "core.reminders.units": "qtype_numerical", "core.reminders.value": "local_moodlemobileapp", "core.remove": "moodle", + "core.removedownloadeddata": "local_moodlemobileapp", "core.removefiles": "local_moodlemobileapp", "core.reportbuilder.filtersapplied": "local_moodlemobileapp", "core.reportbuilder.hidecolumns": "local_moodlemobileapp", @@ -2431,6 +2435,7 @@ "core.selectacategory": "moodle", "core.selectacourse": "moodle", "core.selectagroup": "moodle", + "core.selectall": "local_moodlemobileapp", "core.send": "message", "core.sending": "chat", "core.serverconnection": "local_moodlemobileapp", diff --git a/src/addons/privatefiles/components/file-actions/file-actions.html b/src/addons/privatefiles/components/file-actions/file-actions.html new file mode 100644 index 00000000000..2a0a5e87a92 --- /dev/null +++ b/src/addons/privatefiles/components/file-actions/file-actions.html @@ -0,0 +1,30 @@ +
+ + + + + + {{ filename }} + + + + + +
+ +
+ + + @if (isDownloaded) { + + + } + + + + diff --git a/src/addons/privatefiles/components/file-actions/file-actions.scss b/src/addons/privatefiles/components/file-actions/file-actions.scss new file mode 100644 index 00000000000..da555c7bcfe --- /dev/null +++ b/src/addons/privatefiles/components/file-actions/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/file-actions.ts b/src/addons/privatefiles/components/file-actions/file-actions.ts new file mode 100644 index 00000000000..3f4b70df50d --- /dev/null +++ b/src/addons/privatefiles/components/file-actions/file-actions.ts @@ -0,0 +1,41 @@ +// (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 } from '@angular/core'; +import { CoreModalComponent } from '@classes/modal-component'; + +@Component({ + selector: 'addon-privatefiles-file-actions', + styleUrl: './file-actions.scss', + templateUrl: 'file-actions.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CoreSharedModule], +}) +export class AddonPrivateFilesFileActionsComponent extends CoreModalComponent { + + @Input({ required: false }) isDownloaded = false; + @Input({ required: true }) filename = ''; + @Input({ required: true }) icon = ''; + + constructor(elementRef: ElementRef) { + super(elementRef); + } + +} + +export type AddonPrivateFilesFileActionsComponentParams = { + status: 'cancel' | 'deleteOnline' | 'deleteOffline' | 'download'; +}; diff --git a/src/addons/privatefiles/components/file/file.html b/src/addons/privatefiles/components/file/file.html new file mode 100644 index 00000000000..967b1382580 --- /dev/null +++ b/src/addons/privatefiles/components/file/file.html @@ -0,0 +1,51 @@ + + + @if (file) { + + + @if (showCheckbox) { + + } @else { + + + + } + + +

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

+ + +

+ {{ fileSizeReadable }} + ยท + {{ timemodified * 1000 | coreFormatDate }} +

+
+
+ + + + @if (!showCheckbox) { + + @if (!isDownloaded) { + + } + + + + } +
+
+ } + +
diff --git a/src/addons/privatefiles/components/file/file.scss b/src/addons/privatefiles/components/file/file.scss new file mode 100644 index 00000000000..34c74668bb6 --- /dev/null +++ b/src/addons/privatefiles/components/file/file.scss @@ -0,0 +1,4 @@ +ion-checkbox { + flex: none; + width: 3rem; +} diff --git a/src/addons/privatefiles/components/file/file.ts b/src/addons/privatefiles/components/file/file.ts new file mode 100644 index 00000000000..441bd15c138 --- /dev/null +++ b/src/addons/privatefiles/components/file/file.ts @@ -0,0 +1,44 @@ +// (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 { toBoolean } from '@/core/transforms/boolean'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { CoreFileComponent } from '@components/file/file'; + +@Component({ + selector: 'addon-privatefiles-file', + templateUrl: 'file.html', + standalone: true, + styleUrls: ['file.scss'], + imports: [CoreSharedModule], +}) +export class AddonPrivateFilesFileComponent extends CoreFileComponent implements OnInit, OnDestroy { + + @Input({ transform: toBoolean }) showCheckbox = true; // Show checkbox + @Input({ transform: toBoolean, required: false }) selected = false; // Selected file. + + @Output() onSelectedFileChange: EventEmitter; // Will notify when the checkbox value changes. + @Output() onOpenMenuClick: EventEmitter; // Will notify when menu clicked. + + constructor() { + super(); + this.onSelectedFileChange = new EventEmitter(); + this.onOpenMenuClick = new EventEmitter(); + } + + openMenuClick(): void { + this.onOpenMenuClick.emit(this); + } + +} diff --git a/src/addons/privatefiles/pages/index/index.html b/src/addons/privatefiles/pages/index/index.html index 14080063a6d..e1d57044f10 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,14 @@

{{ title }}

{{file.filename}}
- + + @if (!file.isdir) { + + } @@ -51,10 +72,20 @@

{{ title }}

- + @if (showUpload && root !== 'site' && !path && !selectFilesEnabled()) { + + }
+ +@if (selectFilesEnabled()) { +
+ + {{ 'core.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..5e429b733fc --- /dev/null +++ b/src/addons/privatefiles/pages/index/index.scss @@ -0,0 +1,10 @@ + +:host { + ion-item.item.item-file { + &.file-selected { + --ion-item-background: var(--primary-tint); + } + + --inner-border-width: 0 !important; + } +} diff --git a/src/addons/privatefiles/pages/index/index.ts b/src/addons/privatefiles/pages/index/index.ts index 0dc3cc7202c..05593490269 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,12 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreNavigator } from '@services/navigator'; import { CoreTime } from '@singletons/time'; import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; +import { CoreModals } from '@services/modals'; +import { CoreFilepool } from '@services/filepool'; +import { CoreFileHelper } from '@services/file-helper'; +import { CoreToasts, ToastDuration } from '@services/toasts'; +import { CoreFileComponent } from '@components/file/file'; +import { CoreLoadings } from '@services/loadings'; /** * Page that displays the list of files. @@ -40,6 +46,7 @@ import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; @Component({ selector: 'page-addon-privatefiles-index', templateUrl: 'index.html', + styleUrls: ['./index.scss'], }) export class AddonPrivateFilesIndexPage implements OnInit, OnDestroy { @@ -56,6 +63,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 +289,156 @@ export class AddonPrivateFilesIndexPage implements OnInit, OnDestroy { this.updateSiteObserver?.off(); } + /** + * Delete private files. + */ + async deleteSelectedFiles(showConfirmation = false): Promise { + if (showConfirmation) { + try { + await CoreDomUtils.showDeleteConfirm('core.confirmremoveselectedfiles'); + } catch { + return; + } + } + + const siteId = CoreSites.getCurrentSiteId(); + const loading = await CoreLoadings.show(); + await AddonPrivateFiles.deleteFiles(this.selectedFiles); + + for (const file of this.selectedFiles) { + try { + await this.deleteOfflineFile(file, siteId); + } catch (error) { + await CoreDomUtils.showErrorModalDefault(error, 'An error occourred while file was being deleted.'); + } + } + + await this.refreshFiles(); + loading.dismiss(); + + const message = Translate.instant( + 'core.filedeletedsuccessfully', + { + filename: this.selectedFiles.length === 1 + ? this.selectedFiles[0].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 selected selection value. + * @param file File selection. + */ + selectedFileValueChanged(selected: boolean, file: AddonPrivateFilesFile): void { + if (selected) { + 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 instance CoreFileComponent instance. + * @param file File to manage. + * + * @returns Promise done. + */ + async openManagementFileMenu(instance: CoreFileComponent, 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/file-actions'); + const { status } = await CoreModals.openSheet( + AddonPrivateFilesFileActionsComponent, + { isDownloaded: isFileDownloaded, filename: file.filename, icon: instance.fileIcon }, + ); + + if (status === 'cancel') { + return; + } + + if (status === 'deleteOnline') { + try { + await CoreDomUtils.showDeleteConfirm('core.confirmremoveselectedfile', { filename: file.filename }); + } catch { + return; + } + + const loading = await CoreLoadings.show(); + this.selectedFiles = [file]; + await this.deleteSelectedFiles(); + const message = Translate.instant('core.filedeletedsuccessfully', { filename: file.filename }); + await CoreToasts.show({ message, translateMessage: false, duration: ToastDuration.SHORT }); + + return loading.dismiss(); + } + + if (status === 'deleteOffline') { + return await this.deleteOfflineFile(file, siteId); + } + + await instance.download(); + } + + /** + * 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] : []; + } + + /** + * Enables multiple file selection and mark as selected the passed file. + * + * @param file File to be selected. + */ + enableMultipleSelection(file: AddonPrivateFilesFile): void { + this.selectFilesEnabled.set(true); + this.selectedFiles.push(file); + file.selected = true; + } + + /** + * Remove offline file. + * + * @param file File to remove. + * @param siteId Site ID. + */ + async deleteOfflineFile(file: AddonPrivateFilesFile, siteId: string): Promise { + try { + await CoreFilepool.removeFileByUrl(siteId, file.fileurl); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'core.errordeletefile', true); + } + } + } diff --git a/src/addons/privatefiles/privatefiles-lazy.module.ts b/src/addons/privatefiles/privatefiles-lazy.module.ts index b3a68659b84..8677298e31b 100644 --- a/src/addons/privatefiles/privatefiles-lazy.module.ts +++ b/src/addons/privatefiles/privatefiles-lazy.module.ts @@ -18,6 +18,7 @@ import { Injector, NgModule } from '@angular/core'; import { ROUTES, Routes } from '@angular/router'; import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { AddonPrivateFilesFileComponent } from './components/file/file'; /** * Build module routes. @@ -45,6 +46,7 @@ function buildRoutes(injector: Injector): Routes { @NgModule({ imports: [ CoreSharedModule, + AddonPrivateFilesFileComponent, ], declarations: [ AddonPrivateFilesIndexPage, diff --git a/src/addons/privatefiles/services/privatefiles.ts b/src/addons/privatefiles/services/privatefiles.ts index c638bda9c2f..31479494631 100644 --- a/src/addons/privatefiles/services/privatefiles.ts +++ b/src/addons/privatefiles/services/privatefiles.ts @@ -20,6 +20,7 @@ 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'; const ROOT_CACHE_KEY = 'mmaFiles:'; @@ -92,7 +93,7 @@ export class AddonPrivateFilesProvider { return []; } - return result.files.map((entry) => { + return await Promise.all(result.files.map(async (entry) => { entry.fileurl = entry.url; if (entry.isdir) { @@ -102,7 +103,7 @@ export class AddonPrivateFilesProvider { } return entry; - }); + })); } @@ -388,6 +389,46 @@ export class AddonPrivateFilesProvider { return site.write('core_user_add_user_private_files', params, preSets); } + /** + * Delete a private file. + * + * @param files Private files to remove. + * @param siteId Site ID. + */ + async deleteFiles(files: AddonPrivateFilesFile[], siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const canDeletePrivateFiles = await this.canDeletePrivateFiles(); + + if (!canDeletePrivateFiles) { + return; + } + + const { draftitemid } = await site.write( + 'core_user_prepare_private_files_for_edition', + {}, + ); + + await CoreFileUploader.deleteDraftFiles(draftitemid, files.map(file => ({ + filename: file.filename, + filepath: file.filepath, + }))); + + await site.write('core_user_update_private_files', { draftitemid }); + } + + /** + * Can delete private files in site. + * + * @param siteId Site ID + * + * @returns true or false. + */ + async canDeletePrivateFiles(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.wsAvailable('core_user_update_private_files') && site.canUseAdvancedFeature('privatefiles'); + } + } export const AddonPrivateFiles = makeSingleton(AddonPrivateFilesProvider); @@ -417,6 +458,7 @@ export type AddonPrivateFilesFile = { export type AddonPrivateFilesFileCalculatedData = { fileurl: string; // File URL, using same name as CoreWSExternalFile. imgPath?: string; // Path to file icon's image. + selected?: boolean; }; /** * Params of WS core_files_get_files. @@ -472,3 +514,12 @@ export type AddonPrivateFilesGetUserInfoWSResult = { type AddonPrivateFilesAddUserPrivateFilesWSParams = { draftid: number; // Draft area id. }; + +/** + * Body of core_user_prepare_private_files_for_edition WS response. + */ +type AddonPrivateFilesPreparePrivateFilesForEditionWSResponse = { + areaoptions: { name: string; value: string | number }[]; + draftitemid: number; + warnings?: CoreWSExternalWarning[]; +}; diff --git a/src/assets/fonts/moodle/font-awesome/cloud-x.svg b/src/assets/fonts/moodle/font-awesome/cloud-x.svg new file mode 100644 index 00000000000..d8059e7e6f0 --- /dev/null +++ b/src/assets/fonts/moodle/font-awesome/cloud-x.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/core/lang.json b/src/core/lang.json index 749bea4b696..2f08aea9a12 100644 --- a/src/core/lang.json +++ b/src/core/lang.json @@ -53,6 +53,8 @@ "confirmleaveunknownchanges": "Are you sure you want to leave this page? If you have unsaved changes they will be lost.", "confirmloss": "Are you sure? All changes will be lost.", "confirmopeninbrowser": "Do you want to open it in a web browser?", + "confirmremoveselectedfile": "This will permanently delete {{filename}}. You can't undo this.", + "confirmremoveselectedfiles": "This will permanently delete selected files. You can't undo this.", "connectionlost": "Connection to site lost", "considereddigitalminor": "You are too young to create an account on this site.", "contactsupport": "Contact support", @@ -131,6 +133,7 @@ "expand": "Expand", "explanationdigitalminor": "This information is required to determine if your age is over the digital age of consent. This is the age when an individual can consent to terms and conditions and their data being legally stored and processed.", "favourites": "Starred", + "filedeletedsuccessfully": "You have deleted {{filename}} succesfully", "filename": "Filename", "filenameexist": "File name already exists: {{$a}}", "filenotfound": "File not found, sorry.", @@ -264,6 +267,7 @@ "redirectingtosite": "You will be redirected to the site.", "refresh": "Refresh", "remove": "Remove", + "removedownloadeddata": "Remove downloaded data", "removefiles": "Remove files {{$a}}", "required": "Required", "requireduserdatamissing": "This user lacks some required profile data. Please enter the data in your site and try again.
{{$a}}", @@ -287,6 +291,7 @@ "selectacategory": "Please select a category", "selectacourse": "Select a course", "selectagroup": "Select a group", + "selectall": "Select all", "send": "Send", "sending": "Sending", "serverconnection": "Error connecting to the server: {{details}}", diff --git a/src/core/services/modals.ts b/src/core/services/modals.ts index 91d9a67b424..c5f562605bd 100644 --- a/src/core/services/modals.ts +++ b/src/core/services/modals.ts @@ -69,13 +69,13 @@ export class CoreModalsService { * @param component Component to render inside the modal. * @returns Modal result once it's been closed. */ - async openSheet(component: Constructor>): Promise { + async openSheet(component: Constructor>, componentProps: Record = {}): Promise { const container = document.querySelector('ion-app') ?? document.body; const viewContainer = container.querySelector('ion-router-outlet, ion-nav, #ion-view-container-root'); const element = await AngularFrameworkDelegate.attachViewToDom( container, CoreSheetModalComponent, - { component }, + { component, componentProps }, ); const sheetModal = CoreDirectivesRegistry.require>>( element,