From b1ab3f3bd54ecde13325835b1a81682f3d196482 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 01/52] 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 d7be4a42b3a112eb40ac18e379f17caa09f13534 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 02/52] 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 e0b13c021204aaf3ef1aa7cca348c9564207ad29 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 03/52] 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 --- .../controllers/WorkspaceController.java | 1 + .../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 +- 50 files changed, 4467 insertions(+), 141 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/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/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 98b556065489d76b53f26637bf6216ed281d3441 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 04/52] 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 e961312a3d34032669294cae2fcab1113bd6cfa2 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 06/52] 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 @@ >

- +