From f77de2e684cc8c0b1af66e35daa413246d82f311 Mon Sep 17 00:00:00 2001 From: jenkinsEdificePublic Date: Fri, 5 Dec 2025 15:15:56 +0100 Subject: [PATCH 01/31] chore: prepare next development iteration --- admin/src/main/ts/package.json | 8 ++++---- conversation/frontend/package.json.template | 10 +++++----- package.json | 8 ++++---- pom.xml | 14 +++++++------- portal/frontend/package.json.template | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/admin/src/main/ts/package.json b/admin/src/main/ts/package.json index 82ddd37068..398adbea2c 100644 --- a/admin/src/main/ts/package.json +++ b/admin/src/main/ts/package.json @@ -1,6 +1,6 @@ { "name": "admin-app", - "version": "6.11.0", + "version": "6.11.0-%branch%.%generateVersion%", "scripts": { "ng": "ng", "start": "ng serve --host 0.0.0.0", @@ -34,9 +34,9 @@ "font-awesome": "4.7.0", "jquery": "^3.4.1", "ngx-infinite-scroll": "14.0.1", - "ngx-ode-core": "4.7.3", - "ngx-ode-sijil": "4.7.3", - "ngx-ode-ui": "4.7.3", + "ngx-ode-core": "dev", + "ngx-ode-sijil": "dev", + "ngx-ode-ui": "dev", "ngx-trumbowyg": "^6.0.7", "noty": "2.4.1", "reflect-metadata": "0.1.10", diff --git a/conversation/frontend/package.json.template b/conversation/frontend/package.json.template index a2b0a7c047..9100582ef6 100644 --- a/conversation/frontend/package.json.template +++ b/conversation/frontend/package.json.template @@ -32,11 +32,11 @@ "typecheck": "tsc -b --noEmit --pretty" }, "dependencies": { - "@edifice.io/react": "2.5.4", - "@edifice.io/bootstrap": "2.5.4", - "@edifice.io/client": "2.5.4", - "@edifice.io/utilities": "2.5.4", - "@edifice.io/tiptap-extensions": "2.5.4", + "@edifice.io/react": "%packageVersion%", + "@edifice.io/bootstrap": "%packageVersion%", + "@edifice.io/client": "%packageVersion%", + "@edifice.io/utilities": "%packageVersion%", + "@edifice.io/tiptap-extensions": "%packageVersion%", "@react-spring/web": "^9.7.5", "@tanstack/react-query": "^5.59.13", "clsx": "^2.1.1", diff --git a/package.json b/package.json index 65d4b35fcc..569d8174e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ent-core", - "version": "6.11.0", + "version": "6.11.0-dev.0", "description": "", "main": "gulpfile.js", "directories": { @@ -28,7 +28,7 @@ "angular-sanitize": "1.8.3", "axios": "0.15.3", "core-js": "^2.4.1", - "entcore": "4.8.14", + "entcore": "dev", "entcore-generic-icons": "https://github.com/edificeio/generic-icons.git", "entcore-toolkit": "^1.0.1", "humane-js": "^3.2.2", @@ -76,8 +76,8 @@ "karma-jasmine": "~1.1.0", "karma-jasmine-html-reporter": "^0.2.2", "merge2": "^1.0.3", - "ode-ngjs-front": "1.4.19", - "ode-ts-client": "1.2.4", + "ode-ngjs-front": "dev", + "ode-ts-client": "dev", "sass-loader": "^13.0.2", "source-map-loader": "^0.1.5", "ts-loader": "^3.1.1", diff --git a/pom.xml b/pom.xml index 0e87f33c44..bd0a0489c0 100644 --- a/pom.xml +++ b/pom.xml @@ -57,20 +57,20 @@ - 6.11.0 + 6.11-SNAPSHOT 4.13.2 1.19.3 - 2.0.1 - 4.0.2 - 3.2.1 - 3.0.3 + 2.0-SNAPSHOT + 4.0-SNAPSHOT + 3.2-SNAPSHOT + 3.0-SNAPSHOT 2.9.4 2.1 1.11.4 20220608.1 2.15.2 - 0.2.3 - 3.0.0 + 0.2-SNAPSHOT + 3.0-SNAPSHOT 3.0.2 0.2.0 3.9 diff --git a/portal/frontend/package.json.template b/portal/frontend/package.json.template index a1ebe12beb..bdb49d9f78 100644 --- a/portal/frontend/package.json.template +++ b/portal/frontend/package.json.template @@ -40,7 +40,7 @@ "i18next": "23.8.1", "i18next-http-backend": "2.4.2", "framer-motion": "^12.18.1", - "ode-explorer": "2.4.3", + "ode-explorer": "%packageVersion%", "react": "^18.3.1", "react-dom": "^18.3.1", "react-error-boundary": "4.0.13", From e0304151df5316dc2769bf5d92c0e63b797a7620 Mon Sep 17 00:00:00 2001 From: jenkinsEdificePublic Date: Thu, 6 Nov 2025 13:17:24 +0100 Subject: [PATCH 02/31] chore: prepare next development iteration --- package.json | 6 +++--- pom.xml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 569d8174e0..fcfd7d1a2b 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "angular-sanitize": "1.8.3", "axios": "0.15.3", "core-js": "^2.4.1", - "entcore": "dev", + "entcore": "develop-pedago", "entcore-generic-icons": "https://github.com/edificeio/generic-icons.git", "entcore-toolkit": "^1.0.1", "humane-js": "^3.2.2", @@ -76,8 +76,8 @@ "karma-jasmine": "~1.1.0", "karma-jasmine-html-reporter": "^0.2.2", "merge2": "^1.0.3", - "ode-ngjs-front": "dev", - "ode-ts-client": "dev", + "ode-ngjs-front": "develop-pedago", + "ode-ts-client": "develop-pedago", "sass-loader": "^13.0.2", "source-map-loader": "^0.1.5", "ts-loader": "^3.1.1", diff --git a/pom.xml b/pom.xml index bd0a0489c0..0567b8b45d 100644 --- a/pom.xml +++ b/pom.xml @@ -61,9 +61,9 @@ 4.13.2 1.19.3 2.0-SNAPSHOT - 4.0-SNAPSHOT - 3.2-SNAPSHOT - 3.0-SNAPSHOT + 4.0-develop-pedago-SNAPSHOT + 3.2-develop-pedago-SNAPSHOT + 3.0-develop-pedago-SNAPSHOT 2.9.4 2.1 1.11.4 From 17eb23ed4804d2e535fbb4e2821a8d1a9acb8f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Vogel?= Date: Wed, 19 Mar 2025 15:48:32 +0100 Subject: [PATCH 03/31] feat(app-registry):INTEG-812 add controller for ptit observatoire widget refactor(app-registry): INTEG-812 refactor ptit observatoire controller to use teacher id lpo --- .../org/entcore/registry/AppRegistry.java | 8 +- .../PtitObservatoireController.java | 182 ++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 app-registry/src/main/java/org/entcore/registry/controllers/PtitObservatoireController.java diff --git a/app-registry/src/main/java/org/entcore/registry/AppRegistry.java b/app-registry/src/main/java/org/entcore/registry/AppRegistry.java index 20765c5fa5..0e15d5a46b 100644 --- a/app-registry/src/main/java/org/entcore/registry/AppRegistry.java +++ b/app-registry/src/main/java/org/entcore/registry/AppRegistry.java @@ -19,10 +19,10 @@ package org.entcore.registry; -import io.vertx.core.json.JsonObject; import io.vertx.core.Promise; import org.entcore.broker.api.utils.AddressParameter; import org.entcore.broker.api.utils.BrokerProxyUtils; +import io.vertx.core.json.JsonObject; import org.entcore.common.appregistry.AppRegistryEventsHandler; import org.entcore.common.http.BaseServer; import org.entcore.registry.controllers.*; @@ -53,6 +53,12 @@ public void start(final Promise startPromise) throws Exception { addController(new ScreenTimeController()); } + + JsonObject ptitObservatoireConf = config.getJsonObject("ptit-observatoire-widget-config"); + if (ptitObservatoireConf != null) { + addController(new PtitObservatoireController()); + } + setDefaultResourceFilter(new AppRegistryFilter()); new AppRegistryEventsHandler(vertx, new NopAppRegistryEventService()); vertx.eventBus().publish("app-registry.loaded", new JsonObject()); diff --git a/app-registry/src/main/java/org/entcore/registry/controllers/PtitObservatoireController.java b/app-registry/src/main/java/org/entcore/registry/controllers/PtitObservatoireController.java new file mode 100644 index 0000000000..6fc71e18c0 --- /dev/null +++ b/app-registry/src/main/java/org/entcore/registry/controllers/PtitObservatoireController.java @@ -0,0 +1,182 @@ +package org.entcore.registry.controllers; + +import fr.wseduc.rs.Get; +import fr.wseduc.rs.Post; +import fr.wseduc.security.ActionType; +import fr.wseduc.security.SecuredAction; +import fr.wseduc.webutils.http.BaseController; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.*; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import org.entcore.common.user.UserUtils; +import org.vertx.java.core.http.RouteMatcher; + +import java.util.Map; + +public class PtitObservatoireController extends BaseController { + private HttpClient httpClient; + private String ptitObservatoireUrl; + private String ptitObservatoireApiKey; + + @Override + public void init(Vertx vertx, JsonObject config, RouteMatcher rm, Map securedActions) { + super.init(vertx, config, rm, securedActions); + + this.httpClient = vertx.createHttpClient(new HttpClientOptions()); + JsonObject ptitObservatoireConfiguration = config.getJsonObject("ptit-observatoire-widget-config"); + this.ptitObservatoireUrl = ptitObservatoireConfiguration.getString("url", ""); + this.ptitObservatoireApiKey = ptitObservatoireConfiguration.getString("api-key", ""); + } + + // Get the list of students related to a teacher. + @Get("/ptitObservatoire/students") + @SecuredAction(value = "", type = ActionType.AUTHENTICATED) + public void getPtitObservatoireStudents(final HttpServerRequest request) { + getIdLpo(request) + .compose(idLpo -> { + String url = ptitObservatoireUrl + "/students?filter[teacher_id]=" + idLpo; + + RequestOptions options = new RequestOptions() + .setMethod(HttpMethod.GET) + .setAbsoluteURI(url) + .addHeader("X-API-KEY", ptitObservatoireApiKey) + .addHeader("Content-Type", "application/json"); + + return httpClient.request(options) + .compose(HttpClientRequest::send) + .compose(response -> { + Promise promise = Promise.promise(); + response.bodyHandler(body -> { + if (response.statusCode() == 200) { + JsonObject json = body.toJsonObject(); + JsonArray data = json.getJsonArray("data", new JsonArray()); + promise.complete(data); + } else { + promise.fail("Error from API: " + response.statusCode()); + } + }); + return promise.future(); + }); + }) + .onSuccess(students -> renderJson(request, students)) + .onFailure(err -> renderError(request, null, 500, err.getMessage())); + } + + // Get the list of categories related to a teacher. + @Get("/ptitObservatoire/observations/categories") + @SecuredAction(value = "", type = ActionType.AUTHENTICATED) + public void getPtitObservatoireObservationsCategories(HttpServerRequest request) { + getIdLpo(request) + .compose(idLpo -> { + String url = ptitObservatoireUrl + "/observations/categories?filter[teacher_id]=" + idLpo; + + RequestOptions options = new RequestOptions() + .setMethod(HttpMethod.GET) + .setAbsoluteURI(url) + .addHeader("X-API-KEY", ptitObservatoireApiKey) + .addHeader("Content-Type", "application/json"); + + return httpClient.request(options) + .compose(HttpClientRequest::send) + .compose(response -> { + Promise promise = Promise.promise(); + response.bodyHandler(body -> { + if (response.statusCode() == 200) { + JsonObject json = body.toJsonObject(); + JsonArray data = json.getJsonArray("data", new JsonArray()); + promise.complete(data); + } else { + promise.fail("Error from API: " + response.statusCode()); + } + }); + return promise.future(); + }); + }) + .onSuccess(categories -> renderJson(request, categories)) + .onFailure(err -> renderError(request, null, 500, err.getMessage())); + } + + // Create a new observation. + @Post("/ptitObservatoire/observations") + @SecuredAction(value = "", type = ActionType.AUTHENTICATED) + public void createPtitObsevatoireObservation(HttpServerRequest request) { + Future bodyFuture = request.body(); + Future idLpoFuture = getIdLpo(request); + + // Wait for both futures to complete + Future.all(bodyFuture, idLpoFuture) + .compose(cf -> { + Buffer body = cf.resultAt(0); + String idLpo = cf.resultAt(1); + + JsonObject json = body.toJsonObject(); + + // Modify the json object to include the teacher_id in the body + JsonObject attributes = json + .getJsonObject("data", new JsonObject()) + .getJsonObject("attributes", new JsonObject()); + attributes.put("teacher_id", idLpo); + + RequestOptions options = new RequestOptions() + .setMethod(HttpMethod.POST) + .setAbsoluteURI(ptitObservatoireUrl + "/observations") + .addHeader("X-API-KEY", ptitObservatoireApiKey) + .addHeader("Content-Type", "application/json"); + + return httpClient.request(options) + .compose(req -> req.send(json.encode())); + }) + .onSuccess(response -> { + response.bodyHandler(buffer -> { + JsonObject resJson = buffer.toJsonObject(); + if (response.statusCode() == 201) { + renderJson(request, resJson); + } else { + renderError(request, resJson); + } + }); + }) + .onFailure(err -> renderError(request, null, 500, err.getMessage())); + } + + // Get the ID of a teacher in the PtitObservatoire API. + private Future getIdLpo(HttpServerRequest request) { + return UserUtils.getAuthenticatedUserInfos(eb, request) + .compose(userInfos -> { + String userId = userInfos.getUserId(); + + String url = ptitObservatoireUrl + "/users?filter[external_id]=" + userId; + + RequestOptions options = new RequestOptions() + .setMethod(HttpMethod.GET) + .setAbsoluteURI(url) + .addHeader("X-API-KEY", ptitObservatoireApiKey) + .addHeader("Content-Type", "application/json"); + + return httpClient.request(options) + .compose(HttpClientRequest::send) + .compose(response -> { + Promise promise = Promise.promise(); + response.bodyHandler(body -> { + if (response.statusCode() != 200) { + promise.fail("API error: " + response.statusCode()); + return; + } + + JsonArray data = body.toJsonObject().getJsonArray("data"); + if (data == null || data.isEmpty()) { + promise.fail("User not found"); + } else { + String idLpo = data.getJsonObject(0).getString("id"); + promise.complete(idLpo); + } + }); + return promise.future(); + }); + }); + } +} From 8604ce2d8d5bfff3d767a9f36b43de6273bc7af3 Mon Sep 17 00:00:00 2001 From: moustaphahennawi <56674781+moustaphahennawi@users.noreply.github.com> Date: Wed, 28 May 2025 18:44:12 +0200 Subject: [PATCH 04/31] chore(timeline): INTEG-422 add api for send notification from external services --- .../controllers/TimelineController.java | 45 +++++++++++++++++++ .../notify/external_notification.html | 8 ++++ .../notify/external_notification.json | 4 ++ 3 files changed, 57 insertions(+) create mode 100644 timeline/src/main/resources/view-src/notify/external_notification.html create mode 100644 timeline/src/main/resources/view-src/notify/external_notification.json diff --git a/timeline/src/main/java/org/entcore/timeline/controllers/TimelineController.java b/timeline/src/main/java/org/entcore/timeline/controllers/TimelineController.java index 9d16e95daa..517b93a028 100644 --- a/timeline/src/main/java/org/entcore/timeline/controllers/TimelineController.java +++ b/timeline/src/main/java/org/entcore/timeline/controllers/TimelineController.java @@ -1008,4 +1008,49 @@ public void setEventsI18n(LocalMap eventsI18n) { public void setLazyEventsI18n(HashMap lazyEventsI18n) { this.lazyEventsI18n = lazyEventsI18n; } + + + @Post("/send/notification") + @SecuredAction(value= "", type = ActionType.RESOURCE) + @ResourceFilter(AdminFilter.class) + public void externalNotifications(final HttpServerRequest request) { + RequestUtils.bodyToJson(request, + new Handler() { + @Override + public void handle(JsonObject json) { + final JsonArray recipientsId = json.getJsonArray("recipients", new JsonArray()); + + if (recipientsId == null || recipientsId.isEmpty()) { + badRequest(request, "Invalid sender or recipients"); + return; + } + + final String subject = json.getString("subject", ""); + final String body = json.getString("body", ""); + + final boolean pushMobile = json.getBoolean("pushMobile", false); + + final String mobileTitle = json.getString("mobileTitle", ""); + final String mobileBody = json.getString("mobileBody", ""); + + + JsonObject params = new JsonObject() + .put("subject", subject) + .put("body", body); + + if(pushMobile && !mobileTitle.isEmpty()) { + JsonObject pushNotif = new JsonObject() + .put("title", mobileTitle) + .put("body", mobileBody); + + params.put("pushNotif", pushNotif); + } + + timelineHelper.notifyTimeline(request, "timeline.external_notification", null, recipientsId.getList(), + System.currentTimeMillis() + "external_notification", params); + + ok(request); + } + }); + } } diff --git a/timeline/src/main/resources/view-src/notify/external_notification.html b/timeline/src/main/resources/view-src/notify/external_notification.html new file mode 100644 index 0000000000..3f1aa20eee --- /dev/null +++ b/timeline/src/main/resources/view-src/notify/external_notification.html @@ -0,0 +1,8 @@ + + {{#subjet}} +

{{subjet}}

+ {{/subjet}} + {{#body}} + {{body}} + {{/body}} +
\ No newline at end of file diff --git a/timeline/src/main/resources/view-src/notify/external_notification.json b/timeline/src/main/resources/view-src/notify/external_notification.json new file mode 100644 index 0000000000..8fd7aa8173 --- /dev/null +++ b/timeline/src/main/resources/view-src/notify/external_notification.json @@ -0,0 +1,4 @@ +{ + "default-frequency": "IMMEDIATE", + "push-notif" : true +} \ No newline at end of file From 89d8464a3e2693092b935e54deb0dfedc0c96375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Vogel?= Date: Mon, 26 May 2025 10:30:50 +0200 Subject: [PATCH 05/31] feat(workspace): INTEG-896, port nextcloud integration directly into workspace - Port and adapt existing nextcloud features - Add copy and move features - Add isolated trashbin for nextcloud --- package.json | 2 + .../controllers/WorkspaceController.java | 1 + workspace/src/main/resources/i18n/fr.json | 574 ++++++++++-------- .../resources/public/template/copy/index.html | 2 +- .../nextcloud/content/views/icons.html | 39 ++ .../nextcloud/content/views/list.html | 84 +++ .../content/workspace-nextcloud-content.html | 91 +++ .../workspace-nextcloud-upload-file.html | 55 ++ .../nextcloud/folder/empty-trash.html | 14 + .../nextcloud/folder/folder-creation.html | 15 + .../folder/workspace-nextcloud-folder.html | 59 ++ .../nextcloud/import/nextcloud-import.html | 64 ++ .../share/share-documents-options.html | 53 ++ .../nextcloud/toolbar/share/share.html | 16 + .../workspace-nextcloud-toolbar-share.html | 6 + .../workspace-nextcloud-toolbar-copy.html | 3 + .../workspace-nextcloud-toolbar-delete.html | 37 ++ ...orkspace-nextcloud-toolbar-properties.html | 35 ++ .../workspace-nextcloud-toolbar-trash.html | 37 ++ .../toolbar/workspace-nextcloud-toolbar.html | 131 ++++ workspace/src/main/resources/public/ts/app.ts | 110 ++-- .../main/resources/public/ts/controller.ts | 284 ++++++--- .../public/ts/delegates/actions/copy.ts | 2 - .../public/ts/delegates/actions/trash.ts | 2 +- .../public/ts/directives/folderPicker2.ts | 298 +++++++++ .../public/ts/directives/folderTree2.ts | 200 ++++++ .../content/contentViewer.component.ts | 502 +++++++++++++++ .../content/fileUpload.component.ts | 140 +++++ .../components/content/iconView.component.ts | 11 + .../components/content/listView.component.ts | 66 ++ .../components/content/toolbar.component.ts | 552 +++++++++++++++++ .../content/toolbarShare.components.ts | 121 ++++ .../components/folder/emptyTrash.component.ts | 46 ++ .../folder/folderManager.component.ts | 64 ++ .../nextcloud/constants/dateFormats.ts | 7 + .../nextcloud/constants/rootPaths.ts | 4 + .../nextcloud/enums/documentRole.enum.ts | 11 + .../nextcloud/enums/documentsType.enum.ts | 4 + .../nextcloud/enums/viewMode.enum.ts | 4 + .../models/nextcloudDraggable.model.ts | 7 + .../nextcloud/models/nextcloudFolder.model.ts | 173 ++++++ .../nextcloud/models/nextcloudUser.model.ts | 54 ++ .../nextcloud/nextcloudFolder.directive.ts | 565 +++++++++++++++++ .../services/nextcloud.preferences.ts | 55 ++ .../nextcloud/services/nextcloud.service.ts | 307 ++++++++++ .../services/nextcloudEvent.service.ts | 56 ++ .../services/nextcloudUser.service.ts | 28 + .../directives/nextcloud/utils/date.utils.ts | 12 + .../utils/nextcloudDocuments.utils.ts | 40 ++ .../nextcloud/utils/safeApply.utils.ts | 6 + .../nextcloud/utils/workspaceEntcore.utils.ts | 114 ++++ .../main/resources/view-src/workspace.html | 21 +- 52 files changed, 4776 insertions(+), 408 deletions(-) create mode 100644 workspace/src/main/resources/public/template/nextcloud/content/views/icons.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/content/views/list.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-content.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-upload-file.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/folder/empty-trash.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/folder/folder-creation.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/folder/workspace-nextcloud-folder.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/import/nextcloud-import.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/toolbar/share/share-documents-options.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/toolbar/share/share.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/toolbar/share/workspace-nextcloud-toolbar-share.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-copy.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-delete.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-properties.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-trash.html create mode 100644 workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar.html create mode 100644 workspace/src/main/resources/public/ts/directives/folderPicker2.ts create mode 100644 workspace/src/main/resources/public/ts/directives/folderTree2.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/components/content/contentViewer.component.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/components/content/fileUpload.component.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/components/content/iconView.component.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/components/content/listView.component.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbar.component.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbarShare.components.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/emptyTrash.component.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/folderManager.component.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/constants/dateFormats.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/constants/rootPaths.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentRole.enum.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentsType.enum.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/enums/viewMode.enum.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudDraggable.model.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudFolder.model.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudUser.model.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/nextcloudFolder.directive.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.preferences.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.service.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudEvent.service.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudUser.service.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/utils/date.utils.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/utils/nextcloudDocuments.utils.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/utils/safeApply.utils.ts create mode 100644 workspace/src/main/resources/public/ts/directives/nextcloud/utils/workspaceEntcore.utils.ts diff --git a/package.json b/package.json index fcfd7d1a2b..db7b236af9 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@types/core-js": "0.9.36", "@types/jasmine": "~2.5.53", "@types/jasminewd2": "~2.0.2", + "@types/jquery": "^3.2.1", "@types/jquery.noty": "2.4.0", "@types/node": "~6.0.60", "angular-router-loader": "0.5.0", @@ -78,6 +79,7 @@ "merge2": "^1.0.3", "ode-ngjs-front": "develop-pedago", "ode-ts-client": "develop-pedago", + "rxjs": "5.4.2", "sass-loader": "^13.0.2", "source-map-loader": "^0.1.5", "ts-loader": "^3.1.1", diff --git a/workspace/src/main/java/org/entcore/workspace/controllers/WorkspaceController.java b/workspace/src/main/java/org/entcore/workspace/controllers/WorkspaceController.java index 0dfb07f729..2076d38871 100644 --- a/workspace/src/main/java/org/entcore/workspace/controllers/WorkspaceController.java +++ b/workspace/src/main/java/org/entcore/workspace/controllers/WorkspaceController.java @@ -1781,6 +1781,7 @@ public void view(final HttpServerRequest request) { context.put("enableScratch", config.getBoolean("enable-scratch", false)); context.put("enableGeogebra", config.getBoolean("enable-geogebra", false)); context.put("enableNextcloud", config.getBoolean("enable-nextcloud", false)); + context.put("useNextcloudSniplet", config.getBoolean("use-nextcloud-sniplet", true)); context.put("lazyMode", config.getJsonObject("publicConf", new JsonObject()).getBoolean("lazy-mode", false)); context.put("cacheDocTTl", config.getJsonObject("publicConf", new JsonObject()).getInteger("ttl-documents", -1)); context.put("cacheFolderTtl", config.getJsonObject("publicConf", new JsonObject()).getInteger("ttl-folders", -1)); diff --git a/workspace/src/main/resources/i18n/fr.json b/workspace/src/main/resources/i18n/fr.json index 64ecaf3771..5f1adf0ff5 100644 --- a/workspace/src/main/resources/i18n/fr.json +++ b/workspace/src/main/resources/i18n/fr.json @@ -1,268 +1,308 @@ { - "appDocuments": "Documents ajoutés dans les applis", - "bit": "Bit(s)", - "blocked.extension": "Erreur l'extension du fichier est interdite.", - "blur": "Flouter", - "byte": "Octet(s)", - "cancel": "Annuler", - "choose": "Parcourir", - "comments": "[[document.comments.length]] [[folder.comments.length]] commentaire(s)", - "confirm": "Confirmer", - "confirm.remove": "Attention, si vous supprimez une image ou du son, il ne sera plus visible dans les applications qui l'utilisent (blog, actualités...). Êtes-vous sûr de vouloir supprimer le document ?", - "crop": "Rogner", - "document.error.missing.fileid": "Le fichier recherché n'existe pas", - "document.error.preview.kind.unknown": "Prévisualisation impossible. Le type du document n'a pas été reconnu.", - "document.preview.save.failed": "Une erreur est survenue lors de la génération de la prévisualisation.", - "documents": "Documents personnels", - "dropzone.overlay.action": "J'ai compris", - "dropzone.overlay.label": "Déposez les fichiers", - "dropzone.overlay.solution": "Veuillez importer un ou plusieurs fichiers", - "dropzone.overlay.warning": "Il est impossible d'importer un dossier", - "e400": "L'application a rencontré une erreur. Si ce problème se reproduit, veuillez vous adresser au support.", - "e401": "Vous n'avez pas les droits d'accès pour ce contenu", - "e403": "Le serveur a refusé votre requête", - "e404": "Adresse introuvable", - "e408": "Temps d'attente dépassé, veuillez réessayer", - "e500": "L'application a rencontré une erreur. Si ce problème se reproduit, veuillez vous adresser au support.", - "e504": "La demande a dépassé le délai imparti.", - "externalDocs": "Mes ressources interactives", - "file.too.large": "Espace insuffisant pour stocker le fichier", - "file.too.large.limit": "Fichier(s) trop volumineux. Taille maximum autorisée : ", - "file.too.large.max": "Espace insuffisant pour stocker le(s) fichier(s). Espace restant : ", - "files.header": "Charger plusieurs fichiers", - "files.select": "Sélectionner les fichiers", - "files.select.please": "Veuillez sélectionner un document", - "files.too.large": "Espace insuffisant pour stocker les fichiers.", - "files.warning.identical": "Importer un exemplaire supplémentaire du(des) même(s) fichier(s) ?", - "folder.new": "Créer un dossier", - "folder.new.shared": "Créer un dossier partagé", - "folder.new.shared.title": "Créer un dossier partagé", - "folder.new.title": "Créer un dossier", - "folder.zip.upload.notfound": "Le téléchargement du ZIP a échoué. Veuillez réessayer.", - "folder.zip.upload.partial": "Certains élements du fichiers ZIP n'ont pas pu être importé", - "folderpicker.folder.edit": "Créer un nouveau dossier", - "from": "Auteur", - "gb": "Go", - "gigabyte": "Giga-octet(s)", - "group.AdminLocal": "Administrateurs locaux de {0}.", - "group.Direction": "Directeurs de {0}.", - "group.Discipline": "Enseignants de discipline {0}.", - "group.Func": "Personnels de fonction {0}.", - "group.Guest": "Invités du groupe {0}.", - "group.HeadTeacher": "Professeurs principaux de {0}.", - "group.Personnel": "Personnels du groupe {0}.", - "group.Relative": "Parents du groupe {0}.", - "group.Student": "Élèves du groupe {0}.", - "group.Teacher": "Enseignants du groupe {0}.", - "group.class.Guest": "Invités de la classe {0}.", - "group.class.Personnel": "Personnels de la classe {0}.", - "group.class.Relative": "Parents de la classe {0}.", - "group.class.Student": "Élèves de la classe {0}.", - "group.class.Teacher": "Enseignants de la classe {0}.", - "group.contrib": "Contributeurs de la communauté {0}.", - "group.manager": "Gestionnaires de la communauté {0}.", - "group.read": "Lecteurs de la communauté {0}.", - "group.school.Guest": "Invités de l'{0}.", - "group.school.Personnel": "Personnels de l'{0}.", - "group.school.Relative": "Parents de l'{0}.", - "group.school.Student": "Élèves de l'{0}.", - "group.school.Teacher": "Enseignants de l'{0}.", - "import.file": "Importer", - "kilobyte": "Kilo-octet(s)", - "loading": "Chargement", - "max.file.size": "52428800", - "mb": "Mo", - "megabyte": "Méga-octet(s)", - "modified": "Dernière modification", - "move": "Déplacer", - "name": "Titre", - "nofile": "Aucun fichier", - "owner": "Propriétaire", - "quota.title": "Espace utilisé", - "rack": "Mon casier", - "rack.sent.message": "Votre document a bien été envoyé", - "resize": "Redimensionner", - "root": "Racine", - "rotate": "Pivoter", - "seemore": "Voir la suite", - "send": "Envoyer", - "sent": "Date d'envoi", - "share.groups": "Groupes", - "share.me": "Moi", - "share.more": "Voir la suite", - "share.search.placeholder": "Ex : Sabine, Dupont, Enseignants de l'école...", - "share.search.title": "Rechercher des utilisateurs et des groupes", - "share.title": "Partager avec...", - "share.users": "Utilisateurs", - "shared": "Documents partagés", - "shared_tree": "Documents partagés", - "size": "Poids", - "terabyte": "Téra-octet(s)", - "terminate": "Terminer", - "to": "Destinataire", - "trash": "Corbeille", - "tuto.action.open": "Double-cliquez", - "tuto.action.select": "Cliquez", - "tuto.text.open": "pour ouvrir un dossier ou un fichier.", - "tuto.text.select": "pour sélectionner et accéder au menu d'options,", - "tuto.title": "Naviguez au sein de vos documents en vue vignettes :", - "upload": "Envoyer", - "user": "Utilisateur", - "workspace.add.document": "Importer", - "workspace.add.version": "Nouvelle version", - "workspace.comment": "Commenter", - "workspace.comments": "Commentaires", - "workspace.continue": "Continuer", - "workspace.contrib": "Contribuer", - "workspace.contrib.cant": "Vous n'avez pas les droits de contributions dans ce dossier", - "workspace.copied.toowner": "Document(s) copié(s) dans vos documents personnels", - "workspace.copy": "Copier", - "workspace.copy.title": "Copier des fichiers et/ou dossiers", - "workspace.copy.window.action": "Copier", - "workspace.copy.window.finished": "Copie effectuée avec succès!", - "workspace.copy.window.info": "Rechercher et/ou sélectionner le dossier où vous voulez copier", - "workspace.copy.window.processing": "Copie en cours...", - "workspace.copy.window.title": "Copier de(s) fichier(s) et/ou dossier(s)", - "workspace.create.cancel.content": "Si vous ne partagez pas ce dossier, vous ne pouvez pas le laisser à la racine des documents partagés. Voulez-vous le créer dans vos documents personnels ?", - "workspace.create.cancel.delete": "Supprimer le dossier", - "workspace.create.cancel.own": "Créer dans mes documents personnels", - "workspace.create.cancel.title": "Attention!", - "workspace.create.share.warning": "Attention ! Vous devez obligatoirement partager le dossier créé, sinon", - "workspace.create.share.warning2": " créez le dans votre espace personnel", - "workspace.create.workspace": "Importer un document", - "workspace.delete": "Supprimer", - "workspace.delete.confirm.action": "Confirmer la suppression", - "workspace.delete.confirm.content": "En supprimant ces fichiers ou dossiers vous les supprimerez également pour les utilisateurs qui voient ces fichiers ou dossiers ou qui en sont propriétaires.", - "workspace.delete.confirm.title": "Attention!", - "workspace.delete.empty.trash": "Voulez-vous vraiment vider la corbeille ?", - "workspace.delete.version": "Supprimer la version", - "workspace.deleted.message": "L'élément a été supprimé définitivement.", - "workspace.document.comment": "Commenter", - "workspace.document.comment.hide": "Masquer les commentaires", - "workspace.document.comment.show": "Voir les commentaires", - "workspace.document.delete": "Supprimer un document", - "workspace.document.file": "Fichier", - "workspace.document.get": "Lire un document", - "workspace.document.name": "Nom du document", - "workspace.documents.warning.move": "Le déplacement d'un fichier supprimera tous les partages attribués au fichier et lui réattribuera les droits du dossier de destination.", - "workspace.download": "Télécharger", - "workspace.element.uri.notfound": "Les fichiers ou dossiers ont été déplacés dans un dossier auquel vous n'avez pas accès, vous pouvez les retrouver en effectuant une recherche dans tout l'espace documentaire", - "workspace.empty.trash": "Vider la corbeille", - "workspace.empty.trash.confirm": "La corbeille a été vidée.", - "workspace.external.open": "Éditer", - "workspace.files.warning.content": "

Le partage d'un fichier entraîne son déplacement dans l'espace Documents partagés. Pour garder le fichier dans vos documents personnels, faites une copie avant de le partager.

", - "workspace.files.warning.title": "Partage d'un fichier", - "workspace.folder.already.exists": "Le dossier existe déjà.", - "workspace.folder.create": "Créer", - "workspace.folder.invalid.name": "Le nom d'un dossier ne doit pas contenir le caractère _", - "workspace.folder.name": "Nom du dossier", - "workspace.folder.not.found": "Dossier introuvable", - "workspace.folders.warning.content": "

Le partage d'un dossier entraîne son déplacement dans l'espace Documents partagés. Pour garder le dossier dans vos documents personnels, faites une copie avant de le partager.

Attention, le partage d'un dossier entraîne la modification des partages de tous les fichiers et sous dossiers contenus dans celui-ci.

", - "workspace.folders.warning.folder.copy": "

Lors de la copie d'un dossier, votre copie (et son arborescence) héritera des droits du dossier dans lequel vous le copierez.

", - "workspace.folders.warning.title": "Partage d'un dossier", - "workspace.folders.warning.title.toshare": "Déplacement avec partage", - "workspace.forbidden.move.folder.in.itself": "Interdit de déplacer un dossier dans lui-même.", - "workspace.header": "Espace documentaire", - "workspace.headers": "Espace documentaire", - "workspace.headers.short": "Documents", - "workspace.help.1": "Le droit de contribution dans un dossier permet désormais aux personnes autorisées d'ajouter des documents dans vos dossiers partagés.", - "workspace.help.2": "Cette possibilité implique que tous les dossiers partagés soient visibles par tous au même endroit. Vos fichiers partagés sont désormais tous rangés dans cet emplacement. Si vous partagez un dossier ou un fichier depuis votre espace personnel, ils se retrouveront automatiquement ici.", - "workspace.help.title": "Du nouveau dans l'espace documentaire!", - "workspace.import.action": "J'ai compris", - "workspace.import.cancel.content": "Si vous ne partagez pas ces fichiers, vous ne pouvez pas les laisser à la racine des documents partagés. Voulez-vous les importer dans vos documents personnels ?", - "workspace.import.cancel.delete": "Annuler l'import", - "workspace.import.cancel.own": "Importer dans mes documents personnels", - "workspace.import.share.warning": "Attention ! Vous devez obligatoirement partager vos fichiers importés, sinon", - "workspace.import.share.warning2": "importez les dans votre espace personnel.", - "workspace.import.shared.title": "Partager vos fichiers importés", - "workspace.import.solution": "Veuillez importer un ou plusieurs fichiers", - "workspace.import.warning": "Il est impossible d'importer un dossier", - "workspace.import.zip.cancel": "Annuler", - "workspace.import.zip.content": "Vous tentez de déposer une archive au format zip.
Souhaitez vous qu'elle soit décompressée avant d'être importée ?
Si votre archive comporte une arborescence de dossiers, cette arborescence sera conservée.", - "workspace.import.zip.error.encoding": "Votre archive possède des fichiers ou dossiers dont le nom contient des caractères accentués ou spéciaux incompatibles avec l'import", - "workspace.import.zip.invalid": "Fichier invalide", - "workspace.import.zip.title": "Importer des fichiers", - "workspace.import.zip.uncompress": "Importer et décompresser", - "workspace.import.zip.upload": "Importer sans décompresser", - "workspace.invalid.folder.name": "Nom de dossier invalide", - "workspace.invalid.user": "Utilisateur invalide", - "workspace.invalidfile.placeholder": "Fichier invalide", - "workspace.leave.comment": "Laisser un commentaire", - "workspace.loading": "Chargement en cours", - "workspace.manager": "Gérer", - "workspace.move": "Déplacer", - "workspace.move.path": "Destination", - "workspace.move.racktodocs": "Copier dans mes documents personnels", - "workspace.move.title": "Déplacement des fichiers et/ou dossiers", - "workspace.move.trash": "Supprimer", - "workspace.move.warnings.title": "Attention!", - "workspace.move.warnings.title.toshare": "Déplacement avec partage", - "workspace.move.window.action": "Déplacer", - "workspace.move.window.copyinsteadofmove": "Vous pouvez créer une copie des fichiers ou dossiers au lieu de les déplacer", - "workspace.move.window.docopy": "Effectuer une copie", - "workspace.move.window.finished": "Déplacement effectué avec succès!", - "workspace.move.window.fromshare.notmine": "En déplaçant ces fichiers/dossiers dans votre espace personnel, Vous en deviendrez gestionnaire et serez le seul à pouvoir encore les consulter. Les utilisateurs qui les voient actuellement n'y auront plus accès.
Vous pouvez créer une copie de ces fichiers/dossiers au lieu de les déplacer.", - "workspace.move.window.fromtoshare": "Vous allez déplacer des fichiers et/ou dossiers dans un autre espace partagé. Certains utilisateurs pourraient ne plus y voir accès.", - "workspace.move.window.info": "Rechercher et/ou sélectionner le dossier où vous voulez déplacer", - "workspace.move.window.processing": "Déplacement en cours...", - "workspace.move.window.title": "Déplacer de(s) fichier(s) et/ou dossier(s)", - "workspace.move.window.toown": "Vous aller déplacer vos fichiers et dossiers dans vos documents personnels et les retirer des partages avec les autres utilisateurs.", - "workspace.move.window.toshare": "Vous allez déplacer vos fichiers et dossiers dans un espace partagé, ils ne seront plus visibles dans leur emplacement d'origine.
Vous pouvez créer une copie des fichiers et dossiers au lieu de les déplacer.", - "workspace.name": "Titre du dossier", - "workspace.notify.forbidden": "Vous n'avez pas les droits sur certains fichiers ou dossiers", - "workspace.open": "Ouvrir", - "workspace.open.location": "Voir l'emplacement du fichier", - "workspace.preview.loading": "Prévisualisation en cours de chargement", - "workspace.properties": "Propriétés", - "workspace.properties.managers": "Liste des gestionnaires", - "workspace.properties.parent": "Dossier parent", - "workspace.rack.file": "Fichier", - "workspace.rack.name": "Nom du document", - "workspace.rack.to": "Destinataire", - "workspace.read": "Consulter", - "workspace.removed.message": "L'élément a été déplacé dans la corbeille.", - "workspace.rename": "Renommer", - "workspace.restored.message": "La restauration est terminée", - "workspace.return": "Retour", - "workspace.search.description.one": "Un commentaire contenant, '{0}', répond à vos critères de recherche.", - "workspace.search.description.several": "{0} commentaires répondent à vos critères de recherche, dont le plus récent contient : '{1}'.", - "workspace.search.inprogress": "Recherche en cours...", - "workspace.search.inworkspace": "Rechercher dans tout l'espace documentaire", - "workspace.search.placeholder": "Renseignez votre recherche", - "workspace.search.results.infolder": "Résultats de la recherche dans", - "workspace.search.results.inworkspace": "Résultats de la recherche dans tout l'espace documentaire", - "workspace.select.all": "Tous", - "workspace.send.rack": "Déposer dans un casier", - "workspace.share": "Partager", - "workspace.share.document": "Partager des documents", - "workspace.share.removeall": "L'élément a été déplacé dans le dossier personnel du propriétaire.", - "workspace.shared.document": "a partagé avec vous le document", - "workspace.shared.folder": "a partagé avec vous le dossier", - "workspace.shared.message": "L'attribution des droits a bien été prise en compte", - "workspace.show.versions": "Voir les versions archivées", - "workspace.sniplet.carousel.add": "Ajouter une image", - "workspace.sniplet.carousel.buttons.shape": "Forme des boutons", - "workspace.sniplet.carousel.buttons.shape.sphere": "Ronds", - "workspace.sniplet.carousel.buttons.shape.square": "Carrés", - "workspace.sniplet.carousel.buttons.shape.triangle": "Triangles", - "workspace.sniplet.carousel.description": "Affiche des images dans un slider", - "workspace.sniplet.carousel.fade": "Fondu", - "workspace.sniplet.carousel.threeimages": "Trois images", - "workspace.sniplet.carousel.title": "Carrousel", - "workspace.sniplet.carousel.type": "Type de carrousel", - "workspace.sniplet.carousel.withpreview": "Avec aperçu", - "workspace.sniplet.documents.add": "Ajouter un fichier", - "workspace.sniplet.documents.confirm.remove": "Voulez-vous vraiment supprimer ces documents ? ", - "workspace.sniplet.documents.description": "Il vous permet d'ajouter des documents que vos visiteurs pourront télécharger", - "workspace.sniplet.documents.title": "Documents", - "workspace.title": "Espace documentaire", - "workspace.trash.error": "Erreur lors de la mise à la corbeille", - "workspace.trash.restore": "Restaurer", - "workspace.unselect.all": "Aucun", - "workspace.upload.forbidden": "Vous n'avez pas les droits sur ce dossier.", - "workspace.valid": "Appliquer", - "workspace.versions.list": "Versions", - "workspace.warning.cancel": "Annuler", - "workspace.warning.copy": "Copier et partager", - "workspace.warning.notcopy": "Partager sans copier" -} \ No newline at end of file + "externalDocs": "Mes ressources interactives", + "appDocuments": "Documents ajoutés dans les applis", + "bit": "Bit(s)", + "byte": "Octet(s)", + "cancel": "Annuler", + "choose": "Parcourir", + "comments": "[[document.comments.length]] [[folder.comments.length]] commentaire(s)", + "confirm": "Confirmer", + "confirm.remove": "Attention, si vous supprimez une image ou du son, il ne sera plus visible dans les applications qui l'utilisent (blog, actualités...). Êtes-vous sûr de vouloir supprimer le document ?", + "documents": "Documents personnels", + "e400": "L'application a rencontré une erreur. Si ce problème se reproduit, veuillez vous adresser au support.", + "e401": "Vous n'avez pas les droits d'accès pour ce contenu", + "e403": "Le serveur a refusé votre requête", + "e404": "Adresse introuvable", + "e408": "Temps d'attente dépassé, veuillez réessayer", + "e500": "L'application a rencontré une erreur. Si ce problème se reproduit, veuillez vous adresser au support.", + "e504": "La demande a dépassé le délai imparti.", + "file.too.large": "Espace insuffisant pour stocker le fichier", + "file.too.large.limit": "Fichier(s) trop volumineux. Taille maximum autorisée : ", + "file.too.large.max": "Espace insuffisant pour stocker le(s) fichier(s). Espace restant : ", + "max.file.size": "52428800", + "files.header": "Charger plusieurs fichiers", + "files.select": "Sélectionner les fichiers", + "files.select.please": "Veuillez sélectionner un document", + "files.too.large": "Espace insuffisant pour stocker les fichiers.", + "files.warning.identical": "Importer un exemplaire supplémentaire du(des) même(s) fichier(s) ?", + "folder.new": "Créer un dossier", + "folder.new.title": "Créer un dossier", + "folder.new.shared": "Créer un dossier partagé", + "folder.new.shared.title": "Créer un dossier partagé", + "from": "Auteur", + "gb": "Go", + "rotate": "Pivoter", + "blur": "Flouter", + "resize": "Redimensionner", + "crop": "Rogner", + "gigabyte": "Giga-octet(s)", + "group.HeadTeacher": "Professeurs principaux de {0}.", + "group.Direction": "Directeurs de {0}.", + "group.Func": "Personnels de fonction {0}.", + "group.Discipline": "Enseignants de discipline {0}.", + "group.AdminLocal": "Administrateurs locaux de {0}.", + "group.Personnel": "Personnels du groupe {0}.", + "group.Relative": "Parents du groupe {0}.", + "group.Student": "Élèves du groupe {0}.", + "group.Teacher": "Enseignants du groupe {0}.", + "group.Guest": "Invités du groupe {0}.", + "group.class.Personnel": "Personnels de la classe {0}.", + "group.class.Relative": "Parents de la classe {0}.", + "group.class.Student": "Élèves de la classe {0}.", + "group.class.Teacher": "Enseignants de la classe {0}.", + "group.class.Guest": "Invités de la classe {0}.", + "group.contrib": "Contributeurs de la communauté {0}.", + "group.manager": "Gestionnaires de la communauté {0}.", + "group.read": "Lecteurs de la communauté {0}.", + "group.school.Personnel": "Personnels de l'{0}.", + "group.school.Relative": "Parents de l'{0}.", + "group.school.Student": "Élèves de l'{0}.", + "group.school.Teacher": "Enseignants de l'{0}.", + "group.school.Guest": "Invités de l'{0}.", + "import.file": "Importer", + "kilobyte": "Kilo-octet(s)", + "loading": "Chargement", + "mb": "Mo", + "megabyte": "Méga-octet(s)", + "modified": "Dernière modification", + "name": "Titre", + "nofile": "Aucun fichier", + "owner": "Propriétaire", + "quota.title": "Espace utilisé", + "rack": "Mon casier", + "rack.sent.message": "Votre document a bien été envoyé", + "root": "Racine", + "seemore": "Voir la suite", + "send": "Envoyer", + "sent": "Date d'envoi", + "share.groups": "Groupes", + "share.me": "Moi", + "share.more": "Voir la suite", + "share.search.placeholder": "Ex : Sabine, Dupont, Enseignants de l'école...", + "share.search.title": "Rechercher des utilisateurs et des groupes", + "share.title": "Partager avec...", + "share.users": "Utilisateurs", + "shared": "Documents partagés", + "shared_tree": "Documents partagés", + "size": "Poids", + "terabyte": "Téra-octet(s)", + "terminate": "Terminer", + "to": "Destinataire", + "trash": "Corbeille", + "upload": "Envoyer", + "user": "Utilisateur", + "workspace.add.document": "Importer", + "workspace.add.version": "Nouvelle version", + "workspace.comment": "Commenter", + "workspace.comments": "Commentaires", + "workspace.continue": "Continuer", + "workspace.contrib": "Contribuer", + "workspace.copy": "Copier", + "workspace.delete": "Supprimer", + "workspace.delete.version": "Supprimer la version", + "workspace.document.comment": "Commenter", + "workspace.document.comment.hide": "Masquer les commentaires", + "workspace.document.comment.show": "Voir les commentaires", + "workspace.document.delete": "Supprimer un document", + "workspace.document.file": "Fichier", + "workspace.document.get": "Lire un document", + "workspace.document.name": "Nom du document", + "workspace.folder.name": "Nom du dossier", + "workspace.documents.warning.move": "Le déplacement d'un fichier supprimera tous les partages attribués au fichier et lui réattribuera les droits du dossier de destination.", + "workspace.empty.trash": "Vider la corbeille", + "workspace.folder.already.exists": "Le dossier existe déjà.", + "workspace.folder.create": "Créer", + "workspace.folder.invalid.name": "Le nom d'un dossier ne doit pas contenir le caractère _", + "workspace.folder.not.found": "Dossier introuvable", + "workspace.warning.copy": "Copier et partager", + "workspace.warning.cancel": "Annuler", + "workspace.warning.notcopy": "Partager sans copier", + "workspace.folders.warning.content": "

Le partage d'un dossier entraîne son déplacement dans l'espace Documents partagés. Pour garder le dossier dans vos documents personnels, faites une copie avant de le partager.

Attention, le partage d'un dossier entraîne la modification des partages de tous les fichiers et sous dossiers contenus dans celui-ci.

", + "workspace.files.warning.content": "

Le partage d'un fichier entraîne son déplacement dans l'espace Documents partagés. Pour garder le fichier dans vos documents personnels, faites une copie avant de le partager.

", + "workspace.folders.warning.folder.copy": "

Lors de la copie d'un dossier, votre copie (et son arborescence) héritera des droits du dossier dans lequel vous le copierez.

", + "workspace.move.warnings.title": "Attention!", + "workspace.move.warnings.title.toshare": "Déplacement avec partage", + "workspace.folders.warning.title": "Partage d'un dossier", + "workspace.files.warning.title": "Partage d'un fichier", + "workspace.folders.warning.title.toshare": "Déplacement avec partage", + "workspace.forbidden.move.folder.in.itself": "Interdit de déplacer un dossier dans lui-même.", + "workspace.header": "Espace documentaire", + "workspace.headers": "Espace documentaire", + "workspace.headers.short":"Documents", + "workspace.invalid.folder.name": "Nom de dossier invalide", + "workspace.invalid.user": "Utilisateur invalide", + "workspace.leave.comment": "Laisser un commentaire", + "workspace.manager": "Gérer", + "workspace.move": "Déplacer", + "workspace.move.path": "Destination", + "workspace.move.racktodocs": "Copier dans mes documents personnels", + "workspace.move.trash": "Supprimer", + "workspace.name": "Titre du dossier", + "workspace.rack.file": "Fichier", + "workspace.rack.name": "Nom du document", + "workspace.rack.to": "Destinataire", + "workspace.read": "Consulter", + "workspace.removed.message": "L'élément a été déplacé dans la corbeille.", + "workspace.deleted.message": "L'élément a été supprimé définitivement.", + "workspace.empty.trash.confirm": "La corbeille a été vidée.", + "workspace.rename": "Renommer", + "workspace.properties": "Propriétés", + "workspace.return": "Retour", + "workspace.select.all": "Tous", + "workspace.send.rack": "Déposer dans un casier", + "workspace.share": "Partager", + "workspace.share.removeall": "L'élément a été déplacé dans le dossier personnel du propriétaire.", + "move": "Déplacer", + "workspace.shared.message": "L'attribution des droits a bien été prise en compte", + "workspace.show.versions": "Voir les versions archivées", + "workspace.sniplet.carousel.add": "Ajouter une image", + "workspace.sniplet.carousel.buttons.shape": "Forme des boutons", + "workspace.sniplet.carousel.buttons.shape.sphere": "Ronds", + "workspace.sniplet.carousel.buttons.shape.square": "Carrés", + "workspace.sniplet.carousel.buttons.shape.triangle": "Triangles", + "workspace.sniplet.carousel.description": "Affiche des images dans un slider", + "workspace.sniplet.carousel.fade": "Fondu", + "workspace.sniplet.carousel.threeimages": "Trois images", + "workspace.sniplet.carousel.title": "Carrousel", + "workspace.sniplet.carousel.type": "Type de carrousel", + "workspace.sniplet.carousel.withpreview": "Avec aperçu", + "workspace.sniplet.documents.add": "Ajouter un fichier", + "workspace.sniplet.documents.description": "Il vous permet d'ajouter des documents que vos visiteurs pourront télécharger", + "workspace.sniplet.documents.confirm.remove": "Voulez-vous vraiment supprimer ces documents ? ", + "workspace.sniplet.documents.title": "Documents", + "workspace.title": "Espace documentaire", + "workspace.trash.error": "Erreur lors de la mise à la corbeille", + "workspace.trash.restore": "Restaurer", + "workspace.unselect.all": "Aucun", + "workspace.valid": "Appliquer", + "workspace.versions.list": "Versions", + "workspace.open": "Ouvrir", + "workspace.download": "Télécharger", + "workspace.search.description.one": "Un commentaire contenant, '{0}', répond à vos critères de recherche.", + "workspace.search.description.several": "{0} commentaires répondent à vos critères de recherche, dont le plus récent contient : '{1}'.", + "workspace.shared.document": "a partagé avec vous le document", + "workspace.shared.folder": "a partagé avec vous le dossier", + "tuto.title": "Naviguez au sein de vos documents en vue vignettes :", + "tuto.action.select": "Cliquez", + "tuto.text.select": "pour sélectionner et accéder au menu d'options,", + "tuto.action.open": "Double-cliquez", + "tuto.text.open": "pour ouvrir un dossier ou un fichier.", + "blocked.extension": "Erreur l'extension du fichier est interdite.", + "workspace.restored.message": "La restauration est terminée", + "workspace.search.placeholder": "Renseignez votre recherche", + "workspace.search.results.infolder": "Résultats de la recherche dans", + "workspace.search.results.inworkspace": "Résultats de la recherche dans tout l'espace documentaire", + "workspace.search.inprogress": "Recherche en cours...", + "workspace.search.inworkspace": "Rechercher dans tout l'espace documentaire", + "workspace.open.location": "Voir l'emplacement du fichier", + "dropzone.overlay.label": "Déposez les fichiers", + "dropzone.overlay.warning": "Il est impossible d'importer un dossier", + "dropzone.overlay.solution": "Veuillez importer un ou plusieurs fichiers", + "dropzone.overlay.action": "J'ai compris", + "workspace.import.warning": "Il est impossible d'importer un dossier", + "workspace.import.solution": "Veuillez importer un ou plusieurs fichiers", + "workspace.import.action": "J'ai compris", + "workspace.help.title": "Du nouveau dans l'espace documentaire!", + "workspace.help.1": "Le droit de contribution dans un dossier permet désormais aux personnes autorisées d'ajouter des documents dans vos dossiers partagés.", + "workspace.help.2": "Cette possibilité implique que tous les dossiers partagés soient visibles par tous au même endroit. Vos fichiers partagés sont désormais tous rangés dans cet emplacement. Si vous partagez un dossier ou un fichier depuis votre espace personnel, ils se retrouveront automatiquement ici.", + "folderpicker.folder.edit": "Créer un nouveau dossier", + "workspace.create.cancel.title": "Attention!", + "workspace.create.cancel.delete": "Supprimer le dossier", + "workspace.create.cancel.own": "Créer dans mes documents personnels", + "workspace.create.cancel.content": "Si vous ne partagez pas ce dossier, vous ne pouvez pas le laisser à la racine des documents partagés. Voulez-vous le créer dans vos documents personnels ?", + "workspace.create.share.warning": "Attention ! Vous devez obligatoirement partager le dossier créé, sinon", + "workspace.create.share.warning2": " créez le dans votre espace personnel", + "workspace.import.shared.title": "Partager vos fichiers importés", + "workspace.import.share.warning": "Attention ! Vous devez obligatoirement partager vos fichiers importés, sinon", + "workspace.import.share.warning2": "importez les dans votre espace personnel.", + "workspace.import.cancel.content": "Si vous ne partagez pas ces fichiers, vous ne pouvez pas les laisser à la racine des documents partagés. Voulez-vous les importer dans vos documents personnels ?", + "workspace.import.cancel.delete": "Annuler l'import", + "workspace.import.cancel.own": "Importer dans mes documents personnels", + "workspace.copy.window.title":"Copier de(s) fichier(s) et/ou dossier(s)", + "workspace.copy.window.info":"Rechercher et/ou sélectionner le dossier où vous voulez copier", + "workspace.copy.window.action":"Copier", + "workspace.copy.window.processing":"Copie en cours...", + "workspace.copy.window.finished":"Copie effectuée avec succès!", + "workspace.move.window.title":"Déplacer de(s) fichier(s) et/ou dossier(s)", + "workspace.move.window.info":"Rechercher et/ou sélectionner le dossier où vous voulez déplacer", + "workspace.move.window.action":"Déplacer", + "workspace.move.window.processing":"Déplacement en cours...", + "workspace.move.window.finished":"Déplacement effectué avec succès!", + "workspace.move.window.toshare":"Vous allez déplacer vos fichiers et dossiers dans un espace partagé, ils ne seront plus visibles dans leur emplacement d'origine.
Vous pouvez créer une copie des fichiers et dossiers au lieu de les déplacer.", + "workspace.move.window.docopy":"Effectuer une copie", + "workspace.move.window.toown":"Vous aller déplacer vos fichiers et dossiers dans vos documents personnels et les retirer des partages avec les autres utilisateurs.", + "workspace.move.window.fromshare.notmine":"En déplaçant ces fichiers/dossiers dans votre espace personnel, Vous en deviendrez gestionnaire et serez le seul à pouvoir encore les consulter. Les utilisateurs qui les voient actuellement n'y auront plus accès.
Vous pouvez créer une copie de ces fichiers/dossiers au lieu de les déplacer.", + "workspace.move.window.copyinsteadofmove":"Vous pouvez créer une copie des fichiers ou dossiers au lieu de les déplacer", + "workspace.move.window.fromtoshare":"Vous allez déplacer des fichiers et/ou dossiers dans un autre espace partagé. Certains utilisateurs pourraient ne plus y voir accès.", + "workspace.delete.confirm.title":"Attention!", + "workspace.delete.confirm.action":"Confirmer la suppression", + "workspace.delete.confirm.content":"En supprimant ces fichiers ou dossiers vous les supprimerez également pour les utilisateurs qui voient ces fichiers ou dossiers ou qui en sont propriétaires.", + "workspace.delete.empty.trash":"Voulez-vous vraiment vider la corbeille ?", + "workspace.contrib.cant":"Vous n'avez pas les droits de contributions dans ce dossier", + "workspace.copied.toowner":"Document(s) copié(s) dans vos documents personnels", + "workspace.copy.title":"Copier des fichiers et/ou dossiers", + "workspace.move.title":"Déplacement des fichiers et/ou dossiers", + "workspace.element.uri.notfound":"Les fichiers ou dossiers ont été déplacés dans un dossier auquel vous n'avez pas accès, vous pouvez les retrouver en effectuant une recherche dans tout l'espace documentaire", + "workspace.external.open": "Éditer", + "workspace.create.workspace": "Importer un document", + "workspace.loading": "Chargement en cours", + "workspace.preview.loading": "Prévisualisation en cours de chargement", + "document.error.missing.fileid": "Le fichier recherché n'existe pas", + "document.error.preview.kind.unknown": "Prévisualisation impossible. Le type du document n'a pas été reconnu.", + "document.preview.save.failed": "Une erreur est survenue lors de la génération de la prévisualisation.", + "workspace.share.document": "Partager des documents", + "workspace.upload.forbidden": "Vous n'avez pas les droits sur ce dossier.", + "workspace.notify.forbidden": "Vous n'avez pas les droits sur certains fichiers ou dossiers", + "workspace.properties.parent": "Dossier parent", + "workspace.properties.managers": "Liste des gestionnaires", + "workspace.import.zip.title": "Importer des fichiers", + "workspace.import.zip.content": "Vous tentez de déposer une archive au format zip.
Souhaitez vous qu'elle soit décompressée avant d'être importée ?
Si votre archive comporte une arborescence de dossiers, cette arborescence sera conservée.", + "workspace.import.zip.uncompress": "Importer et décompresser", + "workspace.import.zip.upload": "Importer sans décompresser", + "workspace.import.zip.cancel": "Annuler", + "workspace.import.zip.error.encoding": "Votre archive possède des fichiers ou dossiers dont le nom contient des caractères accentués ou spéciaux incompatibles avec l'import", + "folder.zip.upload.notfound": "Le téléchargement du ZIP a échoué. Veuillez réessayer.", + "folder.zip.upload.partial": "Certains élements du fichiers ZIP n'ont pas pu être importé", + "workspace.invalidfile.placeholder": "Fichier invalide", + "workspace.import.zip.invalid": "Fichier invalide", + + "nextcloud.title": "Nextcloud", + "nextcloud.virtual.media.title": "Documents synchronisés", + "nextcloud.documents": "Documents synchronisés", + "create.nextcloud.documents": "Créer un dossier synchronisé", + "nextcloud.quota": "Espace utilisé synchronisé", + "nextcloud.file.already.exist": "Impossible de renommer le fichier avec ce nom: celui-ci existe déjà", + "nextcloud.url.connect": "Indiquez cette url en configuration Nextcloud pour accéder au service sur tous vos appareils", + "nextcloud.documents.trash": "Placer dans la corbeille", + "nextcloud.documents.confirm.trash": "Voulez-vous déplacer ces documents dans la corbeille ?", + "nextcloud.documents.confirm.trash.action": "Déplacer dans la corbeille", + "nextcloud.documents.trash.confirmation": "Les éléments ont été déplacés dans la corbeille.", + "nextcloud.documents.confirm.deletion": "Voulez-vous supprimer définitivement ces documents", + "nextcloud.documents.deletion.confirmation": "Les éléments ont été définitivement supprimés.", + "nextcloud.fail.upload": "Une erreur est survenue pendant le téléchargement des fichiers.", + "file.too.large.upload": "Fichier(s) trop volumineux", + "error.user.info": "Erreur lors de la récupération des informations utilisateur", + "workspace.definitive.delete.confirm.action": "Supprimer définitivement", + "nextcloud.unlimited.space": "Espace illimité", + + "nextcloud.console.title": "Console Nextcloud", + "nextcloud.console.subtitle": "Configurez les paramètres du client Nextcloud", + "nextcloud.console.sync.folder": "Dossier synchronisé * :", + "nextcloud.console.complete.path": "Chemin complet :", + "nextcloud.console.folder.location.instructions": "Le dossier sera créé à partir du dossier de l’utilisateur (C:) / Utilisateurs / ", + "nextcloud.console.folder.root.location": "(C:)/Utilisateurs//", + "nextcloud.console.bandwidth": "Bande passante :", + "nextcloud.console.bandwidth.unit": "Ko/s", + "nextcloud.console.upload": "Emission ", + "nextcloud.console.download": "Réception ", + "nextcloud.console.excluded.extensions": "Types de fichiers interdits :", + "nextcloud.console.excluded.extensions.instructions": "Saisir l’extension et appuyez sur Entrée pour valider.", + + "nextcloud.console.save": "Enregistrer", + "nextcloud.console.cancel": "Annuler", + "nextcloud.console.alert.save": "Les paramètres ont bien été sauvegardés.", + "nextcloud.move.error": "Erreur lors du déplacement du fichier", + "nextcloud.copy.error": "Erreur lors de la copie du fichier", + "nextcloud.static.trashbin": "Corbeille", + "nextcloud.documents.restore.confirmation": "Les éléments ont été restaurés." +} diff --git a/workspace/src/main/resources/public/template/copy/index.html b/workspace/src/main/resources/public/template/copy/index.html index af611e8f00..e9481ee191 100644 --- a/workspace/src/main/resources/public/template/copy/index.html +++ b/workspace/src/main/resources/public/template/copy/index.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/workspace/src/main/resources/public/template/nextcloud/content/views/icons.html b/workspace/src/main/resources/public/template/nextcloud/content/views/icons.html new file mode 100644 index 0000000000..4f1daf488f --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/content/views/icons.html @@ -0,0 +1,39 @@ + + \ No newline at end of file diff --git a/workspace/src/main/resources/public/template/nextcloud/content/views/list.html b/workspace/src/main/resources/public/template/nextcloud/content/views/list.html new file mode 100644 index 0000000000..7db4afa80e --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/content/views/list.html @@ -0,0 +1,84 @@ + +
+

+ workspace.loading  +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-content.html b/workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-content.html new file mode 100644 index 0000000000..e4c8fad2bd --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-content.html @@ -0,0 +1,91 @@ +
+
+
+ nextcloud.url.connect : + [[nextcloudUrl]] +
+ +
+ + +
+ + +
+
+
+ + + + + + + + + +
+

+ empty.workspace.subfolder.title +

+ + + + +
+ + +
+ + +
+ +
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-upload-file.html b/workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-upload-file.html new file mode 100644 index 0000000000..9a0962c94f --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-upload-file.html @@ -0,0 +1,55 @@ + +
+

+ medialibrary.title +

+ +
+ + +
+
    +
  • + +
    + +
    + + + +
    [[doc.name || doc.title]]
    + + +
    + + [[upload.getSize(doc.size)]] + + +
    + + +
  • +
+
+
+ + +
+ + + + + +
+
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/folder/empty-trash.html b/workspace/src/main/resources/public/template/nextcloud/folder/empty-trash.html new file mode 100644 index 0000000000..311325a50b --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/folder/empty-trash.html @@ -0,0 +1,14 @@ + +

+
+ +
+
+ +
diff --git a/workspace/src/main/resources/public/template/nextcloud/folder/folder-creation.html b/workspace/src/main/resources/public/template/nextcloud/folder/folder-creation.html new file mode 100644 index 0000000000..a3ff5c284e --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/folder/folder-creation.html @@ -0,0 +1,15 @@ + +
+

+ folder.new.title +

+
+ + +
+ +
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/folder/workspace-nextcloud-folder.html b/workspace/src/main/resources/public/template/nextcloud/folder/workspace-nextcloud-folder.html new file mode 100644 index 0000000000..4a8a8c1c4f --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/folder/workspace-nextcloud-folder.html @@ -0,0 +1,59 @@ +
+ + + + + +
+ + + +
+

+ nextcloud.quota +

+ + nextcloud.unlimited.space +
+
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/import/nextcloud-import.html b/workspace/src/main/resources/public/template/nextcloud/import/nextcloud-import.html new file mode 100644 index 0000000000..65e55aec0e --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/import/nextcloud-import.html @@ -0,0 +1,64 @@ + +

+ medialibrary.title +

+
+
+
+ +
+ medialibrary.drop.help2 +
+
+
+
+
+ +
+ +
+ +
+ medialibrary.drop.help +
+
+
+ +
+ workspace.import.warning
workspace.import.solution
+
+
+ +
+
+
+
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share-documents-options.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share-documents-options.html new file mode 100644 index 0000000000..270f3c43f8 --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share-documents-options.html @@ -0,0 +1,53 @@ +
+
+ +

+ + +
+ +
+
+ + +
+ + +
+
+
+
+

+ +
+
+
+
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share.html new file mode 100644 index 0000000000..2696c1bc1a --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share.html @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/share/workspace-nextcloud-toolbar-share.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/workspace-nextcloud-toolbar-share.html new file mode 100644 index 0000000000..ac1dc389be --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/workspace-nextcloud-toolbar-share.html @@ -0,0 +1,6 @@ + + + diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-copy.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-copy.html new file mode 100644 index 0000000000..abfe36eb42 --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-copy.html @@ -0,0 +1,3 @@ + + + diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-delete.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-delete.html new file mode 100644 index 0000000000..a11f8b3a0f --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-delete.html @@ -0,0 +1,37 @@ + +
+ +

+ workspace.delete +

+ + +
+ nextcloud.documents.confirm.deletion +
+
+ + + +
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-properties.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-properties.html new file mode 100644 index 0000000000..64c82684b8 --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-properties.html @@ -0,0 +1,35 @@ + + +

+ workspace.properties +

+ + +
+ + +
+ + + +
diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-trash.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-trash.html new file mode 100644 index 0000000000..c6ab00028a --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-trash.html @@ -0,0 +1,37 @@ + +
+ +

+ nextcloud.documents.trash +

+ + +
+ nextcloud.documents.confirm.trash +
+
+ + + +
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar.html new file mode 100644 index 0000000000..655d45145d --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar.html @@ -0,0 +1,131 @@ +
+
+ +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+
+ + +
+ +
+ +
+ + +
+ + + +
+
+
+
+
diff --git a/workspace/src/main/resources/public/ts/app.ts b/workspace/src/main/resources/public/ts/app.ts index e2c3aaa28e..ebd2feadda 100644 --- a/workspace/src/main/resources/public/ts/app.ts +++ b/workspace/src/main/resources/public/ts/app.ts @@ -1,40 +1,53 @@ -import { ng, routes } from 'entcore'; -import { workspaceController } from './controller'; -import { importFiles } from './directives/import'; -import { fileViewer } from './directives/fileViewer'; -import { pdfViewer } from './directives/pdfViewer'; -import { cssTransitionEnd } from './directives/cssTransitions'; -import { dropzoneOverlay } from './directives/dropzoneOverlay'; -import { lazyLoadImg } from './directives/lazyLoad'; -import { csvViewer } from './directives/csvViewer'; -import { txtViewer } from './directives/txtViewer'; +import { ng, routes } from "entcore"; +import { workspaceController } from "./controller"; +import { cssTransitionEnd } from "./directives/cssTransitions"; +import { csvViewer } from "./directives/csvViewer"; +import { dropzoneOverlay } from "./directives/dropzoneOverlay"; +import { fileViewer } from "./directives/fileViewer"; +import { folderPicker2 } from "./directives/folderPicker2"; +import { folderTree2, folderTreeInner2 } from "./directives/folderTree2"; +import { importFiles } from "./directives/import"; +import { lazyLoadImg } from "./directives/lazyLoad"; +import { + workspaceNextcloudContent, + workspaceNextcloudContentController, +} from "./directives/nextcloud/components/content/contentViewer.component"; +import { + workspaceNextcloudFolder, + workspaceNextcloudFolderController, +} from "./directives/nextcloud/nextcloudFolder.directive"; +import { NextcloudService } from "./directives/nextcloud/services/nextcloud.service"; +import { NextcloudEventService } from "./directives/nextcloud/services/nextcloudEvent.service"; +import { NextcloudUserService } from "./directives/nextcloud/services/nextcloudUser.service"; +import { pdfViewer } from "./directives/pdfViewer"; +import { txtViewer } from "./directives/txtViewer"; routes.define(function ($routeProvider) { - $routeProvider - .when('/', { - action: 'openOwn' - }) - .when('/folder/:folderId', { - action: 'viewFolder' - }) - .when('/shared/folder/:folderId', { - action: 'viewSharedFolder' - }) - .when('/shared', { - action: 'openShared' - }) - .when('/trash', { - action: 'openTrash' - }) - .when('/apps', { - action: 'openApps' - }) - .when('/external', { - action: 'openExternal' - }) - .otherwise({ - redirectTo: '/' - }) + $routeProvider + .when("/", { + action: "openOwn", + }) + .when("/folder/:folderId", { + action: "viewFolder", + }) + .when("/shared/folder/:folderId", { + action: "viewSharedFolder", + }) + .when("/shared", { + action: "openShared", + }) + .when("/trash", { + action: "openTrash", + }) + .when("/apps", { + action: "openApps", + }) + .when("/external", { + action: "openExternal", + }) + .otherwise({ + redirectTo: "/", + }); }); ng.controllers.push(workspaceController); @@ -42,7 +55,26 @@ ng.directives.push(importFiles); ng.directives.push(fileViewer); ng.directives.push(pdfViewer); ng.directives.push(cssTransitionEnd); -ng.directives.push(dropzoneOverlay) -ng.directives.push(lazyLoadImg) -ng.directives.push(csvViewer) -ng.directives.push(txtViewer) +ng.directives.push(dropzoneOverlay); +ng.directives.push(lazyLoadImg); +ng.directives.push(csvViewer); +ng.directives.push(txtViewer); + +// Nextcloud +// Services +ng.services.push(NextcloudService); +ng.services.push(NextcloudUserService); +ng.services.push(NextcloudEventService); + +// Folder +ng.directives.push(workspaceNextcloudFolder); +ng.controllers.push(workspaceNextcloudFolderController); + +// Folder Picker +ng.directives.push(folderTree2); +ng.directives.push(folderTreeInner2); +ng.directives.push(folderPicker2); + +// Content +ng.directives.push(workspaceNextcloudContent); +ng.controllers.push(workspaceNextcloudContentController); diff --git a/workspace/src/main/resources/public/ts/controller.ts b/workspace/src/main/resources/public/ts/controller.ts index 5f8bdaa74f..e34b735d72 100644 --- a/workspace/src/main/resources/public/ts/controller.ts +++ b/workspace/src/main/resources/public/ts/controller.ts @@ -15,18 +15,27 @@ // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. -import { ng, template, idiom as lang, notify, idiom, moment, workspace } from 'entcore'; -import { NavigationDelegateScope, NavigationDelegate } from './delegates/navigation'; -import { ActionDelegate, ActionDelegateScope } from './delegates/actions'; -import { TreeDelegate, TreeDelegateScope } from './delegates/tree'; -import { CommentDelegate, CommentDelegateScope } from './delegates/comments'; -import { DragDelegate, DragDelegateScope } from './delegates/drag'; -import { SearchDelegate, SearchDelegateScope } from './delegates/search'; -import { RevisionDelegateScope, RevisionDelegate } from './delegates/revisions'; -import { KeyboardDelegate, KeyboardDelegateScope } from './delegates/keyboard'; -import { LoolDelegateScope, LoolDelegate } from './delegates/lool'; -import { models, workspaceService, DocumentCursor, Document, DocumentCursorParams, CursorUpdate } from "./services"; -import { DocumentActionType } from 'entcore/types/src/ts/workspace/services'; +import { idiom as lang, idiom, moment, ng, notify, template } from "entcore"; +import { + NavigationDelegate, + NavigationDelegateScope, +} from "./delegates/navigation"; +import { ActionDelegate, ActionDelegateScope } from "./delegates/actions"; +import { TreeDelegate, TreeDelegateScope } from "./delegates/tree"; +import { CommentDelegate, CommentDelegateScope } from "./delegates/comments"; +import { DragDelegate, DragDelegateScope } from "./delegates/drag"; +import { SearchDelegate, SearchDelegateScope } from "./delegates/search"; +import { RevisionDelegate, RevisionDelegateScope } from "./delegates/revisions"; +import { KeyboardDelegate, KeyboardDelegateScope } from "./delegates/keyboard"; +import { LoolDelegate, LoolDelegateScope } from "./delegates/lool"; +import { + CursorUpdate, + DocumentCursor, + DocumentCursorParams, + models, + workspaceService, +} from "./services"; +import { DocumentActionType } from "entcore/types/src/ts/workspace/services"; import {ScratchDelegate, ScratchDelegateScope} from "./delegates/scratch"; import {GeogebraDelegate, GeogebraDelegateScope} from "./delegates/geogebra"; @@ -34,6 +43,7 @@ import {GeogebraDelegate, GeogebraDelegateScope} from "./delegates/geogebra"; declare var ENABLE_LOOL: boolean; declare var ENABLE_SCRATCH: boolean; declare var ENABLE_NEXTCLOUD: boolean; +declare var USE_NEXTCLOUD_SNIPLET: boolean; declare var ENABLE_GGB: boolean; declare var DISABLE_FULL_TEXT_SEARCH: boolean; export interface WorkspaceScope extends RevisionDelegateScope, NavigationDelegateScope, TreeDelegateScope, ActionDelegateScope, CommentDelegateScope, DragDelegateScope, SearchDelegateScope, KeyboardDelegateScope, LoolDelegateScope, ScratchDelegateScope, GeogebraDelegateScope { @@ -41,6 +51,7 @@ export interface WorkspaceScope extends RevisionDelegateScope, NavigationDelegat ENABLE_SCRATCH: boolean; ENABLE_GGB: boolean; ENABLE_NEXTCLOUD: boolean; + USE_NEXTCLOUD_SNIPLET: boolean; DISABLE_FULL_TEXT_SEARCH: boolean; documentList:models.DocumentsListModel; documentListSorted:models.DocumentsListModel; @@ -169,6 +180,7 @@ export let workspaceController = ng.controller('Workspace', ['$scope', '$rootSco $scope.ENABLE_SCRATCH = ENABLE_SCRATCH; $scope.ENABLE_GGB = ENABLE_GGB; $scope.ENABLE_NEXTCLOUD = ENABLE_NEXTCLOUD; + $scope.USE_NEXTCLOUD_SNIPLET = USE_NEXTCLOUD_SNIPLET; $scope.DISABLE_FULL_TEXT_SEARCH = DISABLE_FULL_TEXT_SEARCH; /** @@ -184,87 +196,171 @@ export let workspaceController = ng.controller('Workspace', ['$scope', '$rootSco const shouldCache = workspaceService.isLazyMode(); $scope.documentList = new models.DocumentsListModel($filter).watch($scope,{documents:'openedFolder.documents'}); $scope.documentListSorted = new models.DocumentsListModel($filter).watch($scope,{documents:'openedFolder.sortedDocuments'}); - $scope.trees = [new models.ElementTree(shouldCache,{ - name: lang.translate('documents'), - filter: 'owner', - hierarchical: true, - hidden: false, - children: [], - buttons: [ - { text: lang.translate('workspace.add.document'), action: () => $scope.display.importFiles = true, icon: true, workflow: 'workspace.create', disabled() { return false } } - ], - contextualButtons: [ - { text: lang.translate('workspace.move'), action: $scope.openMoveView, right: "manager", allow: allowAction("move") }, - { text: lang.translate('workspace.copy'), action: $scope.openCopyView, right: "read", allow: allowAction("copy") }, - { text: lang.translate('workspace.move.trash'), action: $scope.toTrashConfirm, right: "manager" } - ] - }), new models.ElementTree(shouldCache,{ - name: lang.translate('shared_tree'), - filter: 'shared', - hierarchical: true, - hidden: false, - buttons: [ - { - text: lang.translate('workspace.add.document'), action: () => $scope.display.importFiles = true, icon: true, workflow: 'workspace.create', disabled() { - if($scope.currentTree.filter == "shared" && $scope.currentTree===$scope.openedFolder.folder){ - return false; - } - let isFolder = ($scope.openedFolder.folder instanceof models.Element); - return isFolder && !$scope.openedFolder.folder.canWriteOnFolder - } - } - ], - children: [], - contextualButtons: [ - { text: lang.translate('workspace.move'), action: $scope.openMoveView, right: "manager", allow: allowAction("move") }, - { text: lang.translate('workspace.copy'), action: $scope.openCopyView, right: "read", allow: allowAction("copy") }, - { text: lang.translate('workspace.move.trash'), action: $scope.toTrashConfirm, right: "manager" } - ] - }),new models.ElementTree(shouldCache,{ - name: lang.translate('externalDocs'), - filter: 'external', - get hidden() { - const tree = $scope.trees.find(e => e.filter == "external"); - return !tree || tree.children.length == 0; - }, - buttons: [], - hierarchical: true, - children: [], - contextualButtons: [ - { - text: lang.translate('workspace.move.trash'), action: $scope.toTrashConfirm, allow() { - //trash only files - return $scope.selectedFolders().length == 0; - } - } - ] - }), new models.ElementTree(shouldCache,{ - name: lang.translate('appDocuments'), - filter: 'protected', - hidden: false, - buttons: [ - { text: lang.translate('workspace.add.document'), action: () => { }, icon: true, workflow: 'workspace.create', disabled() { return true } } - ], - hierarchical: true, - children: [], - contextualButtons: [ - { text: lang.translate('workspace.copy'), action: $scope.openCopyView, right: "read", allow: allowAction("copy") }, - { text: lang.translate('workspace.move.trash'), action: $scope.toTrashConfirm, right: "manager" } - ] - }), new models.ElementTree(shouldCache,{ - name: lang.translate('trash'), - hidden: false, - buttons: [ - { text: lang.translate('workspace.add.document'), action: () => { }, icon: true, workflow: 'workspace.create', disabled() { return true } } - ], - filter: 'trash', - hierarchical: true, - children: [], - contextualButtons: [ - { text: lang.translate('workspace.trash.restore'), action: $scope.restore, right: "manager" }, - { text: lang.translate('workspace.move.trash'), action: $scope.deleteConfirm, right: "manager" } - ] - })]; + $scope.trees = [ + new models.ElementTree(shouldCache, { + name: lang.translate("documents"), + filter: "owner", + hierarchical: true, + hidden: false, + children: [], + buttons: [ + { + text: lang.translate("workspace.add.document"), + action: () => ($scope.display.importFiles = true), + icon: true, + workflow: "workspace.create", + disabled() { + return false; + }, + }, + ], + contextualButtons: [ + { + text: lang.translate("workspace.move"), + action: $scope.openMoveView, + right: "manager", + allow: allowAction("move"), + }, + { + text: lang.translate("workspace.copy"), + action: $scope.openCopyView, + right: "read", + allow: allowAction("copy"), + }, + { + text: lang.translate("workspace.move.trash"), + action: $scope.toTrashConfirm, + right: "manager", + }, + ], + }), + new models.ElementTree(shouldCache, { + name: lang.translate("shared_tree"), + filter: "shared", + hierarchical: true, + hidden: false, + buttons: [ + { + text: lang.translate("workspace.add.document"), + action: () => ($scope.display.importFiles = true), + icon: true, + workflow: "workspace.create", + disabled() { + if ( + $scope.currentTree.filter == "shared" && + $scope.currentTree === $scope.openedFolder.folder + ) { + return false; + } + let isFolder = $scope.openedFolder.folder instanceof models.Element; + return isFolder && !$scope.openedFolder.folder.canWriteOnFolder; + }, + }, + ], + children: [], + contextualButtons: [ + { + text: lang.translate("workspace.move"), + action: $scope.openMoveView, + right: "manager", + allow: allowAction("move"), + }, + { + text: lang.translate("workspace.copy"), + action: $scope.openCopyView, + right: "read", + allow: allowAction("copy"), + }, + { + text: lang.translate("workspace.move.trash"), + action: $scope.toTrashConfirm, + right: "manager", + }, + ], + }), + new models.ElementTree(shouldCache, { + name: lang.translate("externalDocs"), + filter: "external", + get hidden() { + const tree = $scope.trees.find((e) => e.filter == "external"); + return !tree || tree.children.length == 0; + }, + buttons: [], + hierarchical: true, + children: [], + contextualButtons: [ + { + text: lang.translate("workspace.move.trash"), + action: $scope.toTrashConfirm, + allow() { + //trash only files + return $scope.selectedFolders().length == 0; + }, + }, + ], + }), + new models.ElementTree(shouldCache, { + name: lang.translate("appDocuments"), + filter: "protected", + hidden: false, + buttons: [ + { + text: lang.translate("workspace.add.document"), + action: () => {}, + icon: true, + workflow: "workspace.create", + disabled() { + return true; + }, + }, + ], + hierarchical: true, + children: [], + contextualButtons: [ + { + text: lang.translate("workspace.copy"), + action: $scope.openCopyView, + right: "read", + allow: allowAction("copy"), + }, + { + text: lang.translate("workspace.move.trash"), + action: $scope.toTrashConfirm, + right: "manager", + }, + ], + }), + new models.ElementTree(shouldCache, { + name: lang.translate("trash"), + hidden: false, + buttons: [ + { + text: lang.translate("workspace.add.document"), + action: () => {}, + icon: true, + workflow: "workspace.create", + disabled() { + return true; + }, + }, + ], + filter: "trash", + hierarchical: true, + children: [], + contextualButtons: [ + { + text: lang.translate("workspace.trash.restore"), + action: $scope.restore, + right: "manager", + }, + { + text: lang.translate("workspace.move.trash"), + action: $scope.deleteConfirm, + right: "manager", + }, + ], + }), + ]; $scope.display = { nbFiles: 50 }; @@ -335,4 +431,4 @@ export let workspaceController = ng.controller('Workspace', ['$scope', '$rootSco return moment().format('L'); } -}]); \ No newline at end of file +}]); diff --git a/workspace/src/main/resources/public/ts/delegates/actions/copy.ts b/workspace/src/main/resources/public/ts/delegates/actions/copy.ts index 428c9328fa..439efb8326 100644 --- a/workspace/src/main/resources/public/ts/delegates/actions/copy.ts +++ b/workspace/src/main/resources/public/ts/delegates/actions/copy.ts @@ -1,7 +1,6 @@ import { model, template, FolderPickerProps, FolderPickerSourceFile, notify } from "entcore"; import { models, workspaceService } from "../../services"; - export interface ActionCopyDelegateScope { copyProps: FolderPickerProps isMovingElementsMine(): boolean @@ -24,7 +23,6 @@ export interface ActionCopyDelegateScope { selectedItems(): models.Element[] safeApply(a?) setHighlightTree(els: { folder: models.Node, count: number }[]); - } export function ActionCopyDelegate($scope: ActionCopyDelegateScope) { diff --git a/workspace/src/main/resources/public/ts/delegates/actions/trash.ts b/workspace/src/main/resources/public/ts/delegates/actions/trash.ts index 2981bba2fc..fe0540ea00 100644 --- a/workspace/src/main/resources/public/ts/delegates/actions/trash.ts +++ b/workspace/src/main/resources/public/ts/delegates/actions/trash.ts @@ -109,4 +109,4 @@ export function ActionTrashDelegate($scope: ActionTrashScope) { }, 300) }; }; -} \ No newline at end of file +} diff --git a/workspace/src/main/resources/public/ts/directives/folderPicker2.ts b/workspace/src/main/resources/public/ts/directives/folderPicker2.ts new file mode 100644 index 0000000000..9906586a49 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/folderPicker2.ts @@ -0,0 +1,298 @@ +/* + * ⚠️ WARNING ⚠️ + * This component is almost an exact copy of the folderPicker component + * with a few adaptations to make it work with nextcloud. + */ + +import { ng } from "entcore"; +import { models, workspaceService } from "../services"; +import { FolderTreeProps } from "./folderTree2"; +import { SyncDocument } from "./nextcloud/models/nextcloudFolder.model"; + +export interface FolderPickerScope { + treeProps: FolderTreeProps; + nextcloudTreeProps: FolderTreeProps; + folderProps: FolderPickerProps; + + // Tree data + trees: models.Tree[]; + nextcloudTrees: SyncDocument[]; + + // UI state + selectedFolder: models.Element | SyncDocument; + newFolder: models.Element; + search: { + value: string; + }; + + // Utility methods + safeApply(fn?: () => void): void; + + // State check methods + isStateNormal(): boolean; + isStateLoading(): boolean; + isStateLoaded(): boolean; + isStateEmpty(): boolean; + + // New folder operations + openEditView(): void; + canOpenEditView(): boolean; + isEditVew(): boolean; + submitNewFolder(): void; + + // Search operations + canResetSearch(): boolean; + resetSearch(): void; + searchKeyUp(event: KeyboardEvent): void; + + // Action methods + onError(error?: any): void; + onCancel(): void; + onSubmit(): void; + cannotSubmit(): boolean; +} + +export interface FolderPickerSource { + action: "create-from-blob" | "copy-from-file" | "move-from-file"; +} + +export interface FolderPickerSourceFile extends FolderPickerSource { + fileId: string; +} + +export interface FolderPickerSourceBlob extends FolderPickerSource { + title?: string; + content: Blob; +} + +export interface FolderPickerProps { + i18: { + title: string; + actionTitle: string; + actionProcessing: string; + actionFinished: string; + actionEmpty?: string; + info: string; + }; + sources: FolderPickerSource[]; + treeProvider?(): Promise; + nextcloudTreeProvider?(): Promise; + manageSubmit?(folder: models.Element | SyncDocument): boolean; + submit?(folder: models.Element | SyncDocument): Promise | void; + onCancel(): void; + onError?(error: any): void; + onSubmitSuccess?(dest: models.Element | SyncDocument, count: number): void; +} + +export const folderPicker2 = ng.directive("folderPicker2", [ + "$timeout", + () => { + return { + restrict: "E", + scope: { + folderProps: "=", + }, + template: ` +
+

+
+
+
+
+
+ +
+ +
+ +
+ +
+
+ +
+
+
+ `, + link: async (scope: FolderPickerScope) => { + scope.search = { value: "" }; + scope.newFolder = new models.Element(); + + scope.safeApply = function (fn) { + const phase = this.$root.$$phase; + if (phase == "$apply" || phase == "$digest") { + if (fn && typeof fn === "function") { + fn(); + } + } else { + this.$apply(fn); + } + }; + + scope.selectedFolder = null; + + scope.trees = []; + scope.nextcloudTrees = []; + + const canSelect = function (folder: models.Element | SyncDocument) { + if (folder instanceof models.Element) { + if ((folder as models.Tree).filter) { + return (folder as models.Tree).filter == "owner"; + } else { + return true; + } + } else if (folder instanceof SyncDocument) { + return folder.isFolder; + } + return false; + }; + + let selectedWorkspaceFolder: models.Element = null; + let openedWorkspaceFolder: models.Element = null; + + scope.treeProps = { + cssTree: "maxheight-half-vh", + get trees() { + return scope.trees; + }, + isDisabled(folder: models.Element) { + return !canSelect(folder); + }, + isOpenedFolder(folder: models.Element) { + if (openedWorkspaceFolder === folder) { + return true; + } else if ((folder as models.Tree).filter) { + if (!workspaceService.isLazyMode()) { + return true; + } + } + return ( + openedWorkspaceFolder && + workspaceService.findFolderInTreeByRefOrId( + folder, + openedWorkspaceFolder, + ) + ); + }, + isSelectedFolder(folder: models.Element) { + return selectedWorkspaceFolder === folder; + }, + openFolder(folder: models.Element) { + selectedNextcloudFolder = null; + openedNextcloudFolders = []; + + if (canSelect(folder)) { + openedWorkspaceFolder = selectedWorkspaceFolder = folder; + scope.selectedFolder = folder; + } else { + openedWorkspaceFolder = folder; + } + }, + }; + + // Nextcloud tree props (reuse workspaceNextcloudFolder logic) + let selectedNextcloudFolder: SyncDocument = null; + let openedNextcloudFolders: SyncDocument[] = []; + + scope.nextcloudTreeProps = { + cssTree: "maxheight-half-vh", + get trees() { + return scope.nextcloudTrees; + }, + isDisabled(folder: SyncDocument) { + return false; + }, + isOpenedFolder(folder: SyncDocument) { + return openedNextcloudFolders.some( + (openFolder) => openFolder === folder, + ); + }, + isSelectedFolder(folder: SyncDocument) { + return selectedNextcloudFolder === folder; + }, + openFolder(folder: SyncDocument) { + // Clear workspace selection + selectedWorkspaceFolder = null; + openedWorkspaceFolder = null; + + selectedNextcloudFolder = folder; + scope.selectedFolder = folder; + + // Add to opened folders if not already there + if (!openedNextcloudFolders.includes(folder)) { + openedNextcloudFolders.push(folder); + } + }, + }; + + // Load trees + const loadTrees = async () => { + try { + // Load workspace trees + if (scope.folderProps.treeProvider) { + const workspaceTrees = await scope.folderProps.treeProvider(); + if (workspaceTrees && workspaceTrees.length > 0) { + scope.trees.length = 0; + workspaceTrees.forEach((tree) => scope.trees.push(tree)); + } + } + + // Load Nextcloud trees + if (scope.folderProps.nextcloudTreeProvider) { + const nextcloudTrees = + await scope.folderProps.nextcloudTreeProvider(); + if (nextcloudTrees && nextcloudTrees.length > 0) { + scope.nextcloudTrees.length = 0; + nextcloudTrees.forEach((tree) => + scope.nextcloudTrees.push(tree), + ); + } + } + + scope.safeApply(); + } catch (e) { + console.error("Error loading trees:", e); + if (scope.folderProps.onError) { + scope.folderProps.onError(e); + } + } + }; + + // Load trees + await loadTrees(); + + // Submission handling + scope.cannotSubmit = () => !scope.selectedFolder; + + scope.onSubmit = async () => { + if (scope.cannotSubmit()) return; + + // If custom submit handling is provided, use that + if ( + scope.folderProps.manageSubmit && + scope.folderProps.manageSubmit(scope.selectedFolder) + ) { + return; + } + + try { + if (scope.folderProps.submit) { + await scope.folderProps.submit(scope.selectedFolder); + scope.safeApply(); + } + } catch (e) { + scope.folderProps.onError(e); + scope.safeApply(); + } + }; + + scope.onCancel = () => { + scope.folderProps.onCancel(); + }; + }, + }; + }, +]); diff --git a/workspace/src/main/resources/public/ts/directives/folderTree2.ts b/workspace/src/main/resources/public/ts/directives/folderTree2.ts new file mode 100644 index 0000000000..6f3282d1fa --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/folderTree2.ts @@ -0,0 +1,200 @@ +/* + * ⚠️ WARNING ⚠️ + * This component is almost an exact copy of the folderTree component + * with a few adaptations to make it work with nextcloud. + */ + +import { model, ng } from "entcore"; +import { models, workspaceService } from "../services"; +import angular = require("angular"); +import { SyncDocument } from "./nextcloud/models/nextcloudFolder.model"; +import { nextcloudService } from "./nextcloud/services/nextcloud.service"; + +//function to compile template recursively +function compileRecursive($compile, element, link) { + // Normalize the link parameter + if (angular.isFunction(link)) { + link = { post: link }; + } + // Break the recursion loop by removing the contents + const contents = element.contents().remove(); + let compiledContents; + return { + pre: link && link.pre ? link.pre : null, + /** + * Compiles and re-adds the contents + */ + post: function (scope, element) { + // Compile the contents + if (!compiledContents) { + compiledContents = $compile(contents); + } + // Re-add the compiled contents to the element + compiledContents(scope, function (clone) { + element.append(clone); + }); + + // Call the post-linking function, if any + if (link && link.post) { + link.post.apply(null, arguments); + } + }, + }; +} +export interface FolderTreeProps { + cssTree?: string; + trees: T[]; + isDisabled(folder: T): boolean; + isSelectedFolder(folder: T): boolean; + isOpenedFolder(folder: T): boolean; + openFolder(folder: T): void; +} + +export interface FolderTreeInnerScope { + folder: T; + treeProps: FolderTreeProps; + translate(); + canExpendTree(); + isSelectedFolder(): boolean; + isOpenedFolder(): boolean; + openFolder(); + safeApply(a?: any); + isDisabled(): boolean; +} + +export interface FolderTreeScope { + treeProps: FolderTreeProps; + trees(): T[]; +} + +export const folderTreeInner2 = ng.directive("folderTreeInner2", [ + "$compile", + ($compile) => { + return { + restrict: "E", + scope: { + treeProps: "=", + folder: "=", + }, + template: ` + + [[translate()]] + +
    +
  • + +
  • +
`, + compile: function (element) { + // Use the compile function from the RecursionHelper, + // And return the linking function(s) which it returns + return compileRecursive( + $compile, + element, + (scope: FolderTreeInnerScope) => { + scope.safeApply = function (fn) { + const phase = this.$root.$$phase; + if (phase == "$apply" || phase == "$digest") { + if (fn && typeof fn === "function") { + fn(); + } + } else { + this.$apply(fn); + } + }; + scope.canExpendTree = function () { + if (workspaceService.isLazyMode()) { + return ( + scope.folder.children.length > 0 || + (scope.folder as models.Element).cacheChildren.isEmpty + ); + } + return scope.folder.children.length > 0; + }; + scope.isSelectedFolder = function () { + return scope.treeProps.isSelectedFolder(scope.folder); + }; + scope.isOpenedFolder = function () { + return scope.treeProps.isOpenedFolder(scope.folder); + }; + scope.openFolder = async function () { + if (scope.folder instanceof SyncDocument) { + if ( + !scope.folder.children || + scope.folder.children.length === 0 + ) { + const children = await nextcloudService.listDocument( + model.me.userId, + scope.folder.path, + ); + + (scope.folder as SyncDocument).children = children.filter( + (child) => + child.isFolder && + child.path !== "/" && + child.path !== scope.folder.path, + ); + + scope.safeApply(); + } + } else if (workspaceService.isLazyMode()) { + if (scope.folder instanceof models.ElementTree) { + const temp = scope.folder as models.ElementTree; + await workspaceService.fetchChildrenForRoot( + temp, + { filter: temp.filter, hierarchical: false }, + null, + { onlyFolders: true }, + ); + } else { + await workspaceService.fetchChildren( + scope.folder as models.Element, + { filter: "all", hierarchical: false }, + null, + { onlyFolders: true }, + ); + } + } + const ret = scope.treeProps.openFolder(scope.folder); + scope.safeApply(); + return ret; + }; + scope.translate = function () { + return scope.folder.name; + }; + scope.isDisabled = function () { + return scope.treeProps.isDisabled(scope.folder); + }; + }, + ); + }, + }; + }, +]); + +export const folderTree2 = ng.directive("folderTree2", [ + "$templateCache", + ($templateCache) => { + return { + restrict: "E", + scope: { + treeProps: "=", + }, + template: ` + + `, + link: async (scope: FolderTreeScope) => { + scope.trees = function () { + return scope.treeProps ? scope.treeProps.trees : []; + }; + }, + }; + }, +]); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/contentViewer.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/contentViewer.component.ts new file mode 100644 index 0000000000..ef5d2471c8 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/contentViewer.component.ts @@ -0,0 +1,502 @@ +import { AxiosError, AxiosResponse } from "axios"; +import { angular, Me, model, ng, template, workspace } from "entcore"; +import { Subscription } from "rxjs"; +import { ViewMode } from "../../enums/viewMode.enum"; +import { Draggable } from "../../models/nextcloudDraggable.model"; +import { SyncDocument } from "../../models/nextcloudFolder.model"; +import { INextcloudEventService } from "../../services/nextcloudEvent.service"; +import { nextcloudUserService } from "../../services/nextcloudUser.service"; +import { + NextcloudPreference, + Preference, +} from "../../services/nextcloud.preferences"; +import { INextcloudService } from "../../services/nextcloud.service"; +import { safeApply } from "../../utils/safeApply.utils"; +import { UploadFileSnipletViewModel } from "./fileUpload.component"; +import { NextcloudViewIcons } from "./iconView.component"; +import { INextcloudViewList, NextcloudViewList } from "./listView.component"; +import { ToolbarSnipletViewModel } from "./toolbar.component"; +import models = workspace.v2.models; + +declare let window: any; + +const nextcloudTree: string = "nextcloud-folder-tree"; + +export interface IWorkspaceNextcloudContent { + safeApply(): void; + initDraggable(): void; + onSelectContent(document: SyncDocument): void; + onSelectAll(): void; + onOpenContent(document: SyncDocument): void; + getFile(document: SyncDocument): string; + nextcloudUrl: string; + isNextcloudUrlHidden: boolean; + draggable: Draggable; + lockDropzone: boolean; + parentDocument: SyncDocument; + documents: Array; + selectedDocuments: Array; + checkboxSelectAll: boolean; + // drag & drop action + moveDocument(element: any, document: SyncDocument): Promise; + // dropzone + isDropzoneEnabled(): boolean; + canDropOnFolder(): boolean; + onCannotDropFile(): void; + isViewMode(mode: ViewMode): boolean; + changeViewMode(mode: ViewMode): Promise; + + isLoaded: boolean; + viewIcons: NextcloudViewIcons; + viewList: INextcloudViewList; + toolbar: ToolbarSnipletViewModel; + upload: UploadFileSnipletViewModel; + updateTree(): void; + getNextcloudTreeController(): any; + + isTrashMode(): boolean; +} + +export const workspaceNextcloudContentController = ng.controller( + "NextcloudContentController", + [ + "$scope", + "NextcloudService", + "NextcloudEventService", + ( + $scope: IWorkspaceNextcloudContent, + nextcloudService: INextcloudService, + nextcloudEventService: INextcloudEventService, + ) => { + $scope.isLoaded = false; + $scope.documents = []; + $scope.parentDocument = null; + $scope.nextcloudUrl = null; + $scope.selectedDocuments = new Array(); + // fetch nextcloud url hidden state in order to hide or show the nextcloud url + $scope.isNextcloudUrlHidden = false; + + let nextcloudPreference = new Preference(); + let orderDesc: boolean = false; + let orderField: string = null; + let subscription = new Subscription(); + + $scope.getNextcloudTreeController = function () { + return angular.element(document.getElementById(nextcloudTree)).scope(); + }; + + $scope.isTrashMode = function (): boolean { + const treeController = $scope.getNextcloudTreeController(); + return treeController?.isTrashbinOpen; + }; + + nextcloudService + .getIsNextcloudUrlHidden() + .then((isHidden) => ($scope.isNextcloudUrlHidden = isHidden)) + .catch((err: AxiosError) => { + const message: string = + "Error while attempting to fetch nextcloud url hidden state"; + console.error(message + err.message); + $scope.isNextcloudUrlHidden = false; + }); + + // on init we first sync its main folder content + Promise.all([ + initDocumentsContent(nextcloudService, $scope), + nextcloudService.getNextcloudUrl(), + nextcloudPreference.init(), + ]) + .then(([_, url]) => { + $scope.changeViewMode(nextcloudPreference.viewMode); + $scope.nextcloudUrl = url; + $scope.viewList = new NextcloudViewList($scope); + $scope.viewIcons = new NextcloudViewIcons($scope); + $scope.toolbar = new ToolbarSnipletViewModel($scope); + $scope.upload = new UploadFileSnipletViewModel($scope); + $scope.isLoaded = true; + safeApply($scope); + }) + .catch((err: AxiosError) => { + const message: string = + "Error while attempting to init or fetch nextcloud url: "; + console.error(message + err.message); + $scope.isLoaded = true; + safeApply($scope); + }); + + // on receive documents from folder-tree sniplet + subscription.add( + nextcloudEventService + .getDocumentsState() + .subscribe( + (res: { + parentDocument: SyncDocument; + documents: Array; + }) => { + if (res.documents && res.documents.length > 0) { + $scope.parentDocument = res.parentDocument; + + // if we are in trash mode, we do not need to filter documents + if ($scope.isTrashMode()) { + $scope.documents = res.documents.sort(sortDocumentsByFolder); + } else { + $scope.documents = res.documents + .filter( + (syncDocument: SyncDocument) => + syncDocument.name != model.me.userId, + ) + .sort(sortDocumentsByFolder); + orderDesc = false; + orderField = ""; + } + } else { + $scope.parentDocument = res.parentDocument; + $scope.documents = []; + } + $scope.isLoaded = true; + safeApply($scope); + }, + ), + ); + + initDraggable(); + + async function initDocumentsContent( + nextcloudService: INextcloudService, + scope: IWorkspaceNextcloudContent, + ): Promise { + // if we are in trash mode, we do not need to fetch documents + if ($scope.isTrashMode()) { + return; + } + + let selectedFolderFromNextcloudTree: SyncDocument = + $scope.getNextcloudTreeController()["selectedFolder"]; + return nextcloudService + .listDocument(model.me.userId, selectedFolderFromNextcloudTree.path) + .then((documents: Array) => { + // will be called first time while constructor initializing + // since it will syncing at the same time observable will receive its events, we check its length at the end + if (!scope.documents.length) { + scope.documents = documents + .filter( + (syncDocument: SyncDocument) => + syncDocument.path != selectedFolderFromNextcloudTree.path, + ) + .filter( + (syncDocument: SyncDocument) => + syncDocument.name != model.me.userId, + ) + .sort(sortDocumentsByFolder); + scope.parentDocument = new SyncDocument().initParent(); + } + safeApply(scope); + }) + .catch((err: AxiosError) => { + const message: string = + "Error while attempting to fetch documents children from content"; + console.error(message + err.message); + return []; + }); + } + + function updateTree(): void { + const selectedFolderFromNextcloudTree: SyncDocument = + $scope.getNextcloudTreeController()["selectedFolder"]; + updateFolderDocument(selectedFolderFromNextcloudTree); + safeApply($scope); + } + + function initDraggable(): void { + // use this const to make it accessible to its folderTree inner context + const viewModel = $scope; + $scope.draggable = { + dragConditionHandler(event: DragEvent, content?: any): boolean { + return false; + }, + dragDropHandler(event: DragEvent, content?: any): void {}, + async dragEndHandler(event: DragEvent, content?: any): Promise { + await viewModel.moveDocument( + document.elementFromPoint(event.x, event.y), + content, + ); + viewModel.lockDropzone = false; + safeApply($scope); + }, + dragStartHandler(event: DragEvent, content?: any): void { + viewModel.lockDropzone = true; + try { + event.dataTransfer.setData( + "application/json", + JSON.stringify(content), + ); + } catch (e) { + event.dataTransfer.setData("Text", JSON.stringify(content)); + } + nextcloudEventService.setContentContext(content); + }, + dropConditionHandler(event: DragEvent, content?: any): boolean { + return true; + }, + }; + } + + function sortDocumentsByFolder( + syncDocumentA: SyncDocument, + syncDocumentB: SyncDocument, + ): number { + if (syncDocumentA.type === "folder" && syncDocumentB.type === "file") + return -1; + if (syncDocumentA.type === "file" && syncDocumentB.type === "folder") + return 1; + return 0; + } + + $scope.moveDocument = async function ( + element: any, + document: SyncDocument, + ): Promise { + let selectedFolderFromNextcloudTree: SyncDocument = + $scope.getNextcloudTreeController()["selectedFolder"]; + if (!selectedFolderFromNextcloudTree) { + selectedFolderFromNextcloudTree = $scope.parentDocument; + } + let folderContent: any = angular.element(element).scope(); + // if interacted into trees(workspace or nextcloud) + if (folderContent && folderContent.folder) { + processMoveTree( + folderContent, + document, + selectedFolderFromNextcloudTree, + ); + } + if ( + folderContent && + folderContent.content instanceof SyncDocument && + folderContent.content.isFolder + ) { + // if interacted into nextcloud + processMoveToNextcloud( + document, + folderContent.content, + selectedFolderFromNextcloudTree, + ); + } + }; + + function processMoveTree( + folderContent: any, + document: SyncDocument, + selectedFolderFromNextcloudTree: SyncDocument, + ): void { + if (folderContent.folder instanceof models.Element) { + const nextcloudController: any = $scope.getNextcloudTreeController(); + const filesToMove: Set = new Set( + $scope.selectedDocuments, + ).add(document); + const filesPath: Array = Array.from(filesToMove).map( + (file: SyncDocument) => file.path, + ); + if (filesPath.length) { + nextcloudService + .moveDocumentNextcloudToWorkspace( + model.me.userId, + filesPath, + folderContent.folder._id, + ) + .then(() => nextcloudUserService.getUserInfo(model.me.userId)) + .then((userInfos) => { + nextcloudController.userInfo = userInfos; + return nextcloudService.listDocument( + model.me.userId, + selectedFolderFromNextcloudTree.path + ? selectedFolderFromNextcloudTree.path + : null, + ); + }) + .then((syncedDocument: Array) => { + $scope.documents = syncedDocument + .filter( + (syncDocument: SyncDocument) => + syncDocument.path != selectedFolderFromNextcloudTree.path, + ) + .filter( + (syncDocument: SyncDocument) => + syncDocument.name != model.me.userId, + ); + updateFolderDocument(selectedFolderFromNextcloudTree); + safeApply($scope); + }) + .catch((err: AxiosError) => { + const message: string = + "Error while attempting to move nextcloud document to workspace " + + "or update nextcloud list"; + console.error(message + err.message); + }); + } + } else { + processMoveToNextcloud( + document, + folderContent.folder, + selectedFolderFromNextcloudTree, + ); + } + } + + async function moveAllDocuments( + document: SyncDocument, + target: SyncDocument, + ): Promise { + const promises: Array> = []; + $scope.selectedDocuments.push(document); + const selectedSet: Set = new Set( + $scope.selectedDocuments, + ); + selectedSet.forEach((doc: SyncDocument) => { + if (doc.path != target.path) { + promises.push( + nextcloudService.moveDocument( + model.me.userId, + doc.path, + (target.path != null ? target.path : "") + encodeURI(doc.name), + ), + ); + } + }); + return await Promise.all(promises); + } + + function updateDocList( + selectedFolderFromNextcloudTree: SyncDocument, + ): void { + $scope.selectedDocuments = []; + nextcloudService + .listDocument( + model.me.userId, + selectedFolderFromNextcloudTree.path + ? selectedFolderFromNextcloudTree.path + : null, + ) + .then((syncedDocument: Array) => { + $scope.documents = syncedDocument + .filter( + (syncDocument: SyncDocument) => + syncDocument.path != selectedFolderFromNextcloudTree.path, + ) + .filter( + (syncDocument: SyncDocument) => + syncDocument.name != model.me.userId, + ); + updateFolderDocument(selectedFolderFromNextcloudTree); + safeApply($scope); + }) + .catch((err: AxiosError) => { + const message: string = "Error while updating documents list"; + console.error(message + err.message); + }); + } + + function processMoveToNextcloud( + document: SyncDocument, + target: SyncDocument, + selectedFolderFromNextcloudTree: SyncDocument, + ): void { + moveAllDocuments(document, target) + .then(() => updateDocList(selectedFolderFromNextcloudTree)) + .catch((err: AxiosError) => { + updateDocList(selectedFolderFromNextcloudTree); + const message: string = + "Error while attempting to move nextcloud document to workspace " + + "or update nextcloud list"; + console.error(message + err.message); + }); + } + + function updateFolderDocument( + selectedFolderFromNextcloudTree: SyncDocument, + ): void { + nextcloudEventService.setContentContext(null); + nextcloudEventService.sendOpenFolderDocument( + selectedFolderFromNextcloudTree, + ); + } + + $scope.onSelectContent = function (content: SyncDocument): void { + $scope.selectedDocuments = $scope.documents.filter( + (document: SyncDocument) => document.selected, + ); + }; + + $scope.onSelectAll = function (): void { + $scope.checkboxSelectAll = !$scope.checkboxSelectAll; + $scope.documents.map(document => document.selected = $scope.checkboxSelectAll); + + $scope.selectedDocuments = $scope.documents.filter( + (document: SyncDocument) => document.selected, + ); + }; + + $scope.isViewMode = function (mode: ViewMode): boolean { + const pathTemplate = `nextcloud/content/views/${mode}`; + return template.contains("documents-content", pathTemplate); + }; + + $scope.changeViewMode = async function (mode: ViewMode): Promise { + let preference: NextcloudPreference = Me.preferences["nextcloud"]; + preference.viewMode = mode; + await nextcloudPreference.updatePreference(preference); + const pathTemplate = `nextcloud/content/views/${mode}`; + $scope.documents.forEach((document) => (document.selected = false)); + $scope.selectedDocuments = []; + template.open("documents-content", pathTemplate); + + safeApply($scope); + }; + + $scope.onOpenContent = function (document: SyncDocument): void { + if (document.isFolder) { + nextcloudEventService.sendOpenFolderDocument(document); + // reset all selected documents switch we switch folder + $scope.selectedDocuments = []; + } else { + if (document.editable) { + nextcloudService.openNextcloudLink(document, $scope.nextcloudUrl); + } else { + window.open($scope.getFile(document)); + } + } + }; + + $scope.getFile = function (document: SyncDocument): string { + return nextcloudService.getFile( + model.me.userId, + document.name, + document.path, + document.contentType, + ); + }; + + $scope.isDropzoneEnabled = function (): boolean { + return !$scope.lockDropzone; + }; + + $scope.canDropOnFolder = function (): boolean { + return true; + }; + + $scope.onCannotDropFile = function (): void {}; + }, + ], +); + +export const workspaceNextcloudContent = ng.directive( + "workspaceNextcloudContent", + () => { + return { + restrict: "E", + templateUrl: + "/workspace/public/template/nextcloud/content/workspace-nextcloud-content.html", + controller: "NextcloudContentController", + }; + }, +); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/fileUpload.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/fileUpload.component.ts new file mode 100644 index 0000000000..935000ed32 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/fileUpload.component.ts @@ -0,0 +1,140 @@ +import { AxiosError } from "axios"; +import { idiom as lang, model, notify } from "entcore"; +import { SyncDocument } from "../../models/nextcloudFolder.model"; +import { nextcloudUserService } from "../../services/nextcloudUser.service"; +import { nextcloudService } from "../../services/nextcloud.service"; +import { safeApply } from "../../utils/safeApply.utils"; +import { IWorkspaceNextcloudContent } from "./contentViewer.component"; + +declare let window: any; + +interface ILightboxViewModel { + uploadFile: boolean; + importFiles: boolean; +} + +interface IViewModel { + lightbox: ILightboxViewModel; + uploadedDocuments: Array; + files: FileList; + + toggleUploadFilesView(state: boolean): void; + toggleImportFilesView(state: boolean): void; + startImportFlow(): void; + onFilesSelectedFromImport(): void; + onImportFiles(files: FileList): void; + onValidImportFiles(files: FileList): void; + + // document util + getSize(size: number): string; + abortFile(doc: File): void; +} + +export class UploadFileSnipletViewModel implements IViewModel { + private vm: any; + + lightbox: ILightboxViewModel; + uploadedDocuments: Array; + files: null; + + constructor(scope: IWorkspaceNextcloudContent) { + this.vm = scope; + this.lightbox = { + importFiles: false, + uploadFile: false, + }; + this.uploadedDocuments = []; + } + + toggleImportFilesView(state: boolean): void { + this.lightbox.importFiles = state; + this.vm.safeApply(); + } + + startImportFlow(): void { + this.toggleImportFilesView(true); + } + + onFilesSelectedFromImport(): void { + this.toggleImportFilesView(false); + this.onImportFiles(this.files); + } + + toggleUploadFilesView(state: boolean): void { + this.lightbox.uploadFile = state; + if (!state) { + this.uploadedDocuments = []; + this.lightbox.importFiles = false; + } + } + + onImportFiles(files: FileList): void { + this.toggleUploadFilesView(true); + + for (let i = 0; i < files.length; i++) { + this.uploadedDocuments.push(files[i]); + } + } + + onValidImportFiles(): void { + let selectedFolderFromNextcloudTree: SyncDocument = + this.vm.getNextcloudTreeController()["selectedFolder"]; + const nextcloudController: any = this.vm.getNextcloudTreeController(); + nextcloudService + .uploadDocuments( + model.me.userId, + this.uploadedDocuments, + selectedFolderFromNextcloudTree.path, + ) + .then(() => nextcloudUserService.getUserInfo(model.me.userId)) + .then((userInfos) => { + nextcloudController.userInfo = userInfos; + return nextcloudService.listDocument( + model.me.userId, + this.vm.parentDocument.path ? this.vm.parentDocument.path : null, + ); + }) + .then((syncDocuments: Array) => { + this.vm.documents = syncDocuments + .filter( + (syncDocument: SyncDocument) => + syncDocument.path != this.vm.parentDocument.path, + ) + .filter( + (syncDocument: SyncDocument) => + syncDocument.name != model.me.userId, + ); + safeApply(this.vm); + }) + .catch((err: AxiosError) => { + const message: string = "Error while uploading files to nextcloud: "; + console.error( + `${message}${err.message}: ${this.vm.toolbar.getErrorMessage(err)}`, + ); + if (err.message.includes("413") || err.message.includes("507")) { + notify.error(lang.translate("file.too.large.upload")); + } else { + notify.error(lang.translate("nextcloud.fail.upload")); + } + }); + this.toggleUploadFilesView(false); + safeApply(this.vm); + } + + getSize(size: number): string { + const koSize = size / 1024; + if (koSize > 1024) { + return parseInt(String((koSize / 1024) * 10)) / 10 + " Mo"; + } + return Math.ceil(koSize) + " Ko"; + } + + abortFile(doc: File): void { + const index: number = this.uploadedDocuments.indexOf(doc); + this.uploadedDocuments.splice(index, 1); + + if (this.uploadedDocuments.length === 0) { + this.toggleUploadFilesView(false); + } + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/iconView.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/iconView.component.ts new file mode 100644 index 0000000000..ab5e1d604f --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/iconView.component.ts @@ -0,0 +1,11 @@ +interface INextcloudViewIcons {} + +export class NextcloudViewIcons implements INextcloudViewIcons { + private vm: any; + private scope: any; + + constructor(scope) { + this.scope = scope; + this.vm = scope.vm; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/listView.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/listView.component.ts new file mode 100644 index 0000000000..dddce569be --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/listView.component.ts @@ -0,0 +1,66 @@ +import { SyncDocument } from "../../models/nextcloudFolder.model"; +import { DateUtils } from "../../utils/date.utils"; + +export interface INextcloudViewList { + orderByField(fieldName: string, desc?: boolean): void; + isOrderedDesc(fieldName: string): boolean; + isOrderedAsc(fieldName: string): boolean; + displayLastModified(document: SyncDocument): string; + + orderField: string; + orderDesc: boolean; +} + +export class NextcloudViewList implements INextcloudViewList { + private vm: any; + + orderField: string; + orderDesc: boolean; + + constructor(vm) { + this.vm = vm; + this.orderField = null; + this.orderDesc = false; + } + + isOrderedAsc(fieldName: string): boolean { + return this.orderField === fieldName && !this.orderDesc; + } + + isOrderedDesc(fieldName: string): boolean { + return this.orderField === fieldName && this.orderDesc; + } + + orderByField(fieldName: string, desc?: boolean): void { + if (fieldName === this.orderField) { + this.orderDesc = !this.orderDesc; + } else { + this.orderDesc = typeof desc === "boolean" ? desc : false; + } + this.orderField = fieldName; + this.vm.documents = this.vm.documents.sort( + (a: SyncDocument, b: SyncDocument) => { + if (this.orderField === "lastModified") { + return this.orderDesc + ? new Date(a.lastModified).getTime() - + new Date(b.lastModified).getTime() + : new Date(b.lastModified).getTime() - + new Date(a.lastModified).getTime(); + } else if (this.orderField === "size") { + if (a.size > b.size) return this.orderDesc ? 1 : -1; + if (a.size < b.size) return this.orderDesc ? -1 : 1; + } else if (typeof a[fieldName] === "string") { + return this.orderDesc + ? a[fieldName].localeCompare(b[fieldName]) + : b[fieldName].localeCompare(a[fieldName]); + } + return 0; + }, + ); + } + + displayLastModified(document: SyncDocument): string { + if (!document.lastModified) return ""; + return DateUtils.format(document.lastModified, "DD/MM/YYYY HH:mm:ss"); + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbar.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbar.component.ts new file mode 100644 index 0000000000..6e679ee684 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbar.component.ts @@ -0,0 +1,552 @@ +import { AxiosError } from "axios"; +import { + angular, + FolderPickerProps, + FolderPickerSourceFile, + model, + toasts, +} from "entcore"; +import { WorkspaceScope } from "../../../../controller"; +import { models } from "../../../../services"; +import { SyncDocument } from "../../models/nextcloudFolder.model"; +import { INextcloudFolderScope } from "../../nextcloudFolder.directive"; +import { nextcloudUserService } from "../../services/nextcloudUser.service"; +import { nextcloudService } from "../../services/nextcloud.service"; +import { safeApply } from "../../utils/safeApply.utils"; +import { IWorkspaceNextcloudContent } from "./contentViewer.component"; +import { ToolbarShareSnipletViewModel } from "./toolbarShare.components"; + +declare let window: any; + +interface ILightbox { + properties: boolean; + delete: boolean; + share: boolean; + copy: boolean; +} + +export interface IViewModel { + lightbox: ILightbox; + currentDocument: SyncDocument; + + // Document selection + hasOneDocumentSelected(selectedDocuments: Array): boolean; + isSelectedEditable(selectedDocuments: Array): boolean; + + // Actions + downloadFiles(selectedDocuments: Array): void; + openDocument(): void; + editDocument(): void; + + // Properties/Rename + toggleRenameView( + state: boolean, + selectedDocuments?: Array, + ): void; + renameDocument(): void; + + // Delete + toggleDeleteView(state: boolean): void; + deleteDocuments(): void; + restoreDocuments(): void; + + // Copy/Move + toggleCopyView(state: boolean, selectedDocuments?: Array): void; + + // Share + share: any; +} + +/** + * ViewModel for toolbar actions in the Nextcloud content view + */ +export class ToolbarSnipletViewModel implements IViewModel { + private vm: IWorkspaceNextcloudContent; + private treeController: INextcloudFolderScope; + private workspaceScope: WorkspaceScope; + + public lightbox: ILightbox; + public currentDocument: SyncDocument; + + public share: ToolbarShareSnipletViewModel; + + public copyProps: FolderPickerProps; + + constructor(scope: IWorkspaceNextcloudContent) { + this.vm = scope; + this.treeController = this.vm.getNextcloudTreeController(); + + // Get workspace scope for accessing workspace trees + this.workspaceScope = angular + .element(document.querySelector('[data-ng-controller="Workspace"]')) + .scope(); + + // Initialize UI state + this.lightbox = { + properties: false, + delete: false, + share: false, + copy: false, + }; + this.currentDocument = null; + + this.share = new ToolbarShareSnipletViewModel(scope, this.lightbox); + + // Initialize empty copy props + this.copyProps = { + i18: null, + sources: [], + treeProvider: null, + nextcloudTreeProvider: null, + onCancel: () => this.closeCopyView(), + onError: () => this.closeCopyView(), + }; + } + + /*** Selection State ***/ + + public isSelectedEditable(selectedDocuments: Array): boolean { + return selectedDocuments.length > 0 && selectedDocuments[0].editable; + } + + public hasOneDocumentSelected( + selectedDocuments: Array, + ): boolean { + const total: number = selectedDocuments ? selectedDocuments.length : 0; + return total === 1; + } + + /*** Document Actions ***/ + + public openDocument(): void { + if (this.vm.selectedDocuments.length > 0) { + nextcloudService.openNextcloudLink( + this.vm.selectedDocuments[0], + this.vm.nextcloudUrl, + ); + } + } + + public editDocument(): void { + if (this.vm.selectedDocuments.length > 0) { + nextcloudService.openNextcloudEditLink( + this.vm.selectedDocuments[0], + this.vm.nextcloudUrl, + ); + } + } + + public downloadFiles(selectedDocuments: Array): void { + if (selectedDocuments.length === 1) { + this.downloadSingleFile(selectedDocuments[0]); + } else { + this.downloadMultipleFiles(selectedDocuments); + } + } + + private downloadSingleFile(document: SyncDocument): void { + window.open( + nextcloudService.getFile( + model.me.userId, + document.name, + document.path, + document.contentType, + document.isFolder, + ), + ); + } + + private downloadMultipleFiles(documents: Array): void { + const selectedDocumentsName: Array = documents.map( + (doc: SyncDocument) => doc.name, + ); + const getPathParent: string = this.vm.parentDocument.path || "/"; + + window.open( + nextcloudService.getFiles( + model.me.userId, + getPathParent, + selectedDocumentsName, + ), + ); + } + + /*** Rename Operations ***/ + + public toggleRenameView( + state: boolean, + selectedDocuments?: Array, + ): void { + this.lightbox.properties = state; + if (state && selectedDocuments) { + this.currentDocument = Object.assign({}, selectedDocuments[0]); + } else { + this.currentDocument = null; + } + } + + public renameDocument(): void { + const oldDocumentToRename: SyncDocument = this.vm.selectedDocuments[0]; + if (!oldDocumentToRename) return; + + const parentPath = this.vm.parentDocument.path || ""; + const targetDocument: string = + parentPath + "/" + encodeURIComponent(this.currentDocument.name); + + nextcloudService + .moveDocument(model.me.userId, oldDocumentToRename.path, targetDocument) + .then(() => this.refreshDocuments()) + .then(() => { + this.toggleRenameView(false); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }) + .catch((err: AxiosError) => { + this.handleError( + err, + "Error while attempting to rename document from content", + ); + this.toggleRenameView(false); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }); + } + + /*** Delete Operations ***/ + + public toggleDeleteView(state: boolean): void { + this.lightbox.delete = state; + } + + // This method moves documents to the trashbin + public deleteDocuments(): void { + const paths: Array = this.vm.selectedDocuments.map( + (selectedDocument: SyncDocument) => selectedDocument.path, + ); + + nextcloudService + .deleteDocuments(model.me.userId, paths) + .then(() => nextcloudUserService.getUserInfo(model.me.userId)) + .then((userInfos) => { + this.treeController.userInfo = userInfos; + toasts.info("nextcloud.documents.trash.confirmation"); + return this.refreshDocuments(); + }) + .then(() => { + this.toggleDeleteView(false); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }) + .catch((err: AxiosError) => { + this.handleError( + err, + "Error while attempting to delete documents from content", + ); + this.toggleDeleteView(false); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }); + } + + public deleteDocumentsPermanently() { + const paths: Array = this.vm.selectedDocuments.map( + (selectedDocument: SyncDocument) => selectedDocument.path, + ); + + nextcloudService + .deleteTrashDocuments(model.me.userId, paths) + .then(() => nextcloudUserService.getUserInfo(model.me.userId)) + .then((userInfos) => { + this.treeController.userInfo = userInfos; + toasts.info("nextcloud.documents.deletion.confirmation"); + return this.refreshTrashbin(); + }) + .then(() => { + this.toggleDeleteView(false); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }) + .catch((err: AxiosError) => { + this.handleError( + err, + "Error while attempting to delete documents from content", + ); + this.toggleDeleteView(false); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }); + } + + public restoreDocuments(): void { + const paths: Array = this.vm.selectedDocuments.map( + (selectedDocument: SyncDocument) => selectedDocument.path, + ); + + nextcloudService + .restoreDocument(model.me.userId, paths) + .then(() => nextcloudUserService.getUserInfo(model.me.userId)) + .then((userInfos) => { + this.treeController.userInfo = userInfos; + toasts.info("nextcloud.documents.restore.confirmation"); + return this.refreshTrashbin(); + }) + .then(() => { + this.vm.selectedDocuments = []; + safeApply(this.vm); + }) + .catch((err: AxiosError) => { + this.handleError( + err, + "Error while attempting to restore documents from content", + ); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }); + } + + /*** Copy Operations ***/ + public toggleCopyView( + state: boolean, + selectedDocuments?: Array, + ): void { + if (state && selectedDocuments) { + this.setupCopyProps(selectedDocuments, "copy"); + } + this.lightbox.copy = state; + } + + private setupCopyProps( + selectedDocuments: Array, + type: "move" | "copy", + ): void { + this.copyProps = { + i18: { + title: + type === "copy" + ? "workspace.copy.window.title" + : "workspace.move.window.title", + actionTitle: + type === "copy" + ? "workspace.copy.window.action" + : "workspace.move.window.action", + actionProcessing: + type === "copy" ? "workspace.copying" : "workspace.moving", + actionFinished: + type === "copy" + ? "workspace.copy.finished" + : "workspace.move.finished", + info: + type === "copy" + ? "workspace.copy.window.info" + : "workspace.move.window.info", + }, + sources: selectedDocuments.map( + (document: SyncDocument) => + ({ + action: type === "copy" ? "copy-from-file" : "move-from-file", + fileId: document.path, + }) as FolderPickerSourceFile, + ), + treeProvider: async () => { + if (this.workspaceScope && this.workspaceScope.trees) { + // Make sure we're returning an array of trees + const trees = this.workspaceScope.trees.filter( + (tree) => tree.filter === "owner", + ); + return trees; + } + return []; + }, + + // If copying, we don't need to load Nextcloud folders + nextcloudTreeProvider: + type === "copy" + ? null + : async () => { + try { + const rootFolder = new SyncDocument().initParent(); + const documents = await nextcloudService.listDocument( + model.me.userId, + ); + rootFolder.children = documents.filter( + (doc) => doc.isFolder && doc.path !== "/", + ); + return [rootFolder]; + } catch (e) { + console.error("Error loading Nextcloud folders", e); + return []; + } + }, + + submit: (selectedFolder: models.Element | SyncDocument) => { + if (selectedFolder instanceof models.Element) { + this.handleSubmitToWorkspace(selectedFolder, selectedDocuments, type); + } else if (selectedFolder instanceof SyncDocument) { + this.handleSubmitToNextcloud(selectedFolder, selectedDocuments, type); + } + }, + onCancel: () => this.closeCopyView(), + onError: () => this.closeCopyView(), + }; + } + + private async handleSubmitToWorkspace( + destFolder: models.Element, + sourceDocuments: Array, + type: "copy" | "move", + ): Promise { + try { + const paths = sourceDocuments.map((doc) => doc.path); + const parentId = destFolder._id; + + let results; + if (type === "copy") { + // Copy from Nextcloud to workspace + results = await nextcloudService.copyDocumentToWorkspace( + model.me.userId, + paths, + parentId, + ); + } else { + // Move from Nextcloud to workspace + results = await nextcloudService.moveDocumentNextcloudToWorkspace( + model.me.userId, + paths, + parentId, + ); + } + + if (results && results.length > 0) { + // Refresh the workspace folder if we're viewing it + if ( + this.workspaceScope && + this.workspaceScope.openedFolder && + this.workspaceScope.openedFolder.folder && + this.workspaceScope.openedFolder.folder._id === parentId + ) { + this.workspaceScope.reloadFolderContent(); + } + } + + // Clear selection and refresh Nextcloud view + this.vm.selectedDocuments = []; + await this.refreshDocuments(); + + this.closeCopyView(); + this.vm.safeApply(); + } catch (err) { + this.handleError( + err, + `Error ${type === "copy" ? "copying" : "moving"} to workspace`, + ); + toasts.warning(`workspace.${type}.error`); + this.closeCopyView(); + this.vm.safeApply(); + } + } + + /** + * Handle submission when a Nextcloud folder is selected + */ + private async handleSubmitToNextcloud( + destFolder: SyncDocument, + sourceDocuments: Array, + type: "move" | "copy", + ): Promise { + try { + const destPath = destFolder.path || "/"; + + if (type === "move") { + for (const doc of sourceDocuments) { + await nextcloudService.moveDocument( + model.me.userId, + doc.path, + `${destPath}/${doc.name}`, + ); + } + // Refresh views + await this.refreshDocuments(); + } else { + // Implement Nextcloud copy functionality here + } + + this.closeCopyView(); + this.vm.safeApply(); + } catch (err) { + this.handleError( + err, + `Error ${type === "copy" ? "copying" : "moving"} within Nextcloud`, + ); + toasts.warning(`nextcloud.${type}.error`); + this.closeCopyView(); + this.vm.safeApply(); + } + } + + public closeCopyView(): void { + this.lightbox.copy = false; + + if (this.copyProps && this.copyProps.sources) { + this.copyProps.sources = []; + } + + if (this.vm && this.vm.safeApply) { + this.vm.safeApply(); + } + } + + /*** Move Operations ***/ + public toggleMoveView( + state: boolean, + selectedDocuments?: Array, + ): void { + if (state && selectedDocuments) { + this.setupCopyProps(selectedDocuments, "move"); + } + this.lightbox.copy = state; + } + + private async moveSubmit(selectedFolder: SyncDocument): Promise { + this.vm.selectedDocuments.forEach((document) => { + nextcloudService.moveDocument( + model.me.userId, + document.path, + selectedFolder.path + "/" + document.name, + ); + }); + this.vm.selectedDocuments = []; + await this.refreshDocuments(); + this.closeCopyView(); + this.vm.safeApply(); + } + + /*** Utility Methods ***/ + + private async refreshDocuments(): Promise> { + const parentPath = this.vm.parentDocument.path; + const syncDocuments = await nextcloudService.listDocument( + model.me.userId, + parentPath || null, + ); + this.vm.documents = syncDocuments + .filter((doc) => doc.path !== this.vm.parentDocument.path) + .filter((doc_1) => doc_1.name !== model.me.userId); + return syncDocuments; + } + + private async refreshTrashbin(): Promise { + const syncDocuments = await nextcloudService.listTrash(model.me.userId); + this.vm.documents = syncDocuments; + } + + private handleError(err: AxiosError, message: string): void { + console.error( + `${message}: ${err.message}${this.getErrorMessage(err) ? `: ${this.getErrorMessage(err)}` : ""}`, + ); + this.closeCopyView(); + } + + private getErrorMessage(err: AxiosError): string { + return err?.response?.data?.message || ""; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbarShare.components.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbarShare.components.ts new file mode 100644 index 0000000000..a865791ddf --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbarShare.components.ts @@ -0,0 +1,121 @@ +import { setTimeout } from "core-js"; +import { + idiom as lang, + model, + notify, + SharePayload, + template, + workspace, +} from "entcore"; +import { SyncDocument } from "../../models/nextcloudFolder.model"; +import { nextcloudService } from "../../services/nextcloud.service"; +import { WorkspaceEntcoreUtils } from "../../utils/workspaceEntcore.utils"; +import models = workspace.v2.models; + +interface IViewModel { + copyingForShare: boolean; + + sharedElement: Array; + toggleShareView( + state: boolean, + selectedDocuments?: Array, + ): void; + onShareAndNotCopy(): void; + onShareAndCopy(): void; + + onSubmitSharedElements(share: SharePayload): Promise; + onCancelShareElements(): Promise; +} + +export class ToolbarShareSnipletViewModel implements IViewModel { + private vm: any; + private lightbox: any; + + copyingForShare: boolean; + sharedElement: Array; + + constructor(scopeParent: any, lightbox: any) { + this.vm = scopeParent; + this.lightbox = lightbox; + this.sharedElement = []; + } + + toggleShareView( + state: boolean, + selectedDocuments?: Array, + ): void { + this.lightbox.share = state; + if (state && selectedDocuments) { + this.copyingForShare = false; + const pathTemplate: string = `nextcloud/toolbar/share/share-documents-options`; + template.open("workspace-nextcloud-toolbar-share", pathTemplate); + } else { + template.close("workspace-nextcloud-toolbar-share"); + this.copyingForShare = true; + } + } + + onShareAndNotCopy(): void { + const paths: Array = this.vm.selectedDocuments.map( + (document: SyncDocument) => document.path, + ); + this.vm.nextcloudService + .moveDocumentNextcloudToWorkspace(model.me.userId, paths) + .then(async (workspaceDocuments: Array) => { + this.sharedElement = workspaceDocuments; + this.vm.updateTree(); + const pathTemplate: string = `nextcloud/toolbar/share/share`; + this.vm.selectedDocuments = []; + template.open("workspace-nextcloud-toolbar-share", pathTemplate); + try { + this.vm.getNextcloudTreeController().userInfo = await this.vm + .getNextcloudTreeController() + .nextcloudUserService.getUserInfo(model.me.userId); + } catch (e) { + notify.error(lang.translate("error.user.info")); + console.error(e); + } + this.vm.safeApply(); + }); + } + + onShareAndCopy(): void { + const paths: Array = this.vm.selectedDocuments.map( + (document: SyncDocument) => document.path, + ); + nextcloudService + .copyDocumentToWorkspace(model.me.userId, paths) + .then((workspaceDocuments: Array) => { + this.sharedElement = workspaceDocuments; + const pathTemplate: string = `nextcloud/toolbar/share/share`; + template.open("workspace-nextcloud-toolbar-share", pathTemplate); + }); + } + + async onSubmitSharedElements(share: SharePayload): Promise { + this.toggleShareView(false); + setTimeout(() => { + WorkspaceEntcoreUtils.toggleWorkspaceContentDisplay(false); + this.vm.safeApply(); + }, 500); + this.sharedElement = []; + } + + async onCancelShareElements(): Promise { + if (this.sharedElement.length) { + try { + await nextcloudService.moveDocumentWorkspaceToCloud( + model.me.userId, + this.sharedElement.map((doc) => doc._id), + this.vm.parentDocument.path, + ); + this.vm.updateTree(); + this.vm.safeApply(); + } catch (e) { + console.error("Error while canceling share: " + e); + } + this.sharedElement = []; + } + this.toggleShareView(false); + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/emptyTrash.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/emptyTrash.component.ts new file mode 100644 index 0000000000..5f116eb475 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/emptyTrash.component.ts @@ -0,0 +1,46 @@ +import { model } from "entcore"; +import { INextcloudFolderScope } from "../../nextcloudFolder.directive"; +import { nextcloudEventService } from "../../services/nextcloudEvent.service"; +import { nextcloudService } from "../../services/nextcloud.service"; +import { safeApply } from "../../utils/safeApply.utils"; + +interface ILightboxViewModel { + emptyTrash: boolean; +} + +interface IViewModel { + lightbox: ILightboxViewModel; +} + +export class EmptyTrashModel implements IViewModel { + private vm: INextcloudFolderScope; + + lightbox: ILightboxViewModel; + + constructor(scope: INextcloudFolderScope) { + this.vm = scope; + this.lightbox = { + emptyTrash: false, + }; + } + + public emptyTrashbin(): void { + nextcloudService + .deleteTrash(model.me.userId) + .then(() => { + return nextcloudService.listTrash(model.me.userId); + }) + .then((syncDocuments) => { + nextcloudEventService.sendDocuments({ + parentDocument: null, + documents: syncDocuments, + }); + this.lightbox.emptyTrash = false; + safeApply(this.vm); + }) + .catch((err: Error) => { + const message: string = "Error while attempting to empty trashbin "; + console.error(message + err.message); + }); + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/folderManager.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/folderManager.component.ts new file mode 100644 index 0000000000..02acaf6178 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/folderManager.component.ts @@ -0,0 +1,64 @@ +import { AxiosError } from "axios"; +import { model, workspace } from "entcore"; +import { SyncDocument } from "../../models/nextcloudFolder.model"; +import { nextcloudService } from "../../services/nextcloud.service"; +import { nextcloudEventService } from "../../services/nextcloudEvent.service"; +import { safeApply } from "../../utils/safeApply.utils"; +import models = workspace.v2.models; + +interface ILightboxViewModel { + folder: boolean; +} + +interface IViewModel { + lightbox: ILightboxViewModel; + currentDocument: SyncDocument; + toggleCreateFolder(state: boolean, folderCreate: models.Element): void; + createFolder(folderCreate: models.Element): void; +} + +export class FolderCreationModel implements IViewModel { + private vm: any; + private scope: any; + + lightbox: ILightboxViewModel; + currentDocument: SyncDocument; + + constructor(scope) { + this.vm = scope; + this.lightbox = { + folder: false, + }; + this.currentDocument = null; + } + + public toggleCreateFolder( + state: boolean, + folderCreate: models.Element, + ): void { + if (folderCreate) { + folderCreate.name = ""; + } + this.lightbox.folder = state; + } + + public createFolder(folderCreate: models.Element): void { + const folder: SyncDocument = this.vm.selectedFolder; + nextcloudService + .createFolder( + model.me.userId, + (folder.path != null ? folder.path + "/" : "") + + encodeURI(folderCreate.name), + ) + .then(() => { + folderCreate.name = ""; + this.toggleCreateFolder(false, folderCreate); + nextcloudEventService.sendOpenFolderDocument(this.vm.selectedFolder); + safeApply(this.scope); + }) + .catch((err: AxiosError) => { + const message: string = "Error while attempting folder creation."; + console.error(message + err.message); + }); + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/constants/dateFormats.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/constants/dateFormats.ts new file mode 100644 index 0000000000..3c22570037 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/constants/dateFormats.ts @@ -0,0 +1,7 @@ +export const DATE_FORMAT = { + formattedDate: "YYYY-MM-DD", + formattedTime: "HH:mm:ss", +}; + +export const START_DAY_TIME = "00:00:00"; +export const END_DAY_TIME = "23:59:59"; diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/constants/rootPaths.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/constants/rootPaths.ts new file mode 100644 index 0000000000..9b071ce321 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/constants/rootPaths.ts @@ -0,0 +1,4 @@ +export const RootsConst = { + directive: "workspace/public/ts/directives", + template: "workspace/public/template", +}; diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentRole.enum.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentRole.enum.ts new file mode 100644 index 0000000000..20f4292cf8 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentRole.enum.ts @@ -0,0 +1,11 @@ +export enum DocumentRole { + XLS = 'spreadsheet', + PPT = 'presentation', + VIDEO = 'video', + IMG = 'image', + AUDIO = 'audio', + DOC = 'doc', + PDF = 'pdf', + UNKNOWN = 'unknown', + FOLDER = 'folder' +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentsType.enum.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentsType.enum.ts new file mode 100644 index 0000000000..0550a9d9cd --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentsType.enum.ts @@ -0,0 +1,4 @@ +export enum DocumentsType { + FILE = "file", + FOLDER = "folder", +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/enums/viewMode.enum.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/enums/viewMode.enum.ts new file mode 100644 index 0000000000..6d0f5348ae --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/enums/viewMode.enum.ts @@ -0,0 +1,4 @@ +export enum ViewMode { + ICONS = "icons", + LIST = "list", +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudDraggable.model.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudDraggable.model.ts new file mode 100644 index 0000000000..70c74b4a37 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudDraggable.model.ts @@ -0,0 +1,7 @@ +export interface Draggable { + dragStartHandler(event: DragEvent, content?: any): void; + dragDropHandler(event: DragEvent, content?: any): void; + dragConditionHandler(event: DragEvent, content?: any): boolean; + dropConditionHandler(event: DragEvent, content?: any): boolean; + dragEndHandler(event: DragEvent, content?: any): void; +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudFolder.model.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudFolder.model.ts new file mode 100644 index 0000000000..b4bf484a7e --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudFolder.model.ts @@ -0,0 +1,173 @@ +import { idiom as lang, model, workspace } from "entcore"; +import { DocumentRole } from "../enums/documentRole.enum"; +import { DocumentsType } from "../enums/documentsType.enum"; +import { NextcloudDocumentsUtils } from "../utils/nextcloudDocuments.utils"; +import models = workspace.v2.models; + +export interface IDocumentResponse { + path: string; + displayname: string; + ownerDisplayName: string; + contentType: string; + size: number; + favorite: number; + etag: string; + fileId: number; + isFolder: boolean; + lastModified: string; +} + +export class SyncDocument { + path: string; + name: string; + ownerDisplayName: string; + contentType: string; + role: DocumentRole; + size: number; + favorite: number; + editable: boolean; + etag: string; + extension: string; + fileId: number; + isFolder: boolean; + lastModified: string; + type: DocumentsType; + children: Array; + cacheChildren: models.CacheList; + cacheDocument: models.CacheList; + + // custom field bound by other entity/model + selected?: boolean; + isNextcloudParent?: boolean; + + // properties used for static folder + isStaticFolder?: boolean; + staticFolderType?: "trashbin"; + + build(data: IDocumentResponse): SyncDocument { + this.name = decodeURIComponent(data.displayname); + this.ownerDisplayName = data.ownerDisplayName; + this.path = data.path.split(model.me.userId).pop(); + this.contentType = data.contentType; + this.size = data.size; + this.favorite = data.favorite; + this.etag = data.etag; + this.fileId = data.fileId; + this.isFolder = data.isFolder; + this.lastModified = data.lastModified; + this.type = this.determineType(); + this.role = this.determineRole(); + this.editable = this.isEditable(); + this.children = []; + this.cacheChildren = new models.CacheList( + 0, + () => false, + () => false, + ); + this.cacheChildren.setData([]); + this.cacheChildren.disableCache(); + this.cacheDocument = new models.CacheList( + 0, + () => false, + () => false, + ); + this.cacheDocument.setData([]); + this.cacheDocument.disableCache(); + + return this; + } + + determineRole(): DocumentRole { + if (this.isFolder) { + return DocumentRole.FOLDER; + } else if (this.contentType) { + return NextcloudDocumentsUtils.determineRole(this.contentType); + } else { + return DocumentRole.UNKNOWN; + } + } + + determineType(): DocumentsType { + if (this.isFolder) { + return DocumentsType.FOLDER; + } else { + return DocumentsType.FILE; + } + } + + isEditable(): boolean { + return ([ + DocumentRole.DOC, + DocumentRole.PDF, + DocumentRole.XLS, + DocumentRole.PPT, + ]).includes(this.role); + } + + // create a folder with only one content (synchronized document) and its children all sync documents + initParent(): SyncDocument { + const parentNextcloudFolder: SyncDocument = new SyncDocument(); + parentNextcloudFolder.path = null; + parentNextcloudFolder.name = lang.translate("nextcloud.documents"); + parentNextcloudFolder.ownerDisplayName = model.me.login; + parentNextcloudFolder.contentType = null; + parentNextcloudFolder.size = null; + parentNextcloudFolder.favorite = null; + parentNextcloudFolder.etag = null; + parentNextcloudFolder.fileId = null; + parentNextcloudFolder.isFolder = true; + parentNextcloudFolder.lastModified = new Date().toISOString(); + parentNextcloudFolder.children = []; + parentNextcloudFolder.cacheChildren = new models.CacheList( + 0, + () => false, + () => false, + ); + parentNextcloudFolder.cacheChildren.setData([]); + parentNextcloudFolder.cacheChildren.disableCache(); + parentNextcloudFolder.cacheDocument = new models.CacheList( + 0, + () => false, + () => false, + ); + parentNextcloudFolder.cacheDocument.setData([]); + parentNextcloudFolder.cacheDocument.disableCache(); + + parentNextcloudFolder.isNextcloudParent = true; + return parentNextcloudFolder; + } + + // static folders are like the parent folder, but they can have specific name and behavior (e.g. trashbin) + static createStaticFolder(type: "trashbin"): SyncDocument { + const staticFolder = new SyncDocument(); + staticFolder.path = `/__static__/${type}`; + staticFolder.name = lang.translate(`nextcloud.static.${type}`); + staticFolder.ownerDisplayName = model.me.login; + staticFolder.contentType = null; + staticFolder.size = null; + staticFolder.favorite = null; + staticFolder.etag = null; + staticFolder.fileId = null; + staticFolder.isFolder = true; + staticFolder.lastModified = new Date().toISOString(); + staticFolder.children = []; + staticFolder.isStaticFolder = true; + staticFolder.staticFolderType = type; + staticFolder.cacheChildren = new models.CacheList( + 0, + () => false, + () => false, + ); + staticFolder.cacheChildren.setData([]); + staticFolder.cacheChildren.disableCache(); + staticFolder.cacheDocument = new models.CacheList( + 0, + () => false, + () => false, + ); + staticFolder.cacheDocument.setData([]); + staticFolder.cacheDocument.disableCache(); + + return staticFolder; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudUser.model.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudUser.model.ts new file mode 100644 index 0000000000..cd929f9c95 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudUser.model.ts @@ -0,0 +1,54 @@ +export interface IUserResponse { + id: string; + displayname: number; + email: string; + phone: string; + itemsperpage: string; + quota: { + free: string; + used: string; + total: string; + relative: number; + quota: number; + }; +} + +export class UserNextcloud { + id: string; + displayName: number; + email: string; + phone: string; + itemsPerPage: string; + quota: Quota; + + build(data: IUserResponse): UserNextcloud { + this.id = data.id; + this.displayName = data.displayname; + this.email = data.email; + this.phone = data.phone; + this.itemsPerPage = data.itemsperpage; + this.quota = new Quota().build(data.quota); + return this; + } +} + +export class Quota { + used: number; + total: number; + unit: string; + + build(data: any): Quota { + this.used = data.used / (1024 * 1024); + this.total = data.quota / (1024 * 1024); + if (this.total > 2000) { + this.total = Math.round((this.total / 1024) * 100) / 100; + this.used = Math.round((this.used / 1024) * 100) / 100; + this.unit = "Go"; + } else { + this.total = Math.round(this.total); + this.used = Math.round(this.used); + this.unit = "Mo"; + } + return this; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/nextcloudFolder.directive.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/nextcloudFolder.directive.ts new file mode 100644 index 0000000000..2848a336f0 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/nextcloudFolder.directive.ts @@ -0,0 +1,565 @@ +import { + angular, + Document, + FolderTreeProps, + model, + ng, + template, +} from "entcore"; +import { Tree } from "entcore/types/src/ts/workspace/model"; +import { Subscription } from "rxjs"; +import { models } from "../../services"; +import { EmptyTrashModel } from "./components/folder/emptyTrash.component"; +import { FolderCreationModel } from "./components/folder/folderManager.component"; +import { DocumentsType } from "./enums/documentsType.enum"; +import { Draggable } from "./models/nextcloudDraggable.model"; +import { SyncDocument } from "./models/nextcloudFolder.model"; +import { UserNextcloud } from "./models/nextcloudUser.model"; +import { INextcloudEventService } from "./services/nextcloudEvent.service"; +import { INextcloudUserService } from "./services/nextcloudUser.service"; +import { INextcloudService } from "./services/nextcloud.service"; +import { NextcloudDocumentsUtils } from "./utils/nextcloudDocuments.utils"; +import { safeApply } from "./utils/safeApply.utils"; +import { WorkspaceEntcoreUtils } from "./utils/workspaceEntcore.utils"; + +export interface INextcloudFolderScope { + documents: Array; + userInfo: UserNextcloud; + folderTree: FolderTreeProps; + selectedFolder: models.Element; + openedFolder: Array; + droppable: Draggable; + dragOverEventListeners: Map; + dragLeaveEventListeners: Map; + + initTree(folder: Array): void; + watchFolderState(): void; + openDocument(folder: any): Promise; + setSwitchDisplayHandler(): void; + // drag & drop actions + initDraggable(): void; + resolveDragTarget(event: DragEvent): Promise; + removeSelectedDocuments(): void; + addDragEventListeners(): void; + removeDragEventListeners(): void; + addDragOverlays(): void; + removeDragOverlays(): void; + addDragFeedback(): void; + removeDragFeedback(): void; + folderCreation: FolderCreationModel; + + isTrashbinOpen: boolean; + emptyTrashbin: EmptyTrashModel; +} + +export const workspaceNextcloudFolderController = ng.controller( + "NextcloudFolderController", + [ + "$scope", + "NextcloudService", + "NextcloudUserService", + "NextcloudEventService", + ( + $scope: INextcloudFolderScope, + nextcloudService: INextcloudService, + nextcloudUserService: INextcloudUserService, + nextcloudEventService: INextcloudEventService, + ) => { + $scope.userInfo = null; + $scope.documents = []; + $scope.folderTree = {}; + $scope.selectedFolder = null; + $scope.openedFolder = []; + $scope.dragOverEventListeners = new Map(); + $scope.dragLeaveEventListeners = new Map(); + $scope.folderCreation = new FolderCreationModel($scope); + $scope.emptyTrashbin = new EmptyTrashModel($scope); + $scope.isTrashbinOpen = false; + + let subscriptions: Subscription = new Subscription(); + + nextcloudUserService + .getUserInfo(model.me.userId) + .then((nextcloudUserInfo: UserNextcloud) => { + $scope.userInfo = nextcloudUserInfo; + $scope.documents = [new SyncDocument().initParent()]; + $scope.initTree($scope.documents); + $scope.initDraggable(); + safeApply($scope); + }) + .catch((err: Error) => { + const message: string = "Error while attempting to fetch user info"; + console.error(message + err.message); + }); + + // on receive openFolder event + subscriptions.add( + nextcloudEventService + .getOpenedFolderDocument() + .subscribe((document: SyncDocument) => { + let getFolderContext: SyncDocument = $scope.folderTree.trees.find( + (f) => f.fileId === document.fileId, + ); + $scope.folderTree.openFolder( + getFolderContext ? getFolderContext : document, + ); + }), + ); + + $scope.initTree = (folder: Array): void => { + // use this const to make it accessible to its folderTree inner context + const viewModel: INextcloudFolderScope = $scope; + + // move nextcloud tree under workspace tree + const nextcloudElement: HTMLElement = document.querySelector( + '[application="nextcloud"]', + ).parentElement; + if (nextcloudElement) { + nextcloudElement.parentNode.appendChild(nextcloudElement); + } + + // we create all the static folders + const staticFolders: Array = [ + SyncDocument.createStaticFolder("trashbin"), + ]; + + // then we add them to the folder tree + folder.push(...staticFolders); + + $scope.folderTree = { + cssTree: "folders-tree", + get trees(): any | Array { + return folder; + }, + isDisabled(folder: models.Element): boolean { + return false; + }, + isOpenedFolder(folder: models.Element): boolean { + return viewModel.openedFolder.some( + (openFolder: models.Element) => openFolder === folder, + ); + }, + isSelectedFolder(folder: models.Element): boolean { + return viewModel.selectedFolder === folder; + }, + async openFolder(folder: models.Element): Promise { + viewModel.selectedFolder = folder; + viewModel.setSwitchDisplayHandler(); + // create handler in case icon are only clicked + viewModel.watchFolderState(); + + if ( + !viewModel.openedFolder.some( + (openFolder: models.Element) => openFolder === folder, + ) + ) { + viewModel.openedFolder = viewModel.openedFolder.filter( + (e: models.Element) => (e).path != (folder).path, + ); + viewModel.openedFolder.push(folder); + } + + if ((folder).isStaticFolder) { + await viewModel.openDocument(folder); + } else { + // synchronize documents and send content to its other sniplet content + await viewModel.openDocument(folder); + } + + // reset drag feedback by security + viewModel.removeDragFeedback(); + // init drag over + viewModel.addDragFeedback(); + }, + }; + }; + + $scope.initDraggable = (): void => { + const viewModel: INextcloudFolderScope = $scope; + $scope.droppable = { + dragConditionHandler(event: DragEvent, content?: any): boolean { + return false; + }, + async dragDropHandler(event: DragEvent): Promise { + await viewModel.resolveDragTarget(event); + }, + dragEndHandler(event: DragEvent, content?: any): void {}, + dragStartHandler(event: DragEvent, content?: any): void {}, + dropConditionHandler(event: DragEvent, content?: any): boolean { + return false; + }, + }; + }; + + $scope.resolveDragTarget = async (event: DragEvent): Promise => { + // case drop concerns nextcloud + if (nextcloudEventService.getContentContext()) { + //nextcloud context + } else { + // case drop concerns workspace but we need extra check + const document: any = JSON.parse( + event.dataTransfer.getData("application/json"), + ); + // check if it s a workspace document with its identifier and format file to proceed move to nextcloud + if ( + document && + ((document._id && document.eType === DocumentsType.FILE) || + document.eType === DocumentsType.FOLDER) + ) { + if ( + angular.element(event.target).scope().folder instanceof + SyncDocument + ) { + const syncedDocument: SyncDocument = angular + .element(event.target) + .scope().folder; + let selectedDocuments: Array = + WorkspaceEntcoreUtils.workspaceScope()["documentList"][ + "_documents" + ]; + selectedDocuments = selectedDocuments.concat( + WorkspaceEntcoreUtils.workspaceScope()["currentTree"][ + "children" + ], + ); + let documentToUpdate: Set = new Set( + selectedDocuments + .filter((file: Document) => file.selected) + .map((file: Document) => file._id), + ); + documentToUpdate.add(document._id); + nextcloudService + .moveDocumentWorkspaceToCloud( + model.me.userId, + Array.from(documentToUpdate), + syncedDocument.path, + ) + .then((_: any) => { + WorkspaceEntcoreUtils.updateWorkspaceDocuments( + WorkspaceEntcoreUtils.workspaceScope()["openedFolder"][ + "folder" + ], + ); + nextcloudEventService.sendOpenFolderDocument( + angular.element(event.target).scope().folder, + ); + $scope.selectedFolder = null; + angular + .element(event.target) + .scope() + .folder.classList.remove("selected"); + }) + .catch((err: Error) => { + const message: string = + "Error while attempting to fetch documents children "; + console.error(message + err.message); + }); + } + } + } + }; + + $scope.watchFolderState = (): void => { + // Get all folder tree arrow icons using vanilla JS + const folderArrows = document.querySelectorAll( + "#nextcloud-folder-tree i", + ); + + // Remove existing event listeners + folderArrows.forEach((element) => { + element.removeEventListener("click", onClickFolder($scope)); + }); + + // Use this const to make it accessible to its callback + const viewModel: INextcloudFolderScope = $scope; + + // Add new click event listeners to each folder arrow + folderArrows.forEach((element) => { + element.addEventListener("click", onClickFolder(viewModel)); + }); + }; + + $scope.openDocument = async (document: any): Promise => { + if ((document).isStaticFolder) { + const staticType: string = (document).staticFolderType; + let staticDocuments: Array = []; + + switch (staticType) { + case "trashbin": + $scope.isTrashbinOpen = true; + const trashList = await nextcloudService + .listTrash(model.me.userId) + .catch((err: Error) => { + const message: string = "Error while attempting to fetch "; + console.error(message + err.message); + return []; + }); + staticDocuments = trashList; + } + + $scope.documents = staticDocuments; + nextcloudEventService.sendDocuments({ + parentDocument: document, + documents: staticDocuments, + }); + safeApply($scope); + return; + } + + $scope.isTrashbinOpen = false; + + let syncDocuments: Array = await nextcloudService + .listDocument(model.me.userId, document.path ? document.path : null) + .catch((err: Error) => { + const message: string = + "Error while attempting to fetch documents children "; + console.error(message + err.message); + return []; + }); + // first filter applies only when we happen to fetch its own folder and the second applies on document only + document.children = syncDocuments + .filter(NextcloudDocumentsUtils.filterRemoveOwnDocument(document)) + .filter(NextcloudDocumentsUtils.filterDocumentOnly()); + safeApply($scope); + nextcloudEventService.sendDocuments({ + parentDocument: document.path + ? document + : new SyncDocument().initParent(), + documents: syncDocuments.filter( + NextcloudDocumentsUtils.filterRemoveOwnDocument(document), + ), + }); + }; + + $scope.setSwitchDisplayHandler = (): void => { + const viewModel: INextcloudFolderScope = $scope; + + // case nextcloud folder tree is interacted + // checking if listener does not exist in order to create one + const nextcloudFolder = document.querySelector( + "#nextcloud-folder-tree", + ); + if (nextcloudFolder) { + // Remove old event listener if exists + const oldHandler = nextcloudFolder["workspaceNextcloudHandler"]; + if (oldHandler) { + nextcloudFolder.removeEventListener("click", oldHandler); + } + + // Create and store new handler + const newHandler = switchWorkspaceTreeHandler(); + nextcloudFolder["workspaceNextcloudHandler"] = newHandler; + nextcloudFolder.addEventListener("click", newHandler); + } + + // case entcore workspace folder tree is interacted + // we unbind its handler and rebind it in order to keep our list of workspace updated + const workspaceTree = document.querySelector( + WorkspaceEntcoreUtils.$ENTCORE_WORKSPACE, + ); + if (workspaceTree) { + // Remove old event listener if exists + const oldHandler = workspaceTree["nextcloudHandler"]; + if (oldHandler) { + workspaceTree.removeEventListener("click", oldHandler); + } + + // Create and store new handler + const newHandler = switchNextcloudTreeHandler(viewModel); + workspaceTree["nextcloudHandler"] = newHandler; + workspaceTree.addEventListener("click", newHandler); + } + }; + + $scope.removeSelectedDocuments = (): void => { + let selectedDocuments: Array = + WorkspaceEntcoreUtils.workspaceScope()["openedFolder"]["documents"]; + let folders: Array = + WorkspaceEntcoreUtils.workspaceScope()["openedFolder"]["folders"]; + if (selectedDocuments != null && folders != null) { + selectedDocuments.forEach((doc: Document) => (doc.selected = false)); + folders.forEach((fol: Document) => (fol.selected = false)); + } + }; + + $scope.addDragEventListeners = (): void => { + const folders: HTMLElement[] = Array.from( + document.getElementsByTagName("folder-tree-inner"), + ) as HTMLElement[]; + folders.forEach((element: HTMLElement) => { + element.addEventListener("dragover", onDragOver(element)); + element.addEventListener("dragleave", onDragLeave(element)); + + $scope.dragOverEventListeners.set(element, onDragOver(element)); + $scope.dragLeaveEventListeners.set(element, onDragLeave(element)); + }); + }; + + $scope.addDragOverlays = (): void => { + const folders: HTMLElement[] = Array.from( + document.getElementsByTagName("folder-tree-inner"), + ) as HTMLElement[]; + folders.forEach((element: HTMLElement, i: number) => { + const span: HTMLElement = document.createElement("span"); + span.id = "droptarget-" + i; + span.className = "highlight-title highlight-title-border ng-scope"; + const subSpan: HTMLElement = document.createElement("span"); + subSpan.className = "count-badge ng-binding"; + span.appendChild(subSpan); + + const ul: Element = element.lastElementChild; + if (ul.tagName === "UL") { + element.insertBefore(span, ul); + } else { + element.appendChild(span); + } + element.style.position = "relative"; + element.style.display = "block"; + }); + }; + + $scope.removeDragOverlays = (): void => { + const spans: HTMLElement[] = Array.from( + document.querySelectorAll(`[id^="droptarget-"]`), + ) as HTMLElement[]; + spans.forEach((element: HTMLElement) => { + element.remove(); + }); + }; + + $scope.removeDragEventListeners = (): void => { + $scope.dragOverEventListeners.forEach( + (listener: EventListener, element: HTMLElement) => + element.removeEventListener("dragover", onDragOver(element)), + ); + $scope.dragOverEventListeners.clear(); + $scope.dragLeaveEventListeners.forEach( + (listener: EventListener, element: HTMLElement) => + element.removeEventListener("dragleave", onDragLeave(element)), + ); + $scope.dragLeaveEventListeners.clear(); + }; + + $scope.addDragFeedback = (): void => { + $scope.addDragOverlays(); + $scope.addDragEventListeners(); + }; + + $scope.removeDragFeedback = (): void => { + $scope.removeDragOverlays(); + $scope.removeDragEventListeners(); + }; + + function onDragLeave(element: HTMLElement): EventListener { + return function (event: Event): void { + event.preventDefault(); + event.stopPropagation(); + element.firstElementChild.classList.remove("droptarget"); + }; + } + + function onDragOver(element: HTMLElement): EventListener { + return function (event: Event): void { + event.preventDefault(); + event.stopPropagation(); + element.firstElementChild.classList.add("droptarget"); + }; + } + + function switchWorkspaceTreeHandler() { + const viewModel: INextcloudFolderScope = $scope; + return function (): void { + if (!viewModel.selectedFolder) { + viewModel.folderTree.openFolder(viewModel.documents[0]); + } + + const workspaceFolderTree = document.querySelectorAll( + WorkspaceEntcoreUtils.$ENTCORE_WORKSPACE + " li a", + ); + // using nextcloud content display + template.open( + "documents", + `nextcloud/content/workspace-nextcloud-content`, + ); + + viewModel.removeSelectedDocuments(); + + // clear all potential "selected" class workspace folder tree + workspaceFolderTree.forEach((element: Element): void => { + element.classList.remove("selected"); + }); + + // hide workspace contents (search bar, menu, list of folder/files...) interactions + WorkspaceEntcoreUtils.toggleWorkspaceContentDisplay(false); + }; + } + + function switchNextcloudTreeHandler(viewModel: INextcloudFolderScope) { + return function (): void { + let element: Element = arguments[0].target; + let target: Element; + if (element && element.tagName === "A") { + target = element; + } else if ( + element && + element.parentElement && + element.parentElement.tagName === "A" + ) { + target = element.parentElement; + } + + if (target && viewModel.selectedFolder) { + // go back to workspace content display + // clear nextCloudTree interaction + viewModel.selectedFolder = null; + target.classList.add("selected"); + // update workspace folder content + WorkspaceEntcoreUtils.updateWorkspaceDocuments( + angular.element(target).scope().folder, + ); + //set the right openedFolder + WorkspaceEntcoreUtils.workspaceScope()["openedFolder"]["folder"] = + angular.element(target).scope().folder; + // display workspace contents (search bar, menu, list of folder/files...) interactions + WorkspaceEntcoreUtils.toggleWorkspaceContentDisplay(true); + // remove any content context cache + nextcloudEventService.setContentContext(null); + template.open("documents", `icons`); + } + }; + } + + function onClickFolder(viewModel: INextcloudFolderScope) { + return function () { + event.stopPropagation(); + const scope: any = angular.element(arguments[0].target).scope(); + const folder: models.Element = scope.folder; + if ( + viewModel.openedFolder.some( + (openFolder: models.Element) => openFolder === folder, + ) + ) { + viewModel.openedFolder = viewModel.openedFolder.filter( + (openedFolder: models.Element) => openedFolder !== folder, + ); + } else { + viewModel.openedFolder.push(folder); + } + safeApply(scope); + }; + } + }, + ], +); + +export const workspaceNextcloudFolder = ng.directive( + "workspaceNextcloudFolder", + () => { + return { + restrict: "E", + templateUrl: + "/workspace/public/template/nextcloud/folder/workspace-nextcloud-folder.html", + controller: "NextcloudFolderController", + }; + }, +); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.preferences.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.preferences.ts new file mode 100644 index 0000000000..279149e23a --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.preferences.ts @@ -0,0 +1,55 @@ +import { Me, notify } from "entcore"; +import { ViewMode } from "../enums/viewMode.enum"; + +export type NextcloudPreference = { + viewMode: ViewMode; +}; + +export class Preference { + private _viewMode: ViewMode; + + get viewMode(): ViewMode { + return this._viewMode; + } + + set viewMode(viewMode: ViewMode) { + this._viewMode = viewMode; + } + + async init(): Promise { + try { + // fetch nextcloud preference from Me + let preference = await Me.preference("nextcloud"); + // Check on nextcloud preferences AND nextcloud viewMode preferences + if (this.isEmpty(preference) || !preference.viewMode) { + // If no view mode, we set it with default value + preference.viewMode = ViewMode.ICONS; + // persist for the first time my nextcloud preference + await this.updatePreference(preference); + } + this.setProperties(preference); + } catch (e) { + notify.error("nextcloud.preferences.init.error"); + throw e; + } + } + + async updatePreference(preference: NextcloudPreference): Promise { + Me.preferences.nextcloud = preference; + try { + await Me.savePreference("nextcloud"); + this.setProperties(preference); + } catch (e) { + notify.error("nextcloud.preferences.updatepreference.error"); + throw e; + } + } + + private setProperties(preference: NextcloudPreference): void { + this._viewMode = preference.viewMode; + } + + private isEmpty(preference: NextcloudPreference): boolean { + return !preference || !Object.keys(preference).length; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.service.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.service.ts new file mode 100644 index 0000000000..2819adb936 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.service.ts @@ -0,0 +1,307 @@ +import http, { AxiosResponse } from "axios"; +import { ng, workspace } from "entcore"; +import { + IDocumentResponse, + SyncDocument, +} from "../models/nextcloudFolder.model"; +import models = workspace.v2.models; + +export interface INextcloudService { + openNextcloudLink(document: SyncDocument, nextcloudUrl: string): void; + + openNextcloudEditLink(document: SyncDocument, nextcloudUrl: string): void; + + getNextcloudUrl(): Promise; + + getIsNextcloudUrlHidden(): Promise; + + listDocument(userid: string, path?: string): Promise>; + + uploadDocuments( + userid: string, + files: Array, + path?: string, + ): Promise; + + moveDocument( + userid: string, + path: string, + destPath: string, + ): Promise; + + moveDocumentNextcloudToWorkspace( + userid: string, + paths: Array, + parentId?: string, + ): Promise; + + moveDocumentWorkspaceToCloud( + userid: string, + ids: Array, + cloudDocumentName?: string, + ): Promise; + + copyDocumentToWorkspace( + userid: string, + paths: Array, + parentId?: string, + ): Promise>; + + deleteDocuments(userid: string, path: Array): Promise; + + deleteTrashDocuments( + userid: string, + path: Array, + ): Promise; + + restoreDocument(userid: string, paths: Array): Promise; + + deleteTrash(userid: string): Promise; + + getFile( + userid: string, + fileName: string, + path: string, + contentType: string, + isFolder?: boolean, + ): string; + + getFiles(userid: string, path: string, files: Array): string; + + createFolder(userid: string, folderPath: String): Promise; + + listTrash(userid: string): Promise>; +} + +export const nextcloudService: INextcloudService = { + openNextcloudLink: (document: SyncDocument, nextcloudUrl: string): void => { + const url: string = document.path.includes("/") + ? document.path.substring(0, document.path.lastIndexOf("/")) + : ""; + const dir: string = url ? url : "/"; + window.open( + `${nextcloudUrl}/index.php/apps/files?dir=${dir}&openfile=${document.fileId}`, + ); + }, + + openNextcloudEditLink: ( + document: SyncDocument, + nextcloudUrl: string, + ): void => { + const url: string = `${nextcloudUrl}/index.php/apps/onlyoffice/${document.fileId}`; + window.open(url); + }, + + getNextcloudUrl: async (): Promise => { + return http + .get(`/nextcloud/config/url`) + .then((res: AxiosResponse) => res.data.url); + }, + + getIsNextcloudUrlHidden: async (): Promise => { + return http + .get(`/nextcloud/config/isNextcloudUrlHidden`) + .then((res: AxiosResponse) => res.data.isNextcloudUrlHidden); + }, + + createFolder: async ( + userid: string, + folderPath: String, + ): Promise => { + const urlParam: string = folderPath ? `?path=${folderPath}` : ""; + return http.post( + `/nextcloud/files/user/${userid}/create/folder${urlParam}`, + ); + }, + + listDocument: async ( + userid: string, + path?: string, + ): Promise> => { + const urlParam: string = path ? `?path=${path}` : ""; + return http + .get(`/nextcloud/files/user/${userid}${urlParam}`) + .then((res: AxiosResponse) => + res.data.data.map((document: IDocumentResponse) => + new SyncDocument().build(document), + ), + ); + }, + + uploadDocuments( + userid: string, + files: Array, + path?: string, + ): Promise { + const urlParam: string = path ? `?path=${path}` : ""; + const formData: FormData = new FormData(); + const headers = { + headers: { + "Content-type": "multipart/form-data", + "File-Count": files.length, + }, + }; + files.forEach((file) => { + formData.append("fileToUpload[]", file); + }); + // @ts-ignore + return http.put( + `/nextcloud/files/user/${userid}/upload${urlParam}`, + formData, + headers, + ); + }, + + moveDocument: ( + userid: string, + path: string, + destPath: string, + ): Promise => { + const urlParam: string = `?path=${path}&destPath=${destPath}`; + // @ts-ignore + return http.put(`/nextcloud/files/user/${userid}/move${urlParam}`); + }, + + moveDocumentNextcloudToWorkspace: ( + userid: string, + paths: Array, + parentId?: string, + ): Promise => { + let urlParams: URLSearchParams = new URLSearchParams(); + paths.forEach((path: string) => urlParams.append("path", path)); + const parentIdParam: string = parentId ? `&parentId=${parentId}` : ""; + // @ts-ignore + return http + .put( + `/nextcloud/files/user/${userid}/move/workspace?${urlParams}${parentIdParam}`, + ) + .then((res: AxiosResponse) => + res.data.data + .filter((document) => document._id) + .map((document) => new models.Element(document)), + ); + }, + + moveDocumentWorkspaceToCloud: ( + userid: string, + ids: Array, + cloudDocumentName?: string, + ): Promise => { + let urlParams: URLSearchParams = new URLSearchParams(); + ids.forEach((path: string) => urlParams.append("id", path)); + const parentDocumentNameParam: string = cloudDocumentName + ? `&parentName=${cloudDocumentName}` + : ""; + // @ts-ignore + return http.put( + `/nextcloud/files/user/${userid}/workspace/move/cloud?${urlParams}${parentDocumentNameParam}`, + ); + }, + + copyDocumentToWorkspace( + userid: string, + paths: Array, + parentId?: string, + ): Promise> { + let urlParams: URLSearchParams = new URLSearchParams(); + paths.forEach((path: string) => urlParams.append("path", path)); + const parentIdParam: string = parentId ? `&parentId=${parentId}` : ""; + // @ts-ignore + return http + .put( + `/nextcloud/files/user/${userid}/copy/workspace?${urlParams}${parentIdParam}`, + ) + .then((res: AxiosResponse) => + res.data.data + .filter((document) => document._id) + .map((document) => new models.Element(document)), + ); + }, + + deleteDocuments( + userid: string, + paths: Array, + ): Promise { + let urlParams: URLSearchParams = new URLSearchParams(); + paths.forEach((path: string) => { + urlParams.append("path", path); + }); + // @ts-ignore + return http.delete(`/nextcloud/files/user/${userid}/delete?${urlParams}`); + }, + + restoreDocument: ( + userid: string, + paths: Array, + ): Promise => { + let urlParams: URLSearchParams = new URLSearchParams(); + paths.forEach((path: string) => { + urlParams.append("path", path); + }); + // @ts-ignore + return http.put(`/nextcloud/files/user/${userid}/restore?${urlParams}`); + }, + + listTrash: async (userid: string): Promise> => { + return http + .get(`/nextcloud/files/user/${userid}/trash`) + .then((res: AxiosResponse) => + res.data.map((document: IDocumentResponse) => + new SyncDocument().build(document), + ), + ); + }, + + deleteTrash(userid: string): Promise { + // @ts-ignore + return http.delete(`/nextcloud/files/user/${userid}/trash/delete`); + }, + + deleteTrashDocuments( + userid: string, + paths: Array, + ): Promise { + let urlParams: URLSearchParams = new URLSearchParams(); + paths.forEach((path: string) => { + urlParams.append("path", path); + }); + // @ts-ignore + return http.delete( + `/nextcloud/files/user/${userid}/trash/delete-documents?${urlParams}`, + ); + }, + + getFile: ( + userid: string, + fileName: string, + path: string, + contentType: string, + isFolder: boolean = false, + ): string => { + const pathParam: string = path ? `?path=${path}` : ""; + const contentTypeParam: string = + path && contentType ? `&contentType=${contentType}` : ""; + const isFolderParam: string = pathParam + ? `&isFolder=${isFolder}` + : `?isFolder=${isFolder}`; + const urlParam: string = `${pathParam}${contentTypeParam}${isFolderParam}`; + return `/nextcloud/files/user/${userid}/file/${encodeURI( + fileName, + )}/download${urlParam}`; + }, + + getFiles: (userid: string, path: string, files: Array): string => { + const pathParam: string = `?path=${path}`; + let filesParam: string = ""; + files.forEach((file: string) => { + filesParam += `&file=${file}`; + }); + const urlParam: string = `${pathParam}${filesParam}`; + return `/nextcloud/files/user/${userid}/multiple/download${urlParam}`; + }, +}; + +export const NextcloudService = ng.service( + "NextcloudService", + (): INextcloudService => nextcloudService, +); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudEvent.service.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudEvent.service.ts new file mode 100644 index 0000000000..59930b0045 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudEvent.service.ts @@ -0,0 +1,56 @@ +import { ng } from "entcore"; +import { Observable, Subject } from "rxjs"; +import { SyncDocument } from "../models/nextcloudFolder.model"; + +export interface INextcloudEventService { + sendDocuments(documents: { + parentDocument: SyncDocument; + documents: Array; + }): void; + getDocumentsState(): Observable<{ + parentDocument: SyncDocument; + documents: Array; + }>; + sendOpenFolderDocument(document: SyncDocument): void; + getOpenedFolderDocument(): Observable; + getContentContext(): SyncDocument; + setContentContext(content: SyncDocument): void; +} + +const openFolderSubject = new Subject(); +const documentSubject = new Subject<{ + parentDocument: SyncDocument; + documents: Array; +}>(); +let contentContext: SyncDocument = null; + +export const nextcloudEventService: INextcloudEventService = { + sendDocuments: (documents) => { + documentSubject.next(documents); + }, + + getDocumentsState: () => { + return documentSubject.asObservable(); + }, + + sendOpenFolderDocument: (document) => { + openFolderSubject.next(document); + }, + + getOpenedFolderDocument: () => { + return openFolderSubject.asObservable(); + }, + + getContentContext: () => { + return contentContext; + }, + + setContentContext: (content) => { + contentContext = content; + }, +}; + +export const NextcloudEventService = ng.service( + "NextcloudEventService", + (): INextcloudEventService => nextcloudEventService, +); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudUser.service.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudUser.service.ts new file mode 100644 index 0000000000..d73529f410 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudUser.service.ts @@ -0,0 +1,28 @@ +import http, { AxiosResponse } from "axios"; +import { ng } from "entcore"; +import { UserNextcloud } from "../models/nextcloudUser.model"; + +export interface INextcloudUserService { + resolveUser(userid: string): Promise; + + getUserInfo(userid: string): Promise; +} + +export const nextcloudUserService: INextcloudUserService = { + resolveUser: async (userid: string): Promise => { + return http.get(decodeURI(`/nextcloud/user/${userid}/provide/token`)); + }, + + getUserInfo: async (userid: string): Promise => { + return http + .get(`/nextcloud/user/${userid}`) + .then((response: AxiosResponse) => + new UserNextcloud().build(response.data), + ); + }, +}; + +export const NextcloudUserService = ng.service( + "NextcloudUserService", + (): INextcloudUserService => nextcloudUserService, +); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/utils/date.utils.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/date.utils.ts new file mode 100644 index 0000000000..ea685e9823 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/date.utils.ts @@ -0,0 +1,12 @@ +import { moment } from "entcore"; + +export class DateUtils { + /** + * Format date based on given format using moment + * @param date date to format + * @param format format + */ + static format(date: any, format: string) { + return moment(date).format(format); + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/utils/nextcloudDocuments.utils.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/nextcloudDocuments.utils.ts new file mode 100644 index 0000000000..d364d279bf --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/nextcloudDocuments.utils.ts @@ -0,0 +1,40 @@ +import { model } from "entcore"; +import { DocumentRole } from "../enums/documentRole.enum"; +import { SyncDocument } from "../models/nextcloudFolder.model"; + +export class NextcloudDocumentsUtils { + static determineRole(contentType: string): DocumentRole { + for (let role in DocumentRole) { + if (contentType.includes(DocumentRole[role])) { + return DocumentRole[role]; + } + } + return DocumentRole.UNKNOWN; + } + + static filterRemoveNameFile(): (syncDocument: SyncDocument) => boolean { + return (syncDocument: SyncDocument) => + syncDocument.name !== model.me.userId; + } + + static filterDocumentOnly(): (syncDocument: SyncDocument) => boolean { + return (syncDocument: SyncDocument) => + syncDocument.isFolder && syncDocument.name != model.me.userId; + } + + static filterFilesOnly(): (syncDocument: SyncDocument) => boolean { + return (syncDocument: SyncDocument) => + !syncDocument.isFolder && syncDocument.name != model.me.userId; + } + + static filterRemoveOwnDocument( + document: SyncDocument, + ): (syncDocument: SyncDocument) => boolean { + return (syncDocument: SyncDocument) => syncDocument.path !== document.path; + } + + static getExtension(filename: string): string { + let words: Array = filename.split("."); + return words[words.length - 1]; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/utils/safeApply.utils.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/safeApply.utils.ts new file mode 100644 index 0000000000..cb7799f52c --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/safeApply.utils.ts @@ -0,0 +1,6 @@ +export function safeApply($scope: any) { + let phase = $scope.$root.$$phase; + if (phase !== "$apply" && phase !== "$digest") { + $scope.$apply(); + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/utils/workspaceEntcore.utils.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/workspaceEntcore.utils.ts new file mode 100644 index 0000000000..5f01c89eeb --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/workspaceEntcore.utils.ts @@ -0,0 +1,114 @@ +import { angular, Document, model, workspace } from "entcore"; +import { WorkspaceEvent } from "entcore/types/src/ts/workspace/services"; +import { models } from "../../../services"; +import { SyncDocument } from "../models/nextcloudFolder.model"; +import { NextcloudDocumentsUtils } from "./nextcloudDocuments.utils"; +import ng = require("angular"); + +export class WorkspaceEntcoreUtils { + static $ENTCORE_WORKSPACE: string = `div[data-ng-include="'folder-content'"]`; + + /** + * Will fetch Element type component and its div to toggle hide or show depending on state + * Format date based on given format using moment + * @param state boolean determine display default or none + */ + static toggleProgressBarDisplay(state: boolean): void { + const htmlQuery: string = ".mobile-navigation > div.row"; + (document.querySelector(htmlQuery)).style.display = state + ? "block" + : "none"; + } + + /** + * Will fetch all buttons in workspace folder its div to toggle hide or show depending on state + * @param state boolean determine display default or none + */ + static toggleWorkspaceButtonsDisplay(state: boolean): void { + const htmlQuery: string = `.mobile-navigation > a, .zero-mobile > div, sniplet[application="lool"`; + Array.from(document.querySelectorAll(htmlQuery)).forEach( + (elem: Element) => + ((elem).style.display = state ? "block" : "none"), + ); + } + + /** + * Will fetch all buttons in workspace folder its div to toggle hide or show depending on state + * @param state boolean determine display default or none + */ + static toggleWorkspaceContentDisplay(state: boolean): void { + const searchImportViewQuery: string = + "section .margin-four > h3, section .margin-four > nav > div.row"; + Array.from(document.querySelectorAll(searchImportViewQuery)).forEach( + (elem: Element) => + ((elem).style.display = state ? "block" : "none"), + ); + + const contentEmptyScreenQuery: string = + "div .toggle-buttons-spacer .emptyscreen"; + Array.from(document.querySelectorAll(contentEmptyScreenQuery)).forEach( + (elem: Element) => + ((elem).style.display = state ? "flex" : "none"), + ); + + const rightMagnetQuery: string = "app-title.twelve div.right-magnet"; + Array.from(document.querySelectorAll(rightMagnetQuery)).forEach( + (elem: Element) => + ((elem).style.display = state ? "block" : "none"), + ); + } + + /** + * Fetch workspace controller scope + */ + static workspaceScope(): ng.IScope { + return angular + .element(document.getElementsByClassName("workspace-app")) + .scope(); + } + + /** + * Update fetch folder content via workspace controller + * @param folder folder from workspace controller + */ + static updateWorkspaceDocuments(folder: any | models.Element): void { + if (folder && folder instanceof models.Element) { + //The root folder is treated differently because it contains all the tree of files and folders, and we can't apply + // the same treatment as if it was a classic folder. + //As it is not a folder, it does not contain the eType attribute, so we have to simulate it by adding the eType attribute, + // and make the refresh happen on this root folder. + if ("tree" in folder) { + folder.eType = "folder"; + } + const event: WorkspaceEvent = { + action: "tree-change", + elements: [folder], + }; + workspace.v2.service.onChange.next(event); + } + } + + static toDocuments(syncDocuments: Array): Array { + let formattedDocuments: Array = []; + syncDocuments.forEach((syncDoc: SyncDocument) => { + let elementObj: any = { + name: syncDoc.name, + comments: "", + metadata: { + "content-type": syncDoc.contentType, + role: syncDoc.role, + extension: NextcloudDocumentsUtils.getExtension(syncDoc.name), + filename: syncDoc.name, + size: syncDoc.size, + }, + owner: model.me.userId, + ownerName: syncDoc.ownerDisplayName, + path: syncDoc.path, + }; + let newElement: Document = new Document(elementObj); + newElement.application = "nextcloud"; + formattedDocuments.push(newElement); + }); + return formattedDocuments; + } +} diff --git a/workspace/src/main/resources/view-src/workspace.html b/workspace/src/main/resources/view-src/workspace.html index 50e643d935..2a5954fea9 100644 --- a/workspace/src/main/resources/view-src/workspace.html +++ b/workspace/src/main/resources/view-src/workspace.html @@ -16,6 +16,7 @@ var ENABLE_SCRATCH= {{enableScratch}}; var ENABLE_GGB= {{enableGeogebra}}; var ENABLE_NEXTCLOUD= {{enableNextcloud}}; + var USE_NEXTCLOUD_SNIPLET = {{useNextcloudSniplet}}; var LAZY_MODE = {{lazyMode}} var CACHE_DOC_TTL_SEC = {{cacheDocTTl}} var CACHE_FOLDER_TTL_SEC = {{cacheFolderTtl}} @@ -109,10 +110,6 @@

- -
- -
folder.new folder.new.shared @@ -123,6 +120,22 @@

+
+
+ + + + + +
+
From a2ec6bdbc184ad821e665870a1d391d4dfd521bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Vogel?= Date: Mon, 16 Jun 2025 11:19:19 +0200 Subject: [PATCH 06/31] chore(workspace): Update trash bin label --- workspace/src/main/resources/i18n/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspace/src/main/resources/i18n/fr.json b/workspace/src/main/resources/i18n/fr.json index 5f1adf0ff5..4e42567aa1 100644 --- a/workspace/src/main/resources/i18n/fr.json +++ b/workspace/src/main/resources/i18n/fr.json @@ -303,6 +303,6 @@ "nextcloud.console.alert.save": "Les paramètres ont bien été sauvegardés.", "nextcloud.move.error": "Erreur lors du déplacement du fichier", "nextcloud.copy.error": "Erreur lors de la copie du fichier", - "nextcloud.static.trashbin": "Corbeille", + "nextcloud.static.trashbin": "Fichiers supprimés", "nextcloud.documents.restore.confirmation": "Les éléments ont été restaurés." } From 7efe14bacc5e2c339593304c60ff53d5ead56ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Vogel?= Date: Mon, 16 Jun 2025 17:08:55 +0200 Subject: [PATCH 07/31] chore(workspace): Remove share without copy option from toolbar --- .../share/share-documents-options.html | 10 +------- .../content/toolbarShare.components.ts | 25 ------------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share-documents-options.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share-documents-options.html index 270f3c43f8..653f6b392a 100644 --- a/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share-documents-options.html +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share-documents-options.html @@ -10,17 +10,9 @@

+ +
+ + + + +
+
+
+
+
+ + + + + + + + +
+ +
+
+ diff --git a/workspace/src/main/resources/public/template/nextcloud/content/views/viewer.html b/workspace/src/main/resources/public/template/nextcloud/content/views/viewer.html new file mode 100644 index 0000000000..7a7e23d6e0 --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/content/views/viewer.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/workspace/src/main/resources/public/ts/app.ts b/workspace/src/main/resources/public/ts/app.ts index ebd2feadda..82c0c56a21 100644 --- a/workspace/src/main/resources/public/ts/app.ts +++ b/workspace/src/main/resources/public/ts/app.ts @@ -20,6 +20,7 @@ import { NextcloudService } from "./directives/nextcloud/services/nextcloud.serv import { NextcloudEventService } from "./directives/nextcloud/services/nextcloudEvent.service"; import { NextcloudUserService } from "./directives/nextcloud/services/nextcloudUser.service"; import { pdfViewer } from "./directives/pdfViewer"; +import { syncDocumentViewer } from "./directives/syncDocumentViewer"; import { txtViewer } from "./directives/txtViewer"; routes.define(function ($routeProvider) { @@ -53,6 +54,7 @@ routes.define(function ($routeProvider) { ng.controllers.push(workspaceController); ng.directives.push(importFiles); ng.directives.push(fileViewer); +ng.directives.push(syncDocumentViewer); ng.directives.push(pdfViewer); ng.directives.push(cssTransitionEnd); ng.directives.push(dropzoneOverlay); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/contentViewer.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/contentViewer.component.ts index ef5d2471c8..d9814b099c 100644 --- a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/contentViewer.component.ts +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/contentViewer.component.ts @@ -55,6 +55,9 @@ export interface IWorkspaceNextcloudContent { getNextcloudTreeController(): any; isTrashMode(): boolean; + + openDocument(document?: SyncDocument): any; + closeViewFile(): void; } export const workspaceNextcloudContentController = ng.controller( @@ -453,17 +456,27 @@ export const workspaceNextcloudContentController = ng.controller( safeApply($scope); }; + $scope.openDocument = function(document?: SyncDocument): any { + const pathTemplate: string = `nextcloud/content/views/viewer`; + + this.viewFile = document ? document : $scope.selectedDocuments[0]; + template.open("documents-content", pathTemplate); + $scope.selectedDocuments = []; + } + + $scope.closeViewFile = function(): any { + let preference: NextcloudPreference = Me.preferences["nextcloud"]; + this.viewFile = null; + $scope.changeViewMode(preference.viewMode); + } + $scope.onOpenContent = function (document: SyncDocument): void { if (document.isFolder) { nextcloudEventService.sendOpenFolderDocument(document); // reset all selected documents switch we switch folder $scope.selectedDocuments = []; } else { - if (document.editable) { - nextcloudService.openNextcloudLink(document, $scope.nextcloudUrl); - } else { - window.open($scope.getFile(document)); - } + $scope.openDocument(document); } }; diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbar.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbar.component.ts index 6e679ee684..23e279f7ae 100644 --- a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbar.component.ts +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbar.component.ts @@ -36,6 +36,8 @@ export interface IViewModel { // Actions downloadFiles(selectedDocuments: Array): void; openDocument(): void; + viewFile: SyncDocument; + editDocument(): void; // Properties/Rename @@ -67,6 +69,7 @@ export class ToolbarSnipletViewModel implements IViewModel { public lightbox: ILightbox; public currentDocument: SyncDocument; + public viewFile: SyncDocument; public share: ToolbarShareSnipletViewModel; @@ -119,12 +122,8 @@ export class ToolbarSnipletViewModel implements IViewModel { /*** Document Actions ***/ public openDocument(): void { - if (this.vm.selectedDocuments.length > 0) { - nextcloudService.openNextcloudLink( - this.vm.selectedDocuments[0], - this.vm.nextcloudUrl, - ); - } + if (this.vm.selectedDocuments.length === 0) return; + this.vm.openDocument(); } public editDocument(): void { diff --git a/workspace/src/main/resources/public/ts/directives/syncDocumentViewer.ts b/workspace/src/main/resources/public/ts/directives/syncDocumentViewer.ts new file mode 100644 index 0000000000..609107dd87 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/syncDocumentViewer.ts @@ -0,0 +1,358 @@ +import { ng, model } from "entcore"; +import { CsvDelegate, CsvFile, CsvController } from "./csvViewer"; +import { SyncDocument } from "./nextcloud/models/nextcloudFolder.model"; +import { nextcloudService } from "./nextcloud/services/nextcloud.service"; +import { TxtDelegate, TxtController, TxtFile } from "./txtViewer"; +import http from "axios"; + +interface SyncDocumentViewerScope { + contentType: string; + ngModel: SyncDocument; + isFullscreen: boolean; + csvDelegate: CsvDelegate; + txtDelegate: TxtDelegate; + isTxt(): boolean; + isStreamable(): boolean; + download(): void; + isOfficePdf(): boolean; + isOfficeExcelOrCsv(): boolean; + editImage(): void; + fullscreen(allow: boolean): void; + render?: () => void; + previewUrl(): string; + closeViewFile(): void; + canEditInLool(): boolean; + canEditInScratch(): boolean; + openOnLool(): void; + openOnScratch(): void; + openOnGeogebra(): void; + getFileUrl(): string; + canEditImage(): boolean; + hasStreamingCapability(): boolean; + canDownload(): boolean; + $parent: { + display: { + editedImage: any; + editImage: boolean; + }; + }; + onBack: any; + htmlContent: string; + $apply: any; +} + +function getContentTypeFromSyncDocument(syncDoc: SyncDocument): string { + if (!syncDoc.contentType) { + return "unknown"; + } + + const contentType = syncDoc.contentType.toLowerCase(); + const extension = syncDoc.extension || syncDoc.name.split(".").pop()?.toLowerCase(); + + // Map content types to viewer types + if (contentType.startsWith("image/")) return "img"; + if (contentType.startsWith("video/")) return "video"; + if (contentType.startsWith("audio/")) return "audio"; + if (contentType === "application/pdf") return "pdf"; + if (contentType === "text/html") return "html"; + if (contentType === "text/csv" || extension === "csv") return "csv"; + if (contentType === "text/plain" || extension === "txt") return "txt"; + if (contentType === "application/zip") return "zip"; + + // Check by extension for office documents + if (extension) { + switch (extension) { + case "doc": + case "docx": + return "doc"; + case "ppt": + case "pptx": + return "ppt"; + case "xls": + case "xlsx": + return "xls"; + default: + return "unknown"; + } + } + + return "unknown"; +} + +function createElement(html: string): HTMLElement { + const template = document.createElement("template"); + template.innerHTML = html.trim(); + return template.content.firstChild as HTMLElement; +} + +function fadeIn(element: HTMLElement, duration: number = 400): Promise { + return new Promise((resolve) => { + element.style.opacity = "0"; + element.style.display = "block"; + element.style.transition = `opacity ${duration}ms ease-in-out`; + element.offsetHeight; + element.style.opacity = "1"; + + setTimeout(() => { + element.style.transition = ""; + resolve(); + }, duration); + }); +} + +function fadeOut(element: HTMLElement, duration: number = 400): Promise { + return new Promise((resolve) => { + element.style.transition = `opacity ${duration}ms ease-in-out`; + element.style.opacity = "0"; + + setTimeout(() => { + element.remove(); + resolve(); + }, duration); + }); +} + +class SyncCsvProviderFromText implements CsvFile { + private _cache: Promise; + constructor(private model: SyncDocument) {} + get id() { + return this.model.fileId?.toString() || this.model.path; + } + get content() { + if (this._cache) return this._cache; + this._cache = new Promise(async (resolve, reject) => { + try { + const downloadUrl = nextcloudService.getFile( + model.me.userId, + this.model.name, + this.model.path, + this.model.contentType, + false, + ); + const response = await http.get(downloadUrl); + resolve(response.data); + } catch (error) { + reject(error); + } + }); + return this._cache; + } +} + +class SyncCsvProviderFromExcel implements CsvFile { + private _cache: Promise; + constructor(private model: SyncDocument) {} + get id() { + return this.model.fileId?.toString() || this.model.path; + } + get content() { + if (this._cache) return this._cache; + this._cache = new Promise(async (resolve, reject) => { + try { + const downloadUrl = nextcloudService.getFile( + model.me.userId, + this.model.name, + this.model.path, + this.model.contentType, + false, + ); + const response = await http.get(downloadUrl); + resolve(response.data); + } catch (error) { + reject(error); + } + }); + return this._cache; + } +} + +export const syncDocumentViewer = ng.directive("syncDocumentViewer", [ + "$sce", + ($sce) => { + return { + restrict: "E", + scope: { + ngModel: "=", + onBack: "&", + }, + templateUrl: "/workspace/public/template/directives/sync-document-viewer.html", + link: function (scope: SyncDocumentViewerScope, element, attributes) { + const _csvCache: { [key: string]: CsvFile } = {}; + const _txtCache: { [key: string]: TxtFile } = {}; + let _csvController: CsvController = null; + let _txtDelegate: TxtController = null; + + scope.csvDelegate = { + onInit(ctrl) { + _csvController = ctrl; + _csvController.setContent(getCsvContent()); + }, + }; + scope.txtDelegate = { + onInit(ctrl) { + _txtDelegate = ctrl; + _txtDelegate.setContent(getTxtContent()); + }, + }; + + const getCsvContent = () => { + const cacheKey = scope.ngModel.fileId?.toString() || scope.ngModel.path; + if (_csvCache[cacheKey]) { + return _csvCache[cacheKey]; + } + if (scope.contentType == "csv") { + _csvCache[cacheKey] = new SyncCsvProviderFromText(scope.ngModel); + } else { + _csvCache[cacheKey] = new SyncCsvProviderFromExcel(scope.ngModel); + } + return _csvCache[cacheKey]; + }; + + const getTxtContent = () => { + const cacheKey = scope.ngModel.fileId?.toString() || scope.ngModel.path; + if (_txtCache[cacheKey]) { + return _txtCache[cacheKey]; + } + _txtCache[cacheKey] = new SyncCsvProviderFromText(scope.ngModel); + return _txtCache[cacheKey]; + }; + + scope.contentType = getContentTypeFromSyncDocument(scope.ngModel); + + if (scope.contentType == "html") { + const call = async () => { + try { + const downloadUrl = nextcloudService.getFile( + model.me.userId, + scope.ngModel.name, + scope.ngModel.path, + scope.ngModel.contentType, + false, + ); + const response = await http.get(downloadUrl); + scope.htmlContent = $sce.trustAsHtml(response.data) as string; + scope.$apply(); + } catch (error) { + console.error("Error loading HTML content:", error); + } + }; + call(); + } + + scope.isFullscreen = false; + + scope.download = function () { + const downloadUrl = nextcloudService.getFile( + model.me.userId, + scope.ngModel.name, + scope.ngModel.path, + scope.ngModel.contentType, + false, + ); + window.open(downloadUrl, "_blank"); + }; + + let renderElement: HTMLElement; + let renderParent: HTMLElement; + + scope.canDownload = () => { + return true; + }; + + scope.isOfficePdf = () => { + const ext = ["doc", "ppt"]; + return ext.includes(scope.contentType); + }; + + scope.isOfficeExcelOrCsv = () => { + const ext = ["xls", "csv"]; + return ext.includes(scope.contentType); + }; + + scope.isTxt = () => { + const ext = ["txt"]; + return ext.includes(scope.contentType); + }; + + scope.isStreamable = () => { + return scope.contentType === "video" && scope.ngModel.contentType?.startsWith("video/"); + }; + + scope.closeViewFile = () => { + scope.onBack(); + }; + + scope.previewUrl = () => { + return nextcloudService.getFile( + model.me.userId, + scope.ngModel.name, + scope.ngModel.path, + scope.ngModel.contentType, + false, + ); + }; + + scope.getFileUrl = () => { + return nextcloudService.getFile( + model.me.userId, + scope.ngModel.name, + scope.ngModel.path, + scope.ngModel.contentType, + false, + ); + }; + + scope.hasStreamingCapability = () => { + return false; + }; + + scope.fullscreen = (allow) => { + scope.isFullscreen = allow; + if (allow) { + const container = createElement('
'); + container.style.display = "none"; + + container.addEventListener("click", function (e) { + const target = e.target as HTMLElement; + if (!target.classList.contains("render")) { + scope.fullscreen(false); + scope.$apply("isFullscreen"); + } + }); + + const embeddedViewer = element[0].querySelector(".embedded-viewer"); + renderElement = element[0].querySelector(".render") as HTMLElement; + renderParent = renderElement.parentElement; + + embeddedViewer?.classList.add("fullscreen"); + renderElement.classList.add("fullscreen"); + + container.appendChild(renderElement); + document.body.appendChild(container); + + fadeIn(container); + + if (typeof scope.render === "function") { + scope.render(); + } + } else { + renderElement.classList.remove("fullscreen"); + renderParent.appendChild(renderElement); + + const embeddedViewer = element[0].querySelector(".embedded-viewer"); + embeddedViewer?.classList.remove("fullscreen"); + + const fullscreenViewer = document.querySelector(".fullscreen-viewer") as HTMLElement; + if (fullscreenViewer) { + fadeOut(fullscreenViewer); + } + + if (typeof scope.render === "function") { + scope.render(); + } + } + }; + }, + }; + }, +]); From 39e4b545bb29add78d4eea4524fd2f5aef9d369b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Vogel?= Date: Wed, 18 Jun 2025 10:49:37 +0200 Subject: [PATCH 09/31] Fix(workspace): various bugs and inconsistencies - Fix date display in list view - Fix document deletion when moving to trashbin - Fix droptarget staying in folder after droping a file - Change popup message to improve UX --- .../nextcloud/content/views/list.html | 2 +- .../nextcloud/folder/empty-trash.html | 2 +- .../content/contentViewer.component.ts | 31 ++++++++++++++++++- .../nextcloud/nextcloudFolder.directive.ts | 9 ++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/workspace/src/main/resources/public/template/nextcloud/content/views/list.html b/workspace/src/main/resources/public/template/nextcloud/content/views/list.html index 7db4afa80e..14027902e1 100644 --- a/workspace/src/main/resources/public/template/nextcloud/content/views/list.html +++ b/workspace/src/main/resources/public/template/nextcloud/content/views/list.html @@ -76,7 +76,7 @@ [[document.name]] [[document.ownerDisplayName]] - [[vm.viewList.displayLastModified(document)]] + [[viewList.displayLastModified(document)]] [[formatDocumentSize(document.size)]] diff --git a/workspace/src/main/resources/public/template/nextcloud/folder/empty-trash.html b/workspace/src/main/resources/public/template/nextcloud/folder/empty-trash.html index 311325a50b..c123e3ffc3 100644 --- a/workspace/src/main/resources/public/template/nextcloud/folder/empty-trash.html +++ b/workspace/src/main/resources/public/template/nextcloud/folder/empty-trash.html @@ -4,7 +4,7 @@ >

- +