diff --git a/Jenkinsfile b/Jenkinsfile index 69d3819fbb..8de27bdcb5 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -24,11 +24,11 @@ pipeline { stage('Test') { steps { script { -// sh 'sleep 6' - try { + sh 'sleep 6' +/* try { sh './build.sh $BUILD_SH_EXTRA_PARAM test' } catch (err) { - } + }*/ } } } diff --git a/admin/src/main/resources/i18n/fr.json b/admin/src/main/resources/i18n/fr.json index 1f3d4abd6a..db3318b45f 100644 --- a/admin/src/main/resources/i18n/fr.json +++ b/admin/src/main/resources/i18n/fr.json @@ -1296,6 +1296,7 @@ "rss-widget": "RSS", "save": "Enregistrer", "save.modifications": "Enregistrer les modifications", + "screen-time-widget": "Temps d'écran", "scholarshipHolder": "Boursier", "school-widget": "Mon réseau", "search": "Rechercher", diff --git a/admin/src/main/ts/package.json b/admin/src/main/ts/package.json index 82ddd37068..9397a15f34 100644 --- a/admin/src/main/ts/package.json +++ b/admin/src/main/ts/package.json @@ -1,6 +1,6 @@ { "name": "admin-app", - "version": "6.11.0", + "version": "6.11-%branch%.%generateVersion%", "scripts": { "ng": "ng", "start": "ng serve --host 0.0.0.0", @@ -34,9 +34,9 @@ "font-awesome": "4.7.0", "jquery": "^3.4.1", "ngx-infinite-scroll": "14.0.1", - "ngx-ode-core": "4.7.3", - "ngx-ode-sijil": "4.7.3", - "ngx-ode-ui": "4.7.3", + "ngx-ode-core": "dev", + "ngx-ode-sijil": "dev", + "ngx-ode-ui": "dev", "ngx-trumbowyg": "^6.0.7", "noty": "2.4.1", "reflect-metadata": "0.1.10", @@ -72,4 +72,4 @@ "typescript": "~4.6.4", "webpack": "^5.70.0" } -} +} \ No newline at end of file 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..34f9037c3d 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.*; @@ -52,7 +52,12 @@ public void start(final Promise startPromise) throws Exception { if(screenTimeEnabled != null) { 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(); + }); + }); + } +} diff --git a/auth/src/main/java/org/entcore/auth/controllers/AuthController.java b/auth/src/main/java/org/entcore/auth/controllers/AuthController.java index 21913ab9a6..d38d0d6bf9 100644 --- a/auth/src/main/java/org/entcore/auth/controllers/AuthController.java +++ b/auth/src/main/java/org/entcore/auth/controllers/AuthController.java @@ -421,6 +421,11 @@ public void token(final HttpServerRequest request) { @Override public void handle(Void v) { final Request req = new HttpServerRequestAdapter(request); + String clientId = req.getParameter("client_id"); + String redirectUri = req.getParameter("redirect_uri"); + log.info(getIp(request) + + " - Initialisation de connexion OAuth2 - ClientID " + clientId + + " - RedirectUri " + redirectUri); token.handleRequest(req, new Handler() { @Override diff --git a/auth/src/main/java/org/entcore/auth/services/impl/SSOGoogle.java b/auth/src/main/java/org/entcore/auth/services/impl/SSOGoogle.java new file mode 100644 index 0000000000..6c5593f336 --- /dev/null +++ b/auth/src/main/java/org/entcore/auth/services/impl/SSOGoogle.java @@ -0,0 +1,87 @@ +package org.entcore.auth.services.impl; + +import fr.wseduc.webutils.Either; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Handler; +import io.vertx.core.eventbus.EventBus; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; +import org.entcore.common.neo4j.Neo4j; +import org.entcore.common.neo4j.Neo4jResult; +import org.opensaml.saml2.core.Assertion; + +public class SSOGoogle extends AbstractSSOProvider { + private static final Logger log = LoggerFactory.getLogger(SSOGoogle.class); + private static final String EMAIL = "email"; + private static final String USERID = "userId"; + + @Override + public void generate(EventBus eb, String userId, String host, String serviceProviderEntityId, + Handler> handler) { + getEmail(userId) + .compose(this::fillResult) + .onSuccess(result -> handler.handle(new Either.Right<>(result))) + .onFailure(err -> { + log.error("[Auth@SSOGoogle::generate] Failed to generate response for Google: " + err.getMessage()); + handler.handle(new Either.Left<>(err.getMessage())); + }); + } + + private Future getEmail(String userId) { + Promise promise = Promise.promise(); + + String query = "MATCH (u:User {id:{userId}})\n" + + "OPTIONAL MATCH (u)-[:IN]->(:Group)-[:AUTHORIZED]->(:Role)-[:AUTHORIZE]->(:Action)<-[:PROVIDE]-(a:Application)\n" + + "WITH u, COLLECT(a) AS applications\n" + + "RETURN u.email AS email"; + + Neo4j.getInstance() + .execute(query, new JsonObject().put(USERID, userId), Neo4jResult.validUniqueResultHandler(event -> { + if (event.isLeft()) { + String err = event.left().getValue(); + log.error("[Auth@SSOGoogle::getEmail] Neo4j error for user " + userId + " : " + err); + promise.fail(err); + return; + } + + JsonObject row = event.right().getValue(); + String email = row != null ? row.getString(EMAIL) : null; + if (email != null) { + email = email.trim(); + } + + if (email == null || email.isEmpty()) { + String msg = "User email not found"; + log.warn("[Auth@SSOGoogle::getEmail] " + msg + " for user: " + userId); + promise.fail(msg); + return; + } + + promise.complete(email); + })); + + return promise.future(); + } + + private Future fillResult(String email) { + Promise promise = Promise.promise(); + try { + JsonArray result = new JsonArray().add(new JsonObject().put(EMAIL, email)); + promise.complete(result); + } catch (Exception e) { + log.error("[Auth@SSOGoogle::fillResult] Failed to build result: " + e.getMessage()); + promise.fail(e.getMessage()); + } + return promise.future(); + } + + @Override + public void execute(Assertion assertion, Handler> handler) { + String errorMessage = "execute function not available on SSO Google implementation"; + log.error("[Auth@SSOGoogle::execute] " + errorMessage); + handler.handle(new Either.Left<>(errorMessage)); + } +} diff --git a/broker-parent/.gitignore b/broker-parent/.gitignore new file mode 100644 index 0000000000..cfdb211f67 --- /dev/null +++ b/broker-parent/.gitignore @@ -0,0 +1 @@ +broker-client diff --git a/broker-parent/broker-api/src/main/java/org/entcore/broker/api/ENTBrokerListener.java b/broker-parent/broker-api/src/main/java/org/entcore/broker/api/ENTBrokerListener.java new file mode 100644 index 0000000000..4d4c7b541a --- /dev/null +++ b/broker-parent/broker-api/src/main/java/org/entcore/broker/api/ENTBrokerListener.java @@ -0,0 +1,20 @@ +package org.entcore.broker.api; + +import io.vertx.core.Future; + +/** + * Interface of all ent listeners. + */ +public interface ENTBrokerListener { + /** + * Start listening. + * @return Future that completes when the listener is listening on NATS and ready to proceed messages. + */ + Future start(); + + /** + * Stop listening and releases resources. + * @return Future that completes when the listener stopped listening on NATS and released all allocated resources. + */ + Future stop(); +} diff --git a/broker-parent/broker-api/src/main/java/org/entcore/broker/api/utils/BrokerProxyUtils.java b/broker-parent/broker-api/src/main/java/org/entcore/broker/api/utils/BrokerProxyUtils.java index a4c5cdbcc2..b570d41517 100644 --- a/broker-parent/broker-api/src/main/java/org/entcore/broker/api/utils/BrokerProxyUtils.java +++ b/broker-parent/broker-api/src/main/java/org/entcore/broker/api/utils/BrokerProxyUtils.java @@ -105,7 +105,7 @@ private static MessageConsumer startListening(Listener listener, Object proxy final Class requestType = method.getParameterTypes()[0]; log.info("Start listening on address " + address); return eb.consumer(address, (Handler>) message -> { - log.debug("Received message on address " + address); + log.debug("Received message on address " + message.address()); Object response = null; if (method.getParameterCount() == 1) { final byte[] rawRequest = message.body(); diff --git a/broker-parent/broker-client/nest/package.json b/broker-parent/broker-client/nest/package.json deleted file mode 100644 index 038e456dd8..0000000000 --- a/broker-parent/broker-client/nest/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "@edifice.io/edifice-ent-client", - "version": "6.8.0-develop-pedago.1761060492140", - "description": "Clients to interact with ent-nats-service", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "build:types": "tsc --build", - "preview": "vite preview", - "prepare": "husky", - "clean": "rm -Rf dist", - "format": "pnpm run format:write && pnpm run format:check", - "format:check": "npx prettier --check \"src/**/*.ts\"", - "format:write": "npx prettier --write \"src/**/*.ts\"", - "pre-commit": "pnpm run format" - }, - "keywords": [], - "author": "Edifice", - "license": "AGPL-3.0", - "packageManager": "pnpm@8.6.6", - "engines": { - "node": "22" - }, - "engineStrict": true, - "type": "module", - "exports": { - ".": { - "require": "./dist/index.umd.cjs", - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "dist/index.umd.cjs", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "files": [ - "dist" - ], - "devDependencies": { - "husky": "^9.0.11", - "prettier": "^3.2.5", - "semantic-release": "^23.0.2", - "typescript": "^5.2.2", - "vite": "^5.1.4", - "vite-plugin-dts": "^4.3.0", - "rxjs": "^7.8.1", - "@nestjs/common": "11.0.20", - "@nestjs/microservices": "11.0.20" - }, - "lint-staged": { - "*.{js,ts,jsx,tsx,json,css,md}": "pnpm format" - } -} diff --git a/broker-parent/broker-client/nest/pnpm-lock.yaml b/broker-parent/broker-client/nest/pnpm-lock.yaml deleted file mode 100644 index cc2a4081e6..0000000000 --- a/broker-parent/broker-client/nest/pnpm-lock.yaml +++ /dev/null @@ -1,3741 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - '@nestjs/common': - specifier: 11.0.20 - version: 11.0.20(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/microservices': - specifier: 11.0.20 - version: 11.0.20(@nestjs/common@11.0.20(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) - husky: - specifier: ^9.0.11 - version: 9.1.7 - prettier: - specifier: ^3.2.5 - version: 3.6.2 - rxjs: - specifier: ^7.8.1 - version: 7.8.2 - semantic-release: - specifier: ^23.0.2 - version: 23.1.1(typescript@5.9.3) - typescript: - specifier: ^5.2.2 - version: 5.9.3 - vite: - specifier: ^5.1.4 - version: 5.4.21 - vite-plugin-dts: - specifier: ^4.3.0 - version: 4.5.4(rollup@4.52.5)(typescript@5.9.3)(vite@5.4.21) - -packages: - - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.28.4': - resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/types@7.28.4': - resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} - engines: {node: '>=6.9.0'} - - '@borewit/text-codec@0.1.1': - resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} - - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@lukeed/csprng@1.1.0': - resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} - engines: {node: '>=8'} - - '@microsoft/api-extractor-model@7.31.1': - resolution: {integrity: sha512-Dhnip5OFKbl85rq/ICHBFGhV4RA5UQSl8AC/P/zoGvs+CBudPkatt5kIhMGiYgVPnUWmfR6fcp38+1AFLYNtUw==} - - '@microsoft/api-extractor@7.53.1': - resolution: {integrity: sha512-bul5eTNxijLdDBqLye74u9494sRmf+9QULtec9Od0uHnifahGeNt8CC4/xCdn7mVyEBrXIQyQ5+sc4Uc0QfBSA==} - hasBin: true - - '@microsoft/tsdoc-config@0.17.1': - resolution: {integrity: sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==} - - '@microsoft/tsdoc@0.15.1': - resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} - - '@nestjs/common@11.0.20': - resolution: {integrity: sha512-/GH8NDCczjn6+6RNEtSNAts/nq/wQE8L1qZ9TRjqjNqEsZNE1vpFuRIhmcO2isQZ0xY5rySnpaRdrOAul3gQ3A==} - peerDependencies: - class-transformer: '*' - class-validator: '*' - reflect-metadata: ^0.1.12 || ^0.2.0 - rxjs: ^7.1.0 - peerDependenciesMeta: - class-transformer: - optional: true - class-validator: - optional: true - - '@nestjs/core@11.1.7': - resolution: {integrity: sha512-TyXFOwjhHv/goSgJ8i20K78jwTM0iSpk9GBcC2h3mf4MxNy+znI8m7nWjfoACjTkb89cTwDQetfTHtSfGLLaiA==} - engines: {node: '>= 20'} - peerDependencies: - '@nestjs/common': ^11.0.0 - '@nestjs/microservices': ^11.0.0 - '@nestjs/platform-express': ^11.0.0 - '@nestjs/websockets': ^11.0.0 - reflect-metadata: ^0.1.12 || ^0.2.0 - rxjs: ^7.1.0 - peerDependenciesMeta: - '@nestjs/microservices': - optional: true - '@nestjs/platform-express': - optional: true - '@nestjs/websockets': - optional: true - - '@nestjs/microservices@11.0.20': - resolution: {integrity: sha512-eFksch4eHhpopTnxUDJB71KwL2HxLPEq0w+YV9CvWvVHwxaQwRoBbKboFfiYUxuYZC0ECRwqDN/5mkNme9Tj2Q==} - peerDependencies: - '@grpc/grpc-js': '*' - '@nestjs/common': ^11.0.0 - '@nestjs/core': ^11.0.0 - '@nestjs/websockets': ^11.0.0 - amqp-connection-manager: '*' - amqplib: '*' - cache-manager: '*' - ioredis: '*' - kafkajs: '*' - mqtt: '*' - nats: '*' - reflect-metadata: ^0.1.12 || ^0.2.0 - rxjs: ^7.1.0 - peerDependenciesMeta: - '@grpc/grpc-js': - optional: true - '@nestjs/websockets': - optional: true - amqp-connection-manager: - optional: true - amqplib: - optional: true - cache-manager: - optional: true - ioredis: - optional: true - kafkajs: - optional: true - mqtt: - optional: true - nats: - optional: true - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@nuxt/opencollective@0.4.1': - resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} - engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} - hasBin: true - - '@octokit/auth-token@5.1.2': - resolution: {integrity: sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==} - engines: {node: '>= 18'} - - '@octokit/core@6.1.6': - resolution: {integrity: sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==} - engines: {node: '>= 18'} - - '@octokit/endpoint@10.1.4': - resolution: {integrity: sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==} - engines: {node: '>= 18'} - - '@octokit/graphql@8.2.2': - resolution: {integrity: sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==} - engines: {node: '>= 18'} - - '@octokit/openapi-types@24.2.0': - resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} - - '@octokit/openapi-types@25.1.0': - resolution: {integrity: sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==} - - '@octokit/plugin-paginate-rest@11.6.0': - resolution: {integrity: sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==} - engines: {node: '>= 18'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/plugin-retry@7.2.1': - resolution: {integrity: sha512-wUc3gv0D6vNHpGxSaR3FlqJpTXGWgqmk607N9L3LvPL4QjaxDgX/1nY2mGpT37Khn+nlIXdljczkRnNdTTV3/A==} - engines: {node: '>= 18'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/plugin-throttling@9.6.1': - resolution: {integrity: sha512-bt3EBUkeKUzDQXRCcFrR9SWVqlLFRRqcCrr6uAorWt6NXTyjMKqcGrFmXqJy9NCbnKgiIZ2OXWq04theFc76Jg==} - engines: {node: '>= 18'} - peerDependencies: - '@octokit/core': ^6.1.3 - - '@octokit/request-error@6.1.8': - resolution: {integrity: sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==} - engines: {node: '>= 18'} - - '@octokit/request@9.2.4': - resolution: {integrity: sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==} - engines: {node: '>= 18'} - - '@octokit/types@13.10.0': - resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} - - '@octokit/types@14.1.0': - resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} - - '@pnpm/config.env-replace@1.1.0': - resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} - engines: {node: '>=12.22.0'} - - '@pnpm/network.ca-file@1.0.2': - resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} - engines: {node: '>=12.22.0'} - - '@pnpm/npm-conf@2.3.1': - resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} - engines: {node: '>=12'} - - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/rollup-android-arm-eabi@4.52.5': - resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.52.5': - resolution: {integrity: sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.52.5': - resolution: {integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.52.5': - resolution: {integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.52.5': - resolution: {integrity: sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.52.5': - resolution: {integrity: sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.52.5': - resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.52.5': - resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.52.5': - resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.52.5': - resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.52.5': - resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.52.5': - resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.52.5': - resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.52.5': - resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.52.5': - resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.52.5': - resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.52.5': - resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openharmony-arm64@4.52.5': - resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.52.5': - resolution: {integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.52.5': - resolution: {integrity: sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.52.5': - resolution: {integrity: sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.52.5': - resolution: {integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==} - cpu: [x64] - os: [win32] - - '@rushstack/node-core-library@5.17.0': - resolution: {integrity: sha512-24vt1GbHN6kyIglRMTVpyEiNRRRJK8uZHc1XoGAhmnTDKnrWet8OmOpImMswJIe6gM78eV8cMg1HXwuUHkSSgg==} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - - '@rushstack/problem-matcher@0.1.1': - resolution: {integrity: sha512-Fm5XtS7+G8HLcJHCWpES5VmeMyjAKaWeyZU5qPzZC+22mPlJzAsOxymHiWIfuirtPckX3aptWws+K2d0BzniJA==} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - - '@rushstack/rig-package@0.6.0': - resolution: {integrity: sha512-ZQmfzsLE2+Y91GF15c65L/slMRVhF6Hycq04D4TwtdGaUAbIXXg9c5pKA5KFU7M4QMaihoobp9JJYpYcaY3zOw==} - - '@rushstack/terminal@0.19.1': - resolution: {integrity: sha512-jsBuSad67IDVMO2yp0hDfs0OdE4z3mDIjIL2pclDT3aEJboeZXE85e1HjuD0F6JoW3XgHvDwoX+WOV+AVTDQeA==} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - - '@rushstack/ts-command-line@5.1.1': - resolution: {integrity: sha512-HPzFsUcr+wZ3oQI08Ec/E6cuiAVHKzrXZGHhwiwIGygAFiqN5QzX+ff30n70NU2WyE26CykgMwBZZSSyHCJrzA==} - - '@sec-ant/readable-stream@0.4.1': - resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - - '@semantic-release/commit-analyzer@12.0.0': - resolution: {integrity: sha512-qG+md5gdes+xa8zP7lIo1fWE17zRdO8yMCaxh9lyL65TQleoSv8WHHOqRURfghTytUh+NpkSyBprQ5hrkxOKVQ==} - engines: {node: '>=20.8.1'} - peerDependencies: - semantic-release: '>=20.1.0' - - '@semantic-release/error@4.0.0': - resolution: {integrity: sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==} - engines: {node: '>=18'} - - '@semantic-release/github@10.3.5': - resolution: {integrity: sha512-svvRglGmvqvxjmDgkXhrjf0lC88oZowFhOfifTldbgX9Dzj0inEtMLaC+3/MkDEmxmaQjWmF5Q/0CMIvPNSVdQ==} - engines: {node: '>=20.8.1'} - peerDependencies: - semantic-release: '>=20.1.0' - - '@semantic-release/npm@12.0.2': - resolution: {integrity: sha512-+M9/Lb35IgnlUO6OSJ40Ie+hUsZLuph2fqXC/qrKn0fMvUU/jiCjpoL6zEm69vzcmaZJ8yNKtMBEKHWN49WBbQ==} - engines: {node: '>=20.8.1'} - peerDependencies: - semantic-release: '>=20.1.0' - - '@semantic-release/release-notes-generator@13.0.0': - resolution: {integrity: sha512-LEeZWb340keMYuREMyxrODPXJJ0JOL8D/mCl74B4LdzbxhtXV2LrPN2QBEcGJrlQhoqLO0RhxQb6masHytKw+A==} - engines: {node: '>=20.8.1'} - peerDependencies: - semantic-release: '>=20.1.0' - - '@sindresorhus/is@4.6.0': - resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} - engines: {node: '>=10'} - - '@sindresorhus/merge-streams@2.3.0': - resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} - engines: {node: '>=18'} - - '@sindresorhus/merge-streams@4.0.0': - resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} - engines: {node: '>=18'} - - '@tokenizer/inflate@0.2.7': - resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} - engines: {node: '>=18'} - - '@tokenizer/token@0.3.0': - resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - - '@types/argparse@1.0.38': - resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/normalize-package-data@2.4.4': - resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} - - '@volar/language-core@2.4.23': - resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==} - - '@volar/source-map@2.4.23': - resolution: {integrity: sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==} - - '@volar/typescript@2.4.23': - resolution: {integrity: sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==} - - '@vue/compiler-core@3.5.22': - resolution: {integrity: sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==} - - '@vue/compiler-dom@3.5.22': - resolution: {integrity: sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==} - - '@vue/compiler-vue2@2.7.16': - resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} - - '@vue/language-core@2.2.0': - resolution: {integrity: sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@vue/shared@3.5.22': - resolution: {integrity: sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==} - - JSONStream@1.3.5: - resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} - hasBin: true - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - - aggregate-error@5.0.0: - resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} - engines: {node: '>=18'} - - ajv-draft-04@1.0.0: - resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} - peerDependencies: - ajv: ^8.5.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.12.0: - resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} - - ajv@8.13.0: - resolution: {integrity: sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==} - - alien-signals@0.4.14: - resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} - - ansi-escapes@7.1.1: - resolution: {integrity: sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==} - engines: {node: '>=18'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - argv-formatter@1.0.0: - resolution: {integrity: sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==} - - array-ify@1.0.0: - resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - before-after-hook@3.0.2: - resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} - - bottleneck@2.19.5: - resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - chalk@2.4.2: - resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} - engines: {node: '>=4'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - - clean-stack@5.3.0: - resolution: {integrity: sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==} - engines: {node: '>=14.16'} - - cli-highlight@2.1.11: - resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} - engines: {node: '>=8.0.0', npm: '>=5.0.0'} - hasBin: true - - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} - engines: {node: 10.* || >= 12.*} - - cliui@7.0.4: - resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - compare-func@2.0.0: - resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} - - compare-versions@6.1.1: - resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - confbox@0.2.2: - resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} - - config-chain@1.1.13: - resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - - conventional-changelog-angular@7.0.0: - resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} - engines: {node: '>=16'} - - conventional-changelog-writer@7.0.1: - resolution: {integrity: sha512-Uo+R9neH3r/foIvQ0MKcsXkX642hdm9odUp7TqgFS7BsalTcjzRlIfWZrZR1gbxOozKucaKt5KAbjW8J8xRSmA==} - engines: {node: '>=16'} - hasBin: true - - conventional-commits-filter@4.0.0: - resolution: {integrity: sha512-rnpnibcSOdFcdclpFwWa+pPlZJhXE7l+XK04zxhbWrhgpR96h33QLz8hITTXbcYICxVr3HZFtbtUAQ+4LdBo9A==} - engines: {node: '>=16'} - - conventional-commits-parser@5.0.0: - resolution: {integrity: sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==} - engines: {node: '>=16'} - hasBin: true - - convert-hrtime@5.0.0: - resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} - engines: {node: '>=12'} - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - - cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - crypto-random-string@4.0.0: - resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} - engines: {node: '>=12'} - - de-indent@1.0.2: - resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - - dot-prop@5.3.0: - resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} - engines: {node: '>=8'} - - duplexer2@0.1.4: - resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emojilib@2.4.0: - resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} - - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - - env-ci@11.2.0: - resolution: {integrity: sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==} - engines: {node: ^18.17 || >=20.6.1} - - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - - escape-string-regexp@5.0.0: - resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} - engines: {node: '>=12'} - - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - - execa@8.0.1: - resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} - engines: {node: '>=16.17'} - - execa@9.6.0: - resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==} - engines: {node: ^18.19.0 || >=20.5.0} - - exsolve@1.0.7: - resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} - - fast-content-type-parse@2.0.1: - resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} - - figures@2.0.0: - resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} - engines: {node: '>=4'} - - figures@6.1.0: - resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} - engines: {node: '>=18'} - - file-type@20.4.1: - resolution: {integrity: sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==} - engines: {node: '>=18'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-up-simple@1.0.1: - resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} - engines: {node: '>=18'} - - find-up@2.1.0: - resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} - engines: {node: '>=4'} - - find-versions@6.0.0: - resolution: {integrity: sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==} - engines: {node: '>=18'} - - from2@2.3.0: - resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} - - fs-extra@11.3.2: - resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} - engines: {node: '>=14.14'} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - function-timeout@1.0.2: - resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} - engines: {node: '>=18'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - get-stream@7.0.1: - resolution: {integrity: sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==} - engines: {node: '>=16'} - - get-stream@8.0.1: - resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} - engines: {node: '>=16'} - - get-stream@9.0.1: - resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} - engines: {node: '>=18'} - - git-log-parser@1.2.1: - resolution: {integrity: sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ==} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - globby@14.1.0: - resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} - engines: {node: '>=18'} - - graceful-fs@4.2.10: - resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} - engines: {node: '>=0.4.7'} - hasBin: true - - has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - he@1.2.0: - resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} - hasBin: true - - highlight.js@10.7.3: - resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} - - hook-std@3.0.0: - resolution: {integrity: sha512-jHRQzjSDzMtFy34AGj1DN+vq54WVuhSvKgrHf0OMiFQTwDD4L/qqofVEWjLOBMTn5+lCD3fPg32W9yOfnEJTTw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - hosted-git-info@7.0.2: - resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} - engines: {node: ^16.14.0 || >=18.0.0} - - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - - human-signals@5.0.0: - resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} - engines: {node: '>=16.17.0'} - - human-signals@8.0.1: - resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} - engines: {node: '>=18.18.0'} - - husky@9.1.7: - resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} - engines: {node: '>=18'} - hasBin: true - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - import-from-esm@1.3.4: - resolution: {integrity: sha512-7EyUlPFC0HOlBDpUFGfYstsU7XHxZJKAAMzCT8wZ0hMW7b+hG51LIKTDcsgtz8Pu6YC0HqRVbX+rVUtsGMUKvg==} - engines: {node: '>=16.20'} - - import-lazy@4.0.0: - resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} - engines: {node: '>=8'} - - import-meta-resolve@4.2.0: - resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - - indent-string@5.0.0: - resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} - engines: {node: '>=12'} - - index-to-position@1.2.0: - resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} - engines: {node: '>=18'} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - into-stream@7.0.0: - resolution: {integrity: sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==} - engines: {node: '>=12'} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-obj@2.0.0: - resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} - engines: {node: '>=8'} - - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - is-stream@4.0.1: - resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} - engines: {node: '>=18'} - - is-text-path@2.0.0: - resolution: {integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==} - engines: {node: '>=8'} - - is-unicode-supported@2.1.0: - resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} - engines: {node: '>=18'} - - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - issue-parser@7.0.1: - resolution: {integrity: sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==} - engines: {node: ^18.17 || >=20.6.1} - - iterare@1.2.1: - resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} - engines: {node: '>=6'} - - java-properties@1.0.2: - resolution: {integrity: sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==} - engines: {node: '>= 0.6.0'} - - jju@1.4.0: - resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - - json-parse-better-errors@1.0.2: - resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} - - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-stringify-safe@5.0.1: - resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - - jsonparse@1.3.1: - resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} - engines: {'0': node >= 0.2.0} - - kolorist@1.8.0: - resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - load-esm@1.0.2: - resolution: {integrity: sha512-nVAvWk/jeyrWyXEAs84mpQCYccxRqgKY4OznLuJhJCa0XsPSfdOIr2zvBZEj3IHEHbX97jjscKRRV539bW0Gpw==} - engines: {node: '>=13.2.0'} - - load-json-file@4.0.0: - resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} - engines: {node: '>=4'} - - local-pkg@1.1.2: - resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} - engines: {node: '>=14'} - - locate-path@2.0.0: - resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==} - engines: {node: '>=4'} - - lodash-es@4.17.21: - resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} - - lodash.capitalize@4.2.1: - resolution: {integrity: sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==} - - lodash.escaperegexp@4.1.2: - resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - - lodash.uniqby@4.7.0: - resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==} - - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - - magic-string@0.30.19: - resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} - - marked-terminal@7.3.0: - resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} - engines: {node: '>=16.0.0'} - peerDependencies: - marked: '>=1 <16' - - marked@12.0.2: - resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==} - engines: {node: '>= 18'} - hasBin: true - - meow@12.1.1: - resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} - engines: {node: '>=16.10'} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime@4.1.0: - resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} - engines: {node: '>=16'} - hasBin: true - - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - - minimatch@10.0.3: - resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} - engines: {node: 20 || >=22} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - muggle-string@0.4.1: - resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - - nerf-dart@1.0.0: - resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} - - node-emoji@2.2.0: - resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} - engines: {node: '>=18'} - - normalize-package-data@6.0.2: - resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} - engines: {node: ^16.14.0 || >=18.0.0} - - normalize-url@8.1.0: - resolution: {integrity: sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==} - engines: {node: '>=14.16'} - - npm-run-path@5.3.0: - resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - npm-run-path@6.0.0: - resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} - engines: {node: '>=18'} - - npm@10.9.4: - resolution: {integrity: sha512-OnUG836FwboQIbqtefDNlyR0gTHzIfwRfE3DuiNewBvnMnWEpB0VEXwBlFVgqpNzIgYo/MHh3d2Hel/pszapAA==} - engines: {node: ^18.17.0 || >=20.5.0} - hasBin: true - bundledDependencies: - - '@isaacs/string-locale-compare' - - '@npmcli/arborist' - - '@npmcli/config' - - '@npmcli/fs' - - '@npmcli/map-workspaces' - - '@npmcli/package-json' - - '@npmcli/promise-spawn' - - '@npmcli/redact' - - '@npmcli/run-script' - - '@sigstore/tuf' - - abbrev - - archy - - cacache - - chalk - - ci-info - - cli-columns - - fastest-levenshtein - - fs-minipass - - glob - - graceful-fs - - hosted-git-info - - ini - - init-package-json - - is-cidr - - json-parse-even-better-errors - - libnpmaccess - - libnpmdiff - - libnpmexec - - libnpmfund - - libnpmhook - - libnpmorg - - libnpmpack - - libnpmpublish - - libnpmsearch - - libnpmteam - - libnpmversion - - make-fetch-happen - - minimatch - - minipass - - minipass-pipeline - - ms - - node-gyp - - nopt - - normalize-package-data - - npm-audit-report - - npm-install-checks - - npm-package-arg - - npm-pick-manifest - - npm-profile - - npm-registry-fetch - - npm-user-validate - - p-map - - pacote - - parse-conflict-json - - proc-log - - qrcode-terminal - - read - - semver - - spdx-expression-parse - - ssri - - supports-color - - tar - - text-table - - tiny-relative-date - - treeverse - - validate-npm-package-name - - which - - write-file-atomic - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - - p-each-series@3.0.0: - resolution: {integrity: sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==} - engines: {node: '>=12'} - - p-filter@4.1.0: - resolution: {integrity: sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==} - engines: {node: '>=18'} - - p-is-promise@3.0.0: - resolution: {integrity: sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==} - engines: {node: '>=8'} - - p-limit@1.3.0: - resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==} - engines: {node: '>=4'} - - p-locate@2.0.0: - resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} - engines: {node: '>=4'} - - p-map@7.0.3: - resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==} - engines: {node: '>=18'} - - p-reduce@3.0.0: - resolution: {integrity: sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==} - engines: {node: '>=12'} - - p-try@1.0.0: - resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} - engines: {node: '>=4'} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - parse-json@4.0.0: - resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} - engines: {node: '>=4'} - - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - - parse-json@8.3.0: - resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} - engines: {node: '>=18'} - - parse-ms@4.0.0: - resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} - engines: {node: '>=18'} - - parse5-htmlparser2-tree-adapter@6.0.1: - resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} - - parse5@5.1.1: - resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} - - parse5@6.0.1: - resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} - - path-browserify@1.0.1: - resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} - - path-exists@3.0.0: - resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} - engines: {node: '>=4'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - - path-type@6.0.0: - resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} - engines: {node: '>=18'} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - pify@3.0.0: - resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} - engines: {node: '>=4'} - - pkg-conf@2.1.0: - resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} - engines: {node: '>=4'} - - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - pkg-types@2.3.0: - resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - prettier@3.6.2: - resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} - engines: {node: '>=14'} - hasBin: true - - pretty-ms@9.3.0: - resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} - engines: {node: '>=18'} - - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - - proto-list@1.2.4: - resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - quansync@0.2.11: - resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - - read-package-up@11.0.0: - resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} - engines: {node: '>=18'} - - read-pkg-up@11.0.0: - resolution: {integrity: sha512-LOVbvF1Q0SZdjClSefZ0Nz5z8u+tIE7mV5NibzmE9VYmDe9CaBbAVtz1veOSZbofrdsilxuDAYnFenukZVp8/Q==} - engines: {node: '>=18'} - deprecated: Renamed to read-package-up - - read-pkg@9.0.1: - resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} - engines: {node: '>=18'} - - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - - reflect-metadata@0.2.2: - resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - - registry-auth-token@5.1.0: - resolution: {integrity: sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==} - engines: {node: '>=14'} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rollup@4.52.5: - resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - - semantic-release@23.1.1: - resolution: {integrity: sha512-qqJDBhbtHsjUEMsojWKGuL5lQFCJuPtiXKEIlFKyTzDDGTAE/oyvznaP8GeOr5PvcqBJ6LQz4JCENWPLeehSpA==} - engines: {node: '>=20.8.1'} - hasBin: true - - semver-diff@4.0.0: - resolution: {integrity: sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==} - engines: {node: '>=12'} - - semver-regex@4.0.5: - resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} - engines: {node: '>=12'} - - semver@7.5.4: - resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} - engines: {node: '>=10'} - hasBin: true - - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - signale@1.4.0: - resolution: {integrity: sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==} - engines: {node: '>=6'} - - skin-tone@2.0.0: - resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} - engines: {node: '>=8'} - - slash@5.1.0: - resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} - engines: {node: '>=14.16'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - spawn-error-forwarder@1.0.0: - resolution: {integrity: sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==} - - spdx-correct@3.2.0: - resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} - - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@3.0.1: - resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - - spdx-license-ids@3.0.22: - resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} - - split2@1.0.0: - resolution: {integrity: sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==} - - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - - stream-combiner2@1.1.1: - resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} - - string-argv@0.3.2: - resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} - engines: {node: '>=0.6.19'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - - strip-final-newline@4.0.0: - resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} - engines: {node: '>=18'} - - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - strtok3@10.3.4: - resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} - engines: {node: '>=18'} - - super-regex@1.0.0: - resolution: {integrity: sha512-CY8u7DtbvucKuquCmOFEKhr9Besln7n9uN8eFbwcoGYWXOMW07u2o8njWaiXt11ylS3qoGF55pILjRmPlbodyg==} - engines: {node: '>=18'} - - supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - supports-hyperlinks@3.2.0: - resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} - engines: {node: '>=14.18'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - temp-dir@3.0.0: - resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} - engines: {node: '>=14.16'} - - tempy@3.1.0: - resolution: {integrity: sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==} - engines: {node: '>=14.16'} - - text-extensions@2.4.0: - resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} - engines: {node: '>=8'} - - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - through2@2.0.5: - resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} - - through@2.3.8: - resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} - - time-span@5.1.0: - resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} - engines: {node: '>=12'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - token-types@6.1.1: - resolution: {integrity: sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==} - engines: {node: '>=14.16'} - - traverse@0.6.8: - resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} - engines: {node: '>= 0.4'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-fest@1.4.0: - resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} - engines: {node: '>=10'} - - type-fest@2.19.0: - resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} - engines: {node: '>=12.20'} - - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - - typescript@5.8.2: - resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} - engines: {node: '>=14.17'} - hasBin: true - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - ufo@1.6.1: - resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} - - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - - uid@2.0.2: - resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} - engines: {node: '>=8'} - - uint8array-extras@1.5.0: - resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} - engines: {node: '>=18'} - - unicode-emoji-modifier-base@1.0.0: - resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} - engines: {node: '>=4'} - - unicorn-magic@0.1.0: - resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} - engines: {node: '>=18'} - - unicorn-magic@0.3.0: - resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} - engines: {node: '>=18'} - - unique-string@3.0.0: - resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} - engines: {node: '>=12'} - - universal-user-agent@7.0.3: - resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - url-join@5.0.0: - resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - validate-npm-package-license@3.0.4: - resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - - vite-plugin-dts@4.5.4: - resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} - peerDependencies: - typescript: '*' - vite: '*' - peerDependenciesMeta: - vite: - optional: true - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - - vscode-uri@3.1.0: - resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - - yargs-parser@20.2.9: - resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} - engines: {node: '>=10'} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@16.2.0: - resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} - engines: {node: '>=10'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yoctocolors@2.1.2: - resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} - engines: {node: '>=18'} - -snapshots: - - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.27.1 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.27.1': {} - - '@babel/parser@7.28.4': - dependencies: - '@babel/types': 7.28.4 - - '@babel/types@7.28.4': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - - '@borewit/text-codec@0.1.1': {} - - '@colors/colors@1.5.0': - optional: true - - '@esbuild/aix-ppc64@0.21.5': - optional: true - - '@esbuild/android-arm64@0.21.5': - optional: true - - '@esbuild/android-arm@0.21.5': - optional: true - - '@esbuild/android-x64@0.21.5': - optional: true - - '@esbuild/darwin-arm64@0.21.5': - optional: true - - '@esbuild/darwin-x64@0.21.5': - optional: true - - '@esbuild/freebsd-arm64@0.21.5': - optional: true - - '@esbuild/freebsd-x64@0.21.5': - optional: true - - '@esbuild/linux-arm64@0.21.5': - optional: true - - '@esbuild/linux-arm@0.21.5': - optional: true - - '@esbuild/linux-ia32@0.21.5': - optional: true - - '@esbuild/linux-loong64@0.21.5': - optional: true - - '@esbuild/linux-mips64el@0.21.5': - optional: true - - '@esbuild/linux-ppc64@0.21.5': - optional: true - - '@esbuild/linux-riscv64@0.21.5': - optional: true - - '@esbuild/linux-s390x@0.21.5': - optional: true - - '@esbuild/linux-x64@0.21.5': - optional: true - - '@esbuild/netbsd-x64@0.21.5': - optional: true - - '@esbuild/openbsd-x64@0.21.5': - optional: true - - '@esbuild/sunos-x64@0.21.5': - optional: true - - '@esbuild/win32-arm64@0.21.5': - optional: true - - '@esbuild/win32-ia32@0.21.5': - optional: true - - '@esbuild/win32-x64@0.21.5': - optional: true - - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@lukeed/csprng@1.1.0': {} - - '@microsoft/api-extractor-model@7.31.1': - dependencies: - '@microsoft/tsdoc': 0.15.1 - '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.17.0 - transitivePeerDependencies: - - '@types/node' - - '@microsoft/api-extractor@7.53.1': - dependencies: - '@microsoft/api-extractor-model': 7.31.1 - '@microsoft/tsdoc': 0.15.1 - '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.17.0 - '@rushstack/rig-package': 0.6.0 - '@rushstack/terminal': 0.19.1 - '@rushstack/ts-command-line': 5.1.1 - lodash: 4.17.21 - minimatch: 10.0.3 - resolve: 1.22.11 - semver: 7.5.4 - source-map: 0.6.1 - typescript: 5.8.2 - transitivePeerDependencies: - - '@types/node' - - '@microsoft/tsdoc-config@0.17.1': - dependencies: - '@microsoft/tsdoc': 0.15.1 - ajv: 8.12.0 - jju: 1.4.0 - resolve: 1.22.11 - - '@microsoft/tsdoc@0.15.1': {} - - '@nestjs/common@11.0.20(reflect-metadata@0.2.2)(rxjs@7.8.2)': - dependencies: - file-type: 20.4.1 - iterare: 1.2.1 - load-esm: 1.0.2 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - tslib: 2.8.1 - uid: 2.0.2 - transitivePeerDependencies: - - supports-color - - '@nestjs/core@11.1.7(@nestjs/common@11.0.20(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 11.0.20(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nuxt/opencollective': 0.4.1 - fast-safe-stringify: 2.1.1 - iterare: 1.2.1 - path-to-regexp: 8.3.0 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - tslib: 2.8.1 - uid: 2.0.2 - optionalDependencies: - '@nestjs/microservices': 11.0.20(@nestjs/common@11.0.20(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) - - '@nestjs/microservices@11.0.20(@nestjs/common@11.0.20(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 11.0.20(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.0.20(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) - iterare: 1.2.1 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - tslib: 2.8.1 - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 - - '@nuxt/opencollective@0.4.1': - dependencies: - consola: 3.4.2 - - '@octokit/auth-token@5.1.2': {} - - '@octokit/core@6.1.6': - dependencies: - '@octokit/auth-token': 5.1.2 - '@octokit/graphql': 8.2.2 - '@octokit/request': 9.2.4 - '@octokit/request-error': 6.1.8 - '@octokit/types': 14.1.0 - before-after-hook: 3.0.2 - universal-user-agent: 7.0.3 - - '@octokit/endpoint@10.1.4': - dependencies: - '@octokit/types': 14.1.0 - universal-user-agent: 7.0.3 - - '@octokit/graphql@8.2.2': - dependencies: - '@octokit/request': 9.2.4 - '@octokit/types': 14.1.0 - universal-user-agent: 7.0.3 - - '@octokit/openapi-types@24.2.0': {} - - '@octokit/openapi-types@25.1.0': {} - - '@octokit/plugin-paginate-rest@11.6.0(@octokit/core@6.1.6)': - dependencies: - '@octokit/core': 6.1.6 - '@octokit/types': 13.10.0 - - '@octokit/plugin-retry@7.2.1(@octokit/core@6.1.6)': - dependencies: - '@octokit/core': 6.1.6 - '@octokit/request-error': 6.1.8 - '@octokit/types': 14.1.0 - bottleneck: 2.19.5 - - '@octokit/plugin-throttling@9.6.1(@octokit/core@6.1.6)': - dependencies: - '@octokit/core': 6.1.6 - '@octokit/types': 13.10.0 - bottleneck: 2.19.5 - - '@octokit/request-error@6.1.8': - dependencies: - '@octokit/types': 14.1.0 - - '@octokit/request@9.2.4': - dependencies: - '@octokit/endpoint': 10.1.4 - '@octokit/request-error': 6.1.8 - '@octokit/types': 14.1.0 - fast-content-type-parse: 2.0.1 - universal-user-agent: 7.0.3 - - '@octokit/types@13.10.0': - dependencies: - '@octokit/openapi-types': 24.2.0 - - '@octokit/types@14.1.0': - dependencies: - '@octokit/openapi-types': 25.1.0 - - '@pnpm/config.env-replace@1.1.0': {} - - '@pnpm/network.ca-file@1.0.2': - dependencies: - graceful-fs: 4.2.10 - - '@pnpm/npm-conf@2.3.1': - dependencies: - '@pnpm/config.env-replace': 1.1.0 - '@pnpm/network.ca-file': 1.0.2 - config-chain: 1.1.13 - - '@rollup/pluginutils@5.3.0(rollup@4.52.5)': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.3 - optionalDependencies: - rollup: 4.52.5 - - '@rollup/rollup-android-arm-eabi@4.52.5': - optional: true - - '@rollup/rollup-android-arm64@4.52.5': - optional: true - - '@rollup/rollup-darwin-arm64@4.52.5': - optional: true - - '@rollup/rollup-darwin-x64@4.52.5': - optional: true - - '@rollup/rollup-freebsd-arm64@4.52.5': - optional: true - - '@rollup/rollup-freebsd-x64@4.52.5': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.52.5': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.52.5': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.52.5': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.52.5': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.52.5': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.52.5': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.52.5': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.52.5': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.52.5': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.52.5': - optional: true - - '@rollup/rollup-linux-x64-musl@4.52.5': - optional: true - - '@rollup/rollup-openharmony-arm64@4.52.5': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.52.5': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.52.5': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.52.5': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.52.5': - optional: true - - '@rushstack/node-core-library@5.17.0': - dependencies: - ajv: 8.13.0 - ajv-draft-04: 1.0.0(ajv@8.13.0) - ajv-formats: 3.0.1(ajv@8.13.0) - fs-extra: 11.3.2 - import-lazy: 4.0.0 - jju: 1.4.0 - resolve: 1.22.11 - semver: 7.5.4 - - '@rushstack/problem-matcher@0.1.1': {} - - '@rushstack/rig-package@0.6.0': - dependencies: - resolve: 1.22.11 - strip-json-comments: 3.1.1 - - '@rushstack/terminal@0.19.1': - dependencies: - '@rushstack/node-core-library': 5.17.0 - '@rushstack/problem-matcher': 0.1.1 - supports-color: 8.1.1 - - '@rushstack/ts-command-line@5.1.1': - dependencies: - '@rushstack/terminal': 0.19.1 - '@types/argparse': 1.0.38 - argparse: 1.0.10 - string-argv: 0.3.2 - transitivePeerDependencies: - - '@types/node' - - '@sec-ant/readable-stream@0.4.1': {} - - '@semantic-release/commit-analyzer@12.0.0(semantic-release@23.1.1(typescript@5.9.3))': - dependencies: - conventional-changelog-angular: 7.0.0 - conventional-commits-filter: 4.0.0 - conventional-commits-parser: 5.0.0 - debug: 4.4.3 - import-from-esm: 1.3.4 - lodash-es: 4.17.21 - micromatch: 4.0.8 - semantic-release: 23.1.1(typescript@5.9.3) - transitivePeerDependencies: - - supports-color - - '@semantic-release/error@4.0.0': {} - - '@semantic-release/github@10.3.5(semantic-release@23.1.1(typescript@5.9.3))': - dependencies: - '@octokit/core': 6.1.6 - '@octokit/plugin-paginate-rest': 11.6.0(@octokit/core@6.1.6) - '@octokit/plugin-retry': 7.2.1(@octokit/core@6.1.6) - '@octokit/plugin-throttling': 9.6.1(@octokit/core@6.1.6) - '@semantic-release/error': 4.0.0 - aggregate-error: 5.0.0 - debug: 4.4.3 - dir-glob: 3.0.1 - globby: 14.1.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - issue-parser: 7.0.1 - lodash-es: 4.17.21 - mime: 4.1.0 - p-filter: 4.1.0 - semantic-release: 23.1.1(typescript@5.9.3) - url-join: 5.0.0 - transitivePeerDependencies: - - supports-color - - '@semantic-release/npm@12.0.2(semantic-release@23.1.1(typescript@5.9.3))': - dependencies: - '@semantic-release/error': 4.0.0 - aggregate-error: 5.0.0 - execa: 9.6.0 - fs-extra: 11.3.2 - lodash-es: 4.17.21 - nerf-dart: 1.0.0 - normalize-url: 8.1.0 - npm: 10.9.4 - rc: 1.2.8 - read-pkg: 9.0.1 - registry-auth-token: 5.1.0 - semantic-release: 23.1.1(typescript@5.9.3) - semver: 7.7.3 - tempy: 3.1.0 - - '@semantic-release/release-notes-generator@13.0.0(semantic-release@23.1.1(typescript@5.9.3))': - dependencies: - conventional-changelog-angular: 7.0.0 - conventional-changelog-writer: 7.0.1 - conventional-commits-filter: 4.0.0 - conventional-commits-parser: 5.0.0 - debug: 4.4.3 - get-stream: 7.0.1 - import-from-esm: 1.3.4 - into-stream: 7.0.0 - lodash-es: 4.17.21 - read-pkg-up: 11.0.0 - semantic-release: 23.1.1(typescript@5.9.3) - transitivePeerDependencies: - - supports-color - - '@sindresorhus/is@4.6.0': {} - - '@sindresorhus/merge-streams@2.3.0': {} - - '@sindresorhus/merge-streams@4.0.0': {} - - '@tokenizer/inflate@0.2.7': - dependencies: - debug: 4.4.3 - fflate: 0.8.2 - token-types: 6.1.1 - transitivePeerDependencies: - - supports-color - - '@tokenizer/token@0.3.0': {} - - '@types/argparse@1.0.38': {} - - '@types/estree@1.0.8': {} - - '@types/normalize-package-data@2.4.4': {} - - '@volar/language-core@2.4.23': - dependencies: - '@volar/source-map': 2.4.23 - - '@volar/source-map@2.4.23': {} - - '@volar/typescript@2.4.23': - dependencies: - '@volar/language-core': 2.4.23 - path-browserify: 1.0.1 - vscode-uri: 3.1.0 - - '@vue/compiler-core@3.5.22': - dependencies: - '@babel/parser': 7.28.4 - '@vue/shared': 3.5.22 - entities: 4.5.0 - estree-walker: 2.0.2 - source-map-js: 1.2.1 - - '@vue/compiler-dom@3.5.22': - dependencies: - '@vue/compiler-core': 3.5.22 - '@vue/shared': 3.5.22 - - '@vue/compiler-vue2@2.7.16': - dependencies: - de-indent: 1.0.2 - he: 1.2.0 - - '@vue/language-core@2.2.0(typescript@5.9.3)': - dependencies: - '@volar/language-core': 2.4.23 - '@vue/compiler-dom': 3.5.22 - '@vue/compiler-vue2': 2.7.16 - '@vue/shared': 3.5.22 - alien-signals: 0.4.14 - minimatch: 9.0.5 - muggle-string: 0.4.1 - path-browserify: 1.0.1 - optionalDependencies: - typescript: 5.9.3 - - '@vue/shared@3.5.22': {} - - JSONStream@1.3.5: - dependencies: - jsonparse: 1.3.1 - through: 2.3.8 - - acorn@8.15.0: {} - - agent-base@7.1.4: {} - - aggregate-error@5.0.0: - dependencies: - clean-stack: 5.3.0 - indent-string: 5.0.0 - - ajv-draft-04@1.0.0(ajv@8.13.0): - optionalDependencies: - ajv: 8.13.0 - - ajv-formats@3.0.1(ajv@8.13.0): - optionalDependencies: - ajv: 8.13.0 - - ajv@8.12.0: - dependencies: - fast-deep-equal: 3.1.3 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - uri-js: 4.4.1 - - ajv@8.13.0: - dependencies: - fast-deep-equal: 3.1.3 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - uri-js: 4.4.1 - - alien-signals@0.4.14: {} - - ansi-escapes@7.1.1: - dependencies: - environment: 1.1.0 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@3.2.1: - dependencies: - color-convert: 1.9.3 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - any-promise@1.3.0: {} - - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - - argparse@2.0.1: {} - - argv-formatter@1.0.0: {} - - array-ify@1.0.0: {} - - balanced-match@1.0.2: {} - - before-after-hook@3.0.2: {} - - bottleneck@2.19.5: {} - - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - callsites@3.1.0: {} - - chalk@2.4.2: - dependencies: - ansi-styles: 3.2.1 - escape-string-regexp: 1.0.5 - supports-color: 5.5.0 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chalk@5.6.2: {} - - char-regex@1.0.2: {} - - clean-stack@5.3.0: - dependencies: - escape-string-regexp: 5.0.0 - - cli-highlight@2.1.11: - dependencies: - chalk: 4.1.2 - highlight.js: 10.7.3 - mz: 2.7.0 - parse5: 5.1.1 - parse5-htmlparser2-tree-adapter: 6.0.1 - yargs: 16.2.0 - - cli-table3@0.6.5: - dependencies: - string-width: 4.2.3 - optionalDependencies: - '@colors/colors': 1.5.0 - - cliui@7.0.4: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - color-convert@1.9.3: - dependencies: - color-name: 1.1.3 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.3: {} - - color-name@1.1.4: {} - - compare-func@2.0.0: - dependencies: - array-ify: 1.0.0 - dot-prop: 5.3.0 - - compare-versions@6.1.1: {} - - confbox@0.1.8: {} - - confbox@0.2.2: {} - - config-chain@1.1.13: - dependencies: - ini: 1.3.8 - proto-list: 1.2.4 - - consola@3.4.2: {} - - conventional-changelog-angular@7.0.0: - dependencies: - compare-func: 2.0.0 - - conventional-changelog-writer@7.0.1: - dependencies: - conventional-commits-filter: 4.0.0 - handlebars: 4.7.8 - json-stringify-safe: 5.0.1 - meow: 12.1.1 - semver: 7.7.3 - split2: 4.2.0 - - conventional-commits-filter@4.0.0: {} - - conventional-commits-parser@5.0.0: - dependencies: - JSONStream: 1.3.5 - is-text-path: 2.0.0 - meow: 12.1.1 - split2: 4.2.0 - - convert-hrtime@5.0.0: {} - - core-util-is@1.0.3: {} - - cosmiconfig@9.0.0(typescript@5.9.3): - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.1 - js-yaml: 4.1.0 - parse-json: 5.2.0 - optionalDependencies: - typescript: 5.9.3 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - crypto-random-string@4.0.0: - dependencies: - type-fest: 1.4.0 - - de-indent@1.0.2: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-extend@0.6.0: {} - - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - - dot-prop@5.3.0: - dependencies: - is-obj: 2.0.0 - - duplexer2@0.1.4: - dependencies: - readable-stream: 2.3.8 - - emoji-regex@8.0.0: {} - - emojilib@2.4.0: {} - - entities@4.5.0: {} - - env-ci@11.2.0: - dependencies: - execa: 8.0.1 - java-properties: 1.0.2 - - env-paths@2.2.1: {} - - environment@1.1.0: {} - - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - - escalade@3.2.0: {} - - escape-string-regexp@1.0.5: {} - - escape-string-regexp@5.0.0: {} - - estree-walker@2.0.2: {} - - execa@8.0.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 8.0.1 - human-signals: 5.0.0 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.3.0 - onetime: 6.0.0 - signal-exit: 4.1.0 - strip-final-newline: 3.0.0 - - execa@9.6.0: - dependencies: - '@sindresorhus/merge-streams': 4.0.0 - cross-spawn: 7.0.6 - figures: 6.1.0 - get-stream: 9.0.1 - human-signals: 8.0.1 - is-plain-obj: 4.1.0 - is-stream: 4.0.1 - npm-run-path: 6.0.0 - pretty-ms: 9.3.0 - signal-exit: 4.1.0 - strip-final-newline: 4.0.0 - yoctocolors: 2.1.2 - - exsolve@1.0.7: {} - - fast-content-type-parse@2.0.1: {} - - fast-deep-equal@3.1.3: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-safe-stringify@2.1.1: {} - - fastq@1.19.1: - dependencies: - reusify: 1.1.0 - - fflate@0.8.2: {} - - figures@2.0.0: - dependencies: - escape-string-regexp: 1.0.5 - - figures@6.1.0: - dependencies: - is-unicode-supported: 2.1.0 - - file-type@20.4.1: - dependencies: - '@tokenizer/inflate': 0.2.7 - strtok3: 10.3.4 - token-types: 6.1.1 - uint8array-extras: 1.5.0 - transitivePeerDependencies: - - supports-color - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - find-up-simple@1.0.1: {} - - find-up@2.1.0: - dependencies: - locate-path: 2.0.0 - - find-versions@6.0.0: - dependencies: - semver-regex: 4.0.5 - super-regex: 1.0.0 - - from2@2.3.0: - dependencies: - inherits: 2.0.4 - readable-stream: 2.3.8 - - fs-extra@11.3.2: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - function-timeout@1.0.2: {} - - get-caller-file@2.0.5: {} - - get-stream@6.0.1: {} - - get-stream@7.0.1: {} - - get-stream@8.0.1: {} - - get-stream@9.0.1: - dependencies: - '@sec-ant/readable-stream': 0.4.1 - is-stream: 4.0.1 - - git-log-parser@1.2.1: - dependencies: - argv-formatter: 1.0.0 - spawn-error-forwarder: 1.0.0 - split2: 1.0.0 - stream-combiner2: 1.1.1 - through2: 2.0.5 - traverse: 0.6.8 - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - globby@14.1.0: - dependencies: - '@sindresorhus/merge-streams': 2.3.0 - fast-glob: 3.3.3 - ignore: 7.0.5 - path-type: 6.0.0 - slash: 5.1.0 - unicorn-magic: 0.3.0 - - graceful-fs@4.2.10: {} - - graceful-fs@4.2.11: {} - - handlebars@4.7.8: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - - has-flag@3.0.0: {} - - has-flag@4.0.0: {} - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - he@1.2.0: {} - - highlight.js@10.7.3: {} - - hook-std@3.0.0: {} - - hosted-git-info@7.0.2: - dependencies: - lru-cache: 10.4.3 - - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - human-signals@5.0.0: {} - - human-signals@8.0.1: {} - - husky@9.1.7: {} - - ieee754@1.2.1: {} - - ignore@7.0.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - import-from-esm@1.3.4: - dependencies: - debug: 4.4.3 - import-meta-resolve: 4.2.0 - transitivePeerDependencies: - - supports-color - - import-lazy@4.0.0: {} - - import-meta-resolve@4.2.0: {} - - indent-string@5.0.0: {} - - index-to-position@1.2.0: {} - - inherits@2.0.4: {} - - ini@1.3.8: {} - - into-stream@7.0.0: - dependencies: - from2: 2.3.0 - p-is-promise: 3.0.0 - - is-arrayish@0.2.1: {} - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - is-obj@2.0.0: {} - - is-plain-obj@4.1.0: {} - - is-stream@3.0.0: {} - - is-stream@4.0.1: {} - - is-text-path@2.0.0: - dependencies: - text-extensions: 2.4.0 - - is-unicode-supported@2.1.0: {} - - isarray@1.0.0: {} - - isexe@2.0.0: {} - - issue-parser@7.0.1: - dependencies: - lodash.capitalize: 4.2.1 - lodash.escaperegexp: 4.1.2 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.uniqby: 4.7.0 - - iterare@1.2.1: {} - - java-properties@1.0.2: {} - - jju@1.4.0: {} - - js-tokens@4.0.0: {} - - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - - json-parse-better-errors@1.0.2: {} - - json-parse-even-better-errors@2.3.1: {} - - json-schema-traverse@1.0.0: {} - - json-stringify-safe@5.0.1: {} - - jsonfile@6.2.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - jsonparse@1.3.1: {} - - kolorist@1.8.0: {} - - lines-and-columns@1.2.4: {} - - load-esm@1.0.2: {} - - load-json-file@4.0.0: - dependencies: - graceful-fs: 4.2.11 - parse-json: 4.0.0 - pify: 3.0.0 - strip-bom: 3.0.0 - - local-pkg@1.1.2: - dependencies: - mlly: 1.8.0 - pkg-types: 2.3.0 - quansync: 0.2.11 - - locate-path@2.0.0: - dependencies: - p-locate: 2.0.0 - path-exists: 3.0.0 - - lodash-es@4.17.21: {} - - lodash.capitalize@4.2.1: {} - - lodash.escaperegexp@4.1.2: {} - - lodash.isplainobject@4.0.6: {} - - lodash.isstring@4.0.1: {} - - lodash.uniqby@4.7.0: {} - - lodash@4.17.21: {} - - lru-cache@10.4.3: {} - - lru-cache@6.0.0: - dependencies: - yallist: 4.0.0 - - magic-string@0.30.19: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - marked-terminal@7.3.0(marked@12.0.2): - dependencies: - ansi-escapes: 7.1.1 - ansi-regex: 6.2.2 - chalk: 5.6.2 - cli-highlight: 2.1.11 - cli-table3: 0.6.5 - marked: 12.0.2 - node-emoji: 2.2.0 - supports-hyperlinks: 3.2.0 - - marked@12.0.2: {} - - meow@12.1.1: {} - - merge-stream@2.0.0: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - - mime@4.1.0: {} - - mimic-fn@4.0.0: {} - - minimatch@10.0.3: - dependencies: - '@isaacs/brace-expansion': 5.0.0 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 - - minimist@1.2.8: {} - - mlly@1.8.0: - dependencies: - acorn: 8.15.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.1 - - ms@2.1.3: {} - - muggle-string@0.4.1: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - nanoid@3.3.11: {} - - neo-async@2.6.2: {} - - nerf-dart@1.0.0: {} - - node-emoji@2.2.0: - dependencies: - '@sindresorhus/is': 4.6.0 - char-regex: 1.0.2 - emojilib: 2.4.0 - skin-tone: 2.0.0 - - normalize-package-data@6.0.2: - dependencies: - hosted-git-info: 7.0.2 - semver: 7.7.3 - validate-npm-package-license: 3.0.4 - - normalize-url@8.1.0: {} - - npm-run-path@5.3.0: - dependencies: - path-key: 4.0.0 - - npm-run-path@6.0.0: - dependencies: - path-key: 4.0.0 - unicorn-magic: 0.3.0 - - npm@10.9.4: {} - - object-assign@4.1.1: {} - - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - - p-each-series@3.0.0: {} - - p-filter@4.1.0: - dependencies: - p-map: 7.0.3 - - p-is-promise@3.0.0: {} - - p-limit@1.3.0: - dependencies: - p-try: 1.0.0 - - p-locate@2.0.0: - dependencies: - p-limit: 1.3.0 - - p-map@7.0.3: {} - - p-reduce@3.0.0: {} - - p-try@1.0.0: {} - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - parse-json@4.0.0: - dependencies: - error-ex: 1.3.4 - json-parse-better-errors: 1.0.2 - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - parse-json@8.3.0: - dependencies: - '@babel/code-frame': 7.27.1 - index-to-position: 1.2.0 - type-fest: 4.41.0 - - parse-ms@4.0.0: {} - - parse5-htmlparser2-tree-adapter@6.0.1: - dependencies: - parse5: 6.0.1 - - parse5@5.1.1: {} - - parse5@6.0.1: {} - - path-browserify@1.0.1: {} - - path-exists@3.0.0: {} - - path-key@3.1.1: {} - - path-key@4.0.0: {} - - path-parse@1.0.7: {} - - path-to-regexp@8.3.0: {} - - path-type@4.0.0: {} - - path-type@6.0.0: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@2.3.1: {} - - picomatch@4.0.3: {} - - pify@3.0.0: {} - - pkg-conf@2.1.0: - dependencies: - find-up: 2.1.0 - load-json-file: 4.0.0 - - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.0 - pathe: 2.0.3 - - pkg-types@2.3.0: - dependencies: - confbox: 0.2.2 - exsolve: 1.0.7 - pathe: 2.0.3 - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prettier@3.6.2: {} - - pretty-ms@9.3.0: - dependencies: - parse-ms: 4.0.0 - - process-nextick-args@2.0.1: {} - - proto-list@1.2.4: {} - - punycode@2.3.1: {} - - quansync@0.2.11: {} - - queue-microtask@1.2.3: {} - - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - - read-package-up@11.0.0: - dependencies: - find-up-simple: 1.0.1 - read-pkg: 9.0.1 - type-fest: 4.41.0 - - read-pkg-up@11.0.0: - dependencies: - find-up-simple: 1.0.1 - read-pkg: 9.0.1 - type-fest: 4.41.0 - - read-pkg@9.0.1: - dependencies: - '@types/normalize-package-data': 2.4.4 - normalize-package-data: 6.0.2 - parse-json: 8.3.0 - type-fest: 4.41.0 - unicorn-magic: 0.1.0 - - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - - reflect-metadata@0.2.2: {} - - registry-auth-token@5.1.0: - dependencies: - '@pnpm/npm-conf': 2.3.1 - - require-directory@2.1.1: {} - - require-from-string@2.0.2: {} - - resolve-from@4.0.0: {} - - resolve-from@5.0.0: {} - - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - reusify@1.1.0: {} - - rollup@4.52.5: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.52.5 - '@rollup/rollup-android-arm64': 4.52.5 - '@rollup/rollup-darwin-arm64': 4.52.5 - '@rollup/rollup-darwin-x64': 4.52.5 - '@rollup/rollup-freebsd-arm64': 4.52.5 - '@rollup/rollup-freebsd-x64': 4.52.5 - '@rollup/rollup-linux-arm-gnueabihf': 4.52.5 - '@rollup/rollup-linux-arm-musleabihf': 4.52.5 - '@rollup/rollup-linux-arm64-gnu': 4.52.5 - '@rollup/rollup-linux-arm64-musl': 4.52.5 - '@rollup/rollup-linux-loong64-gnu': 4.52.5 - '@rollup/rollup-linux-ppc64-gnu': 4.52.5 - '@rollup/rollup-linux-riscv64-gnu': 4.52.5 - '@rollup/rollup-linux-riscv64-musl': 4.52.5 - '@rollup/rollup-linux-s390x-gnu': 4.52.5 - '@rollup/rollup-linux-x64-gnu': 4.52.5 - '@rollup/rollup-linux-x64-musl': 4.52.5 - '@rollup/rollup-openharmony-arm64': 4.52.5 - '@rollup/rollup-win32-arm64-msvc': 4.52.5 - '@rollup/rollup-win32-ia32-msvc': 4.52.5 - '@rollup/rollup-win32-x64-gnu': 4.52.5 - '@rollup/rollup-win32-x64-msvc': 4.52.5 - fsevents: 2.3.3 - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - - safe-buffer@5.1.2: {} - - semantic-release@23.1.1(typescript@5.9.3): - dependencies: - '@semantic-release/commit-analyzer': 12.0.0(semantic-release@23.1.1(typescript@5.9.3)) - '@semantic-release/error': 4.0.0 - '@semantic-release/github': 10.3.5(semantic-release@23.1.1(typescript@5.9.3)) - '@semantic-release/npm': 12.0.2(semantic-release@23.1.1(typescript@5.9.3)) - '@semantic-release/release-notes-generator': 13.0.0(semantic-release@23.1.1(typescript@5.9.3)) - aggregate-error: 5.0.0 - cosmiconfig: 9.0.0(typescript@5.9.3) - debug: 4.4.3 - env-ci: 11.2.0 - execa: 9.6.0 - figures: 6.1.0 - find-versions: 6.0.0 - get-stream: 6.0.1 - git-log-parser: 1.2.1 - hook-std: 3.0.0 - hosted-git-info: 7.0.2 - import-from-esm: 1.3.4 - lodash-es: 4.17.21 - marked: 12.0.2 - marked-terminal: 7.3.0(marked@12.0.2) - micromatch: 4.0.8 - p-each-series: 3.0.0 - p-reduce: 3.0.0 - read-package-up: 11.0.0 - resolve-from: 5.0.0 - semver: 7.7.3 - semver-diff: 4.0.0 - signale: 1.4.0 - yargs: 17.7.2 - transitivePeerDependencies: - - supports-color - - typescript - - semver-diff@4.0.0: - dependencies: - semver: 7.7.3 - - semver-regex@4.0.5: {} - - semver@7.5.4: - dependencies: - lru-cache: 6.0.0 - - semver@7.7.3: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - signal-exit@4.1.0: {} - - signale@1.4.0: - dependencies: - chalk: 2.4.2 - figures: 2.0.0 - pkg-conf: 2.1.0 - - skin-tone@2.0.0: - dependencies: - unicode-emoji-modifier-base: 1.0.0 - - slash@5.1.0: {} - - source-map-js@1.2.1: {} - - source-map@0.6.1: {} - - spawn-error-forwarder@1.0.0: {} - - spdx-correct@3.2.0: - dependencies: - spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.22 - - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@3.0.1: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.22 - - spdx-license-ids@3.0.22: {} - - split2@1.0.0: - dependencies: - through2: 2.0.5 - - split2@4.2.0: {} - - sprintf-js@1.0.3: {} - - stream-combiner2@1.1.1: - dependencies: - duplexer2: 0.1.4 - readable-stream: 2.3.8 - - string-argv@0.3.2: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-bom@3.0.0: {} - - strip-final-newline@3.0.0: {} - - strip-final-newline@4.0.0: {} - - strip-json-comments@2.0.1: {} - - strip-json-comments@3.1.1: {} - - strtok3@10.3.4: - dependencies: - '@tokenizer/token': 0.3.0 - - super-regex@1.0.0: - dependencies: - function-timeout: 1.0.2 - time-span: 5.1.0 - - supports-color@5.5.0: - dependencies: - has-flag: 3.0.0 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - supports-hyperlinks@3.2.0: - dependencies: - has-flag: 4.0.0 - supports-color: 7.2.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - temp-dir@3.0.0: {} - - tempy@3.1.0: - dependencies: - is-stream: 3.0.0 - temp-dir: 3.0.0 - type-fest: 2.19.0 - unique-string: 3.0.0 - - text-extensions@2.4.0: {} - - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - through2@2.0.5: - dependencies: - readable-stream: 2.3.8 - xtend: 4.0.2 - - through@2.3.8: {} - - time-span@5.1.0: - dependencies: - convert-hrtime: 5.0.0 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - token-types@6.1.1: - dependencies: - '@borewit/text-codec': 0.1.1 - '@tokenizer/token': 0.3.0 - ieee754: 1.2.1 - - traverse@0.6.8: {} - - tslib@2.8.1: {} - - type-fest@1.4.0: {} - - type-fest@2.19.0: {} - - type-fest@4.41.0: {} - - typescript@5.8.2: {} - - typescript@5.9.3: {} - - ufo@1.6.1: {} - - uglify-js@3.19.3: - optional: true - - uid@2.0.2: - dependencies: - '@lukeed/csprng': 1.1.0 - - uint8array-extras@1.5.0: {} - - unicode-emoji-modifier-base@1.0.0: {} - - unicorn-magic@0.1.0: {} - - unicorn-magic@0.3.0: {} - - unique-string@3.0.0: - dependencies: - crypto-random-string: 4.0.0 - - universal-user-agent@7.0.3: {} - - universalify@2.0.1: {} - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - url-join@5.0.0: {} - - util-deprecate@1.0.2: {} - - validate-npm-package-license@3.0.4: - dependencies: - spdx-correct: 3.2.0 - spdx-expression-parse: 3.0.1 - - vite-plugin-dts@4.5.4(rollup@4.52.5)(typescript@5.9.3)(vite@5.4.21): - dependencies: - '@microsoft/api-extractor': 7.53.1 - '@rollup/pluginutils': 5.3.0(rollup@4.52.5) - '@volar/typescript': 2.4.23 - '@vue/language-core': 2.2.0(typescript@5.9.3) - compare-versions: 6.1.1 - debug: 4.4.3 - kolorist: 1.8.0 - local-pkg: 1.1.2 - magic-string: 0.30.19 - typescript: 5.9.3 - optionalDependencies: - vite: 5.4.21 - transitivePeerDependencies: - - '@types/node' - - rollup - - supports-color - - vite@5.4.21: - dependencies: - esbuild: 0.21.5 - postcss: 8.5.6 - rollup: 4.52.5 - optionalDependencies: - fsevents: 2.3.3 - - vscode-uri@3.1.0: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wordwrap@1.0.0: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - xtend@4.0.2: {} - - y18n@5.0.8: {} - - yallist@4.0.0: {} - - yargs-parser@20.2.9: {} - - yargs-parser@21.1.1: {} - - yargs@16.2.0: - dependencies: - cliui: 7.0.4 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 20.2.9 - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yoctocolors@2.1.2: {} diff --git a/broker-parent/broker-client/quarkus/pom.xml b/broker-parent/broker-client/quarkus/pom.xml deleted file mode 100644 index 9c3e82872c..0000000000 --- a/broker-parent/broker-client/quarkus/pom.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - 4.0.0 - - org.entcore - edifice-ent-client - 6.8-develop-pedago-SNAPSHOT - - - io.edifice - quarkus-parent - 1.0-SNAPSHOT - - - - 2.20.5 - 3.2.1 - - - - - - vertx - sonatype-vertx-repository - https://oss.sonatype.org/content/repositories/iovertx-3720/ - - - snapshots - sonatype-snapshots-repository - https://oss.sonatype.org/content/repositories/snapshots - - - ode - ODE Repository - https://maven.opendigitaleducation.com/nexus/content/groups/public - - - - - - ode-snapshots - ODE Snapshots - https://maven.opendigitaleducation.com/nexus/content/repositories/snapshots/ - - - ode-releases - ODE Releases - https://maven.opendigitaleducation.com/nexus/content/repositories/releases/ - - - - - - ${project.groupId} - broker-api - ${project.version} - - - io.nats - jnats - ${jnats.version} - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - - - io.smallrye - jandex-maven-plugin - ${jandex.version} - - - make-index - - jandex - - - - - - - ${project.build.directory}/classes - - **/Dummy*.class - - - - - - - - diff --git a/broker-parent/broker-nats/src/main/java/org/entcore/broker/nats/BrokerListenerProcessor.java b/broker-parent/broker-nats/src/main/java/org/entcore/broker/nats/BrokerListenerProcessor.java index f1e1e15afd..095019bf5e 100644 --- a/broker-parent/broker-nats/src/main/java/org/entcore/broker/nats/BrokerListenerProcessor.java +++ b/broker-parent/broker-nats/src/main/java/org/entcore/broker/nats/BrokerListenerProcessor.java @@ -90,6 +90,7 @@ private NATSEndpoint processMethod(ExecutableElement method, BrokerListener anno // Get method information endpoint.setMethodName(method.getSimpleName().toString()); endpoint.setClassName(((TypeElement) method.getEnclosingElement()).getQualifiedName().toString()); + endpoint.setProvider(true); final List parameters = method.getParameters(); // Get request type information (first parameter) diff --git a/broker-parent/broker-nats/src/main/java/org/entcore/broker/nats/model/NATSEndpoint.java b/broker-parent/broker-nats/src/main/java/org/entcore/broker/nats/model/NATSEndpoint.java index 5aa84e42aa..a5ca59f393 100644 --- a/broker-parent/broker-nats/src/main/java/org/entcore/broker/nats/model/NATSEndpoint.java +++ b/broker-parent/broker-nats/src/main/java/org/entcore/broker/nats/model/NATSEndpoint.java @@ -14,6 +14,15 @@ public class NATSEndpoint { private String className; private boolean proxy; private boolean broadcast; + private boolean provider; + + public boolean isProvider() { + return provider; + } + + public void setProvider(boolean provider) { + this.provider = provider; + } public String getSubject() { return subject; diff --git a/broker-parent/broker-nats/src/main/java/org/entcore/broker/nats/utils/SchemaGeneratorUtil.java b/broker-parent/broker-nats/src/main/java/org/entcore/broker/nats/utils/SchemaGeneratorUtil.java index 28072f917d..48b5fbf6f2 100644 --- a/broker-parent/broker-nats/src/main/java/org/entcore/broker/nats/utils/SchemaGeneratorUtil.java +++ b/broker-parent/broker-nats/src/main/java/org/entcore/broker/nats/utils/SchemaGeneratorUtil.java @@ -1,5 +1,7 @@ package org.entcore.broker.nats.utils; +import io.vertx.core.json.JsonObject; + import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; import javax.lang.model.element.TypeElement; @@ -107,7 +109,7 @@ public Map generateJsonSchemaFromTypeMirror(TypeMirror typeMirro schema.put("type", "number"); } else if("java.lang.Boolean".equals(typeMirrorAsString)) { schema.put("type", "boolean"); - } else if ("java.lang.Object".equals(typeMirrorAsString)) { + } else if ("java.lang.Object".equals(typeMirrorAsString) || JsonObject.class.getCanonicalName().equals(typeMirrorAsString)) { schema.put("type", "object"); } else if (isArrayLike(typeMirror)) { schema.put("type", "array"); @@ -153,6 +155,7 @@ private void specifyEnum(TypeMirror typeMirror, Map schema) { .filter(e -> e.getKind() == ElementKind.ENUM_CONSTANT) .map(e -> e.getSimpleName().toString()) .collect(Collectors.toList()); + schema.put("title", typeMirror.toString()); schema.put("enum", enumConstants); } diff --git a/broker-parent/broker-proxy/src/main/java/org/entcore/broker/proxy/AIWikiGeneratorListener.java b/broker-parent/broker-proxy/src/main/java/org/entcore/broker/proxy/AIWikiGeneratorListener.java new file mode 100644 index 0000000000..985bcacc12 --- /dev/null +++ b/broker-parent/broker-proxy/src/main/java/org/entcore/broker/proxy/AIWikiGeneratorListener.java @@ -0,0 +1,22 @@ +package org.entcore.broker.proxy; + +import io.vertx.core.Future; +import org.entcore.broker.api.BrokerListener; +import org.entcore.broker.api.dto.BaseResponseDTO; + +public interface AIWikiGeneratorListener { + //TODO should be generated by cli(needed for json) + //TODO use dummy dto to avoid issues + + @BrokerListener( + subject = "ai.wiki.content.updated", + proxy = true + ) + Future updateWikiContent(BaseResponseDTO content); + + @BrokerListener( + subject = "ai.wiki.structure.updated", + proxy = true + ) + Future updateWikiStructure(BaseResponseDTO structure); +} diff --git a/broker-parent/broker/src/main/java/org/entcore/broker/client/AbstractNATSBrokerClient.java b/broker-parent/broker/src/main/java/org/entcore/broker/client/AbstractNATSBrokerClient.java new file mode 100644 index 0000000000..c1bd02959c --- /dev/null +++ b/broker-parent/broker/src/main/java/org/entcore/broker/client/AbstractNATSBrokerClient.java @@ -0,0 +1,707 @@ +package org.entcore.broker.client; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.nats.client.Message; +import io.nats.vertx.NatsClient; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.eventbus.EventBus; +import io.vertx.core.eventbus.MessageConsumer; +import io.vertx.core.json.JsonObject; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; +import org.entcore.broker.api.dto.NATSResponseDTO; +import org.entcore.broker.listener.BrokerListener; +import org.entcore.broker.nats.model.NATSContract; +import org.entcore.broker.nats.model.NATSEndpoint; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; + +import static org.apache.commons.lang3.StringUtils.isBlank; + +/** + * Abstract base class for NATS broker clients.
+ * Purpose: + *
    + *
  • Centralizes all common logic for NATS/Vert.x integration, including EventBus bridging, listener management, and NATS operations (publish, request, subscribe, etc.).
  • + *
  • Implements the BrokerClient interface and provides a template for both single-broker and multi-broker implementations.
  • + *
  • Handles dynamic routing, contract-based listener registration, and proxying between NATS and Vert.x EventBus.
  • + *
+ * + * Usage: + *
    + *
  • Extend it to implement a concrete broker client (e.g., NATSBrokerClient or NATSMultiBrokerClient).
  • + *
  • Subclasses must implement three abstract methods: getNatsClientForSubject, getAllNatsClients, and getQueueName.
  • + *
  • Supports both legacy (single broker) and advanced (multi-broker, subject-based routing) scenarios.
  • + *
  • Automatically loads and registers listeners and proxies from contract files (META-INF/nats.json, META-INF/broker-proxy.nats.json).
  • + *
  • Provides robust error handling, dynamic listener instantiation, and transparent bridging between NATS and Vert.x EventBus.
  • + *
+ * + * Design patterns: + *
    + *
  • Template method: subclasses only provide routing and client selection, all orchestration is handled here.
  • + *
  • Polymorphism: all NATS operations (publish, request, subscribe, etc.) are routed through the abstract methods.
  • + *
  • Reflection: listeners are instantiated and invoked dynamically based on contract metadata.
  • + *
+ * + * See also: + *
    + *
  • NATSBrokerClient (single broker, legacy mode)
  • + *
  • NATSMultiBrokerClient (multi-broker, advanced routing)
  • + *
  • NATSBrokersConfig (configuration and routing rules)
  • + *
+ */ +public abstract class AbstractNATSBrokerClient implements BrokerClient { + + protected static final Logger log = LoggerFactory.getLogger(AbstractNATSBrokerClient.class); + + protected final Vertx vertx; + protected final ObjectMapper mapper = new ObjectMapper(); + protected final String charset = "UTF-8"; + protected final long defaultTimeout; + protected final String serverId = UUID.randomUUID().toString(); + protected final Set subscriptions = new HashSet<>(); + protected final Map listeners = new HashMap<>(); + + // EventBus consumers + private MessageConsumer publishConsumer; + private MessageConsumer requestConsumer; + + /** + * Creates an abstract NATS broker client. + * + * @param vertx The Vert.x instance + */ + protected AbstractNATSBrokerClient(Vertx vertx) { + this.vertx = vertx; + final JsonObject conf = vertx.getOrCreateContext().config(); + this.defaultTimeout = conf.getLong("request-default-timeout", 5000L); + } + + // ============================================================ + // ABSTRACT METHODS - To be implemented by subclasses + // ============================================================ + + /** + * Gets the NATS client to use for a given subject. + * Single broker returns the same client always. + * Multi broker routes based on configuration. + * + * @param subject The NATS subject + * @return The NatsClient to use for this subject + */ + protected abstract NatsClient getNatsClientForSubject(String subject); + + /** + * Gets all NATS clients managed by this broker. + * Used for initialization and shutdown. + * + * @return List of all NatsClient instances + */ + protected abstract List getAllNatsClients(); + + /** + * Gets the queue name for NATS subscriptions. + * + * @return The queue name + */ + protected abstract String getQueueName(); + + // ============================================================ + // START & LIFECYCLE + // ============================================================ + + @Override + public Future start() { + // 1. Connect all NATS clients + List> connectFutures = new ArrayList<>(); + for (NatsClient client : getAllNatsClients()) { + Future connectFuture = client.connect() + .onSuccess(e -> { + if (client.getConnection() != null) { + log.info("NATS client connected on " + client.getConnection().getConnectedUrl()); + } + }); + connectFutures.add(connectFuture); + } + + listenDynamicSubjectRegistration(); + + return Future.all(connectFutures) + // 2. Load and register listeners + .compose(v -> loadAndRegisterAll()) + // 3. Setup EventBus handlers + .compose(v -> setupEventBusPublishHandler()) + .compose(v -> setupEventBusRequestHandler()) + .mapEmpty(); + } + + /** + * Sets up the 2 event bus addresses on which this class will listen to take into account + * subjects that were not present in the static contracts. + * broker.add -> excepts a string with the name of the subject to listen to + * broker.remove -> excepts a string with the name of the subject to stop listening to + */ + private void listenDynamicSubjectRegistration() { + final EventBus eb = this.vertx.eventBus(); + eb.localConsumer("broker.remove", m -> { + final String subjectToRemove = m.body(); + final NatsClient natsClient = getNatsClientForSubject(subjectToRemove); + natsClient.unsubscribe(subjectToRemove) + .onSuccess(e -> m.reply(new JsonObject().put("ok", true))) + .onFailure(th -> { + log.warn("Error while unsubscribing to subject " + subjectToRemove); + m.reply(new JsonObject().put("ok", false).put("error", th.getMessage())); + }); + }); + eb.localConsumer("broker.add", m -> { + final String subjectToListen = m.body(); + final NatsClient natsClient = getNatsClientForSubject(subjectToListen); + natsClient.subscribe(subjectToListen, this.getQueueName(), this::proxifyNatsMessage) + .onSuccess(e -> m.reply(new JsonObject().put("ok", true))) + .onFailure(th -> { + log.warn("Error while subscribing to subject " + subjectToListen); + m.reply(new JsonObject().put("ok", false).put("error", th.getMessage())); + }); + }); + } + + @Override + public Future close() { + // Unregister EventBus handlers + if (publishConsumer != null) { + publishConsumer.unregister(); + publishConsumer = null; + } + if (requestConsumer != null) { + requestConsumer.unregister(); + requestConsumer = null; + } + + // Close all NATS clients + List> closeFutures = new ArrayList<>(); + for (NatsClient client : getAllNatsClients()) { + closeFutures.add(client.close()); + } + + return Future.all(closeFutures).mapEmpty(); + } + + // ============================================================ + // EVENTBUS HANDLERS + // ============================================================ + + /** + * Sets up the EventBus handler for broker.publish messages. + * Delegates actual publishing to the underlying BrokerClient. + * + * @return Future that completes when handler is set up + */ + private Future setupEventBusPublishHandler() { + final Promise promise = Promise.promise(); + + // Unregister any existing consumer + if (this.publishConsumer != null) { + this.publishConsumer.unregister(); + } + + // Set up an event bus handler for "broker.publish" messages + this.publishConsumer = vertx.eventBus().consumer("broker.publish", message -> { + final JsonObject body = message.body(); + final String subject = body.getString("subject"); + final String messageStr = body.getString("message"); + + if (subject == null || subject.isEmpty()) { + message.fail(400, "Subject cannot be empty"); + return; + } + + try { + // Prepare message object + final String messageObj = messageStr != null ? messageStr : ""; + + // Delegate to sendMessage (uses polymorphism) + this.sendRawMessage(subject, messageObj) + .onSuccess(v -> { + log.debug("Successfully published message to subject: " + subject); + message.reply(null); // Empty reply to indicate success + }) + .onFailure(err -> { + log.error("Failed to publish message to subject: " + subject, err); + message.fail(500, err.getMessage()); + }); + } catch (Exception e) { + log.error("Error publishing message to subject: " + subject, e); + message.fail(500, e.getMessage()); + } + }); + + log.info("Set up event bus handler for broker.publish"); + promise.complete(); + + return promise.future(); + } + + /** + * Sets up the EventBus handler for broker.request messages. + * Delegates actual requests to the underlying BrokerClient. + * + * @return Future that completes when handler is set up + */ + private Future setupEventBusRequestHandler() { + final Promise promise = Promise.promise(); + + // Unregister any existing consumer + if (this.requestConsumer != null) { + this.requestConsumer.unregister(); + } + + // Set up an event bus handler for "broker.request" messages + this.requestConsumer = vertx.eventBus().consumer("broker.request", message -> { + final JsonObject body = message.body(); + final String subject = body.getString("subject"); + final String requestStr = body.getString("message"); + final long timeout = body.getLong("timeout", defaultTimeout); + + if (subject == null || subject.isEmpty()) { + message.fail(400, "Subject cannot be empty"); + return; + } + + try { + final NatsClient client = getNatsClientForSubject(subject); + if (client == null) { + message.fail(500, "No NATS client available for subject: " + subject); + return; + } + request(client, subject, requestStr, timeout) + .onSuccess(response -> { + try { + message.reply(response); + } catch (Exception e) { + log.error("Error serializing response", e); + message.fail(500, e.getMessage()); + } + }) + .onFailure(err -> { + log.error("Failed to make request to subject: " + subject, err); + message.fail(500, err.getMessage()); + }); + } catch (Exception e) { + log.error("Error processing request message for subject: " + subject, e); + message.fail(500, e.getMessage()); + } + }); + + log.info("Set up event bus handler for broker.request"); + promise.complete(); + + return promise.future(); + } + + // ============================================================ + // LISTENERS LOADING & REGISTRATION + // ============================================================ + + /** + * Loads standard NATS contracts and registers them on all broker clients. + * Loads both META-INF/nats.json and META-INF/broker-proxy.nats.json. + * + * @return Future that completes when all contracts are loaded and registered + */ + private Future loadAndRegisterAll() { + return loadContract("META-INF/nats.json") + .compose(contract -> registerListeners(contract)) + .compose(v -> loadContract("META-INF/broker-proxy.nats.json")) + .compose(contract -> registerProxies(contract)) + .recover(err -> { + // If files don't exist, it's not a critical error + log.info("No NATS contract files found or error loading them: " + err.getMessage()); + return Future.succeededFuture(); + }); + } + + /** + * Loads a NATS contract from the classpath. + * + * @param path The classpath resource path to the JSON contract file + * @return Future containing the loaded contract + */ + private Future loadContract(String path) { + final InputStream is = this.getClass().getClassLoader().getResourceAsStream(path); + if (is == null) { + log.warn("Cannot find " + path + " file in classpath"); + return Future.failedFuture(new FileNotFoundException("Cannot find " + path + " file in classpath")); + } else { + try { + final NATSContract contracts = this.mapper.readValue(is, NATSContract.class); + return Future.succeededFuture(contracts); + } catch (IOException e) { + return Future.failedFuture(e); + } + } + } + + /** + * Registers all listeners from a contract. + * + * @param contract The NATS contract containing endpoints + * @return Future that completes when all listeners are registered + */ + private Future registerListeners(NATSContract contract) { + for (NATSEndpoint endpoint : contract.getEndpoints()) { + try { + final Class listenerClass = Class.forName(endpoint.getClassName()); + final Object listener = getListener(listenerClass); + final Class requestType = Class.forName(endpoint.getRequestType()); + final Class responseType = isBlank(endpoint.getResponseType()) + ? Void.class + : Class.forName(endpoint.getResponseType()); + final Method handler = listenerClass.getMethod(endpoint.getMethodName(), requestType); + + //noinspection unchecked + this.subscribe(endpoint.getSubject(), new BrokerListener() { + @Override + public Class getRequestType() { + return requestType; + } + + @Override + public Class getResponseType() { + return responseType; + } + + @Override + public Future onMessage(Object request, String subject) { + try { + Object response = handler.invoke(listener, request); + if (response instanceof Future) { + return (Future) response; + } else { + return Future.succeededFuture(response); + } + } catch (Exception e) { + log.error("Error invoking method", e); + return Future.failedFuture(e); + } + } + }).onSuccess(v -> { + subscriptions.add(endpoint.getSubject()); + log.info("Registered listener for subject: " + endpoint.getSubject()); + }).onFailure(e -> { + log.error("Error while subscribing to subject: " + endpoint.getSubject(), e); + }); + } catch (Exception e) { + log.error("Error while registering listener for subject: " + endpoint, e); + throw new RuntimeException("Error while registering listener for subject: " + endpoint, e); + } + } + + return Future.succeededFuture(); + } + + /** + * Registers all proxies from a contract. + * Proxies bridge NATS subjects to EventBus addresses. + * + * @param contract The NATS contract containing endpoints + * @return Future that completes when all proxies are registered + */ + private Future registerProxies(NATSContract contract) { + final EventBus eb = this.vertx.eventBus(); + + for (NATSEndpoint endpoint : contract.getEndpoints()) { + if (!endpoint.isProxy()) { + continue; + } + + try { + final String subject = transformToWildcard(endpoint.getSubject()); + final Handler messageHandler = getProxyMessageHandler(); + final NatsClient natsClient = getNatsClientForSubject(endpoint.getSubject()); + final Future future; + + if (endpoint.isBroadcast()) { + future = natsClient.subscribe(subject, messageHandler); + } else { + future = natsClient.subscribe(subject, getQueueName(), messageHandler); + } + + future.onSuccess(e -> log.info("Registered proxy for subject: " + endpoint.getSubject())) + .onFailure(th -> log.error("Error while registering proxy for subject: " + endpoint.getSubject(), th)); + } catch (Exception e) { + log.error("Error while registering proxy for subject: " + endpoint, e); + throw new RuntimeException("Error while registering proxy for subject: " + endpoint, e); + } + } + + return Future.succeededFuture(); + } + + /** + * Creates a proxy message handler that bridges NATS to EventBus. + * + * @return The message handler + */ + private Handler getProxyMessageHandler() { + return msg -> proxifyNatsMessage(msg); + } + + private void proxifyNatsMessage(Message msg) { + final Promise promise = Promise.promise(); + final NatsClient natsClient = getNatsClientForSubject(msg.getSubject()); + try { + this.vertx.eventBus().request(msg.getSubject(), getDataFromMessage(msg)) + .onSuccess(response -> { + try { + promise.tryComplete(response.body()); + } catch (Exception e) { + log.error("Error serializing response to JSON", e); + promise.tryFail(e); + } + }) + .onFailure(th -> { + log.error("Error calling subject " + msg.getSubject(), th); + promise.tryFail(th); + }); + } catch (IOException e) { + promise.tryFail(e); + } + + promise.future().onSuccess(response -> { + try { + final byte[] payload = ((String) response).getBytes(charset); + natsClient.publish(msg.getReplyTo(), payload); + } catch (Exception e) { + sendError(msg, e, natsClient); + } + }).onFailure(th -> { + sendError(msg, th, natsClient); + }); + } + + /** + * Extracts data from a NATS message. + * + * @param msg The NATS message + * @return The extracted data + * @throws IOException If parsing fails + */ + private Object getDataFromMessage(Message msg) throws IOException { + byte[] data = msg.getData(); + final JsonNode rootNode = mapper.readTree(data); + JsonNode dataNode = rootNode.get("data"); + if (dataNode != null) { + data = dataNode.toString().getBytes(StandardCharsets.UTF_8); + } + return data; + } + + /** + * Sends an error response to a NATS reply subject. + * + * @param msg The original NATS message + * @param e The error + * @param natsClient The NATS client to use + */ + private void sendError(Message msg, Throwable e, NatsClient natsClient) { + final String message = e.getMessage() == null ? String.valueOf(e) : e.getMessage(); + final NATSResponseDTO natsResponseDTO = new NATSResponseDTO(null, message, false, null); + try { + natsClient.publish(msg.getReplyTo(), mapper.writeValueAsBytes(natsResponseDTO)); + } catch (JsonProcessingException ex) { + log.error("Cannot serialize error", ex); + natsClient.publish(msg.getReplyTo(), message.getBytes(StandardCharsets.UTF_8)); + } + } + + /** + * Gets or creates a listener instance for the given class. + * + * @param listenerClass The listener class + * @return The listener instance + */ + private Object getListener(Class listenerClass) { + return listeners.computeIfAbsent(listenerClass.getCanonicalName(), k -> { + try { + return listenerClass.getConstructor(Vertx.class).newInstance(vertx); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + throw new IllegalStateException("Could not create listener of type " + listenerClass, e); + } + }); + } + + // ============================================================ + // BROKERCLIENT INTERFACE IMPLEMENTATION + // ============================================================ + + + @Override + public Future sendMessage(String subject, K message) { + try { + return sendRawMessage(subject, mapper.writeValueAsString(message)); + } catch (JsonProcessingException e) { + log.error("An error occurred while serializing message to JSON : " + message, e); + return Future.failedFuture(e); + } + } + + + + @Override + public Future sendRawMessage(String subject, String message) { + final NatsClient client = getNatsClientForSubject(subject); + if (client == null) { + return Future.failedFuture("No NATS client available for subject: " + subject); + } + + try { + final byte[] payload = message.getBytes(charset); + return client.publish(subject, payload) + .onFailure(e -> log.error("Error while sending message to NATS", e)); + } catch (Exception e) { + log.error("An error occurred while serializing message to JSON : " + message, e); + return Future.failedFuture(e); + } + } + + @Override + public Future request(String subject, K message) { + return this.request(subject, message, defaultTimeout); + } + + @Override + public Future request(String subject, K message, long timeout) { + final NatsClient client = getNatsClientForSubject(subject); + if (client == null) { + return Future.failedFuture("No NATS client available for subject: " + subject); + } + try { + final String payload = mapper.writeValueAsString(message); + return request(client, subject, payload, timeout) + .compose(responseStr -> { + try { + @SuppressWarnings("unchecked") + V response = mapper.readValue(responseStr, (Class) Object.class); + return Future.succeededFuture(response); + } catch (Exception err) { + return Future.failedFuture(err); + } + }); + } catch (Exception e) { + log.error("Error while serializing message to JSON", e); + return Future.failedFuture(e); + } + } + /** + * Internal helper to perform a NATS request and deserialize the response. + * @param client The NatsClient to use + * @param subject The NATS subject + * @param payload The serialized request payload + * @param timeout The timeout in ms + * @return Future with the serialized response + */ + private Future request(NatsClient client, String subject, String payload, long timeout) throws Exception { + Promise future = Promise.promise(); + final byte[] payloadBytes = payload != null? payload.getBytes(charset) : new byte[0]; + client.request(subject, payloadBytes, Duration.ofMillis(timeout)) + .onSuccess(e -> { + log.debug("Message sent to subject: " + subject); + try { + final String responseStr = new String(e.getData(), charset); + future.complete(responseStr); + } catch(Exception err) { + future.fail(err); + } + }) + .onFailure(e -> { + log.error("Error while sending message to NATS", e); + future.fail(e); + }); + return future.future(); + } + + @Override + public Future subscribe(String subject, BrokerListener listener) { + final NatsClient client = getNatsClientForSubject(subject); + if (client == null) { + return Future.failedFuture("No NATS client available for subject: " + subject); + } + + if (subscriptions.contains(subject)) { + throw new IllegalStateException("already.listening.on.subject." + subject); + } + + return client.subscribe(transformToWildcard(subject), getQueueName(), msg -> { + try { + final K request = mapper.readValue(new String(msg.getData(), charset), listener.getRequestType()); + final Future futureResponse = listener.onMessage(request, msg.getSubject()); + final String replyTo = msg.getReplyTo(); + if (replyTo != null && !replyTo.trim().isEmpty()) { + futureResponse.onSuccess(response -> { + try { + final byte[] payload = mapper.writeValueAsString(response).getBytes(charset); + client.publish(replyTo, payload); + } catch (Exception e) { + log.error("Error serializing response to JSON", e); + sendError(msg, e, client); + } + }).onFailure(e -> { + log.error("Error while processing message", e); + sendError(msg, e, client); + }); + } + } catch (Exception e) { + log.error("Error deserializing request : " + msg, e); + } + }) + .onSuccess(e -> { + subscriptions.add(subject); + log.info("Listening on subject: " + subject); + }) + .onFailure(th -> log.error("Failed to subscribe to subject: " + subject, th)); + } + + @Override + public Future unsubscribe(String subject) { + final NatsClient client = getNatsClientForSubject(subject); + if (client == null) { + return Future.failedFuture("No NATS client available for subject: " + subject); + } + + subscriptions.remove(subject); + return client.unsubscribe(subject) + .onSuccess(e -> log.info("Successfully unsubscribed from subject: " + subject)) + .onFailure(e -> log.error("Error while unsubscribing from subject: " + subject, e)); + } + + // ============================================================ + // UTILITY METHODS + // ============================================================ + + /** + * Transforms a subject pattern with {param} placeholders to a NATS wildcard pattern. + * Example: "users.{id}.profile" -> "users.*.profile" + * + * @param subject The original subject pattern with {param} placeholders + * @return A NATS-compatible wildcard pattern + */ + protected String transformToWildcard(String subject) { + if (subject == null || !subject.contains("{")) { + return subject; + } + // Simple regex to replace all {param} patterns with NATS wildcards (*) + return subject.replaceAll("\\{[^}]+\\}", "*"); + } +} diff --git a/broker-parent/broker/src/main/java/org/entcore/broker/client/BrokerClient.java b/broker-parent/broker/src/main/java/org/entcore/broker/client/BrokerClient.java index f7e1ec3640..73f740d550 100644 --- a/broker-parent/broker/src/main/java/org/entcore/broker/client/BrokerClient.java +++ b/broker-parent/broker/src/main/java/org/entcore/broker/client/BrokerClient.java @@ -23,6 +23,16 @@ public interface BrokerClient { */ Future sendMessage(String subject, K message); + + /** + * + * @param subject Subject to which the message will be sent + * @param message Raw message to be sent + * @return a Future that will be completed when the message is sent + * @param Type of the message to send + */ + Future sendRawMessage(String subject, String message); + /** * Sends a message to the broker and waits for a reply. * @param subject Subject to which the message will be sent diff --git a/broker-parent/broker/src/main/java/org/entcore/broker/client/BrokerClientFactory.java b/broker-parent/broker/src/main/java/org/entcore/broker/client/BrokerClientFactory.java index 295f1dbc7d..bb5630da35 100644 --- a/broker-parent/broker/src/main/java/org/entcore/broker/client/BrokerClientFactory.java +++ b/broker-parent/broker/src/main/java/org/entcore/broker/client/BrokerClientFactory.java @@ -8,6 +8,7 @@ */ import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; +import org.entcore.broker.config.NATSBrokersConfig; public class BrokerClientFactory { @@ -24,15 +25,21 @@ public class BrokerClientFactory { * @return BrokerClient implementation */ public static BrokerClient getClient(final Vertx vertx) { - final String brokerType = vertx.getOrCreateContext().config().getString("broker-type", "rest"); - log.debug("Broker type selected: {}", brokerType); - if ("nats".equalsIgnoreCase(brokerType)) { - return new NATSBrokerClient(vertx); - } else if ("none".equalsIgnoreCase(brokerType)) { - log.warn("Broker is disabled (broker-type=none). No broker operations will be performed."); - return new NoOpBrokerClient(vertx); - } else { - return new RESTBrokerClient(vertx); - } + final String brokerType = vertx.getOrCreateContext().config().getString("broker-type", "rest"); + log.debug("Broker type selected: {}", brokerType); + if ("nats".equalsIgnoreCase(brokerType)) { + final NATSBrokersConfig config = new NATSBrokersConfig(vertx); + if (config.isMultiBrokerMode()) { + return new NATSMultiBrokerClient(vertx, config); + } else { + // Legacy single broker mode + return new NATSBrokerClient(vertx, config); + } + } else if ("none".equalsIgnoreCase(brokerType)) { + log.warn("Broker is disabled (broker-type=none). No broker operations will be performed."); + return new NoOpBrokerClient(vertx); + } else { + return new RESTBrokerClient(vertx); + } } } diff --git a/broker-parent/broker/src/main/java/org/entcore/broker/client/NATSBrokerClient.java b/broker-parent/broker/src/main/java/org/entcore/broker/client/NATSBrokerClient.java index 071681fa71..3abcc4f288 100644 --- a/broker-parent/broker/src/main/java/org/entcore/broker/client/NATSBrokerClient.java +++ b/broker-parent/broker/src/main/java/org/entcore/broker/client/NATSBrokerClient.java @@ -1,446 +1,68 @@ package org.entcore.broker.client; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.nats.client.Message; -import io.nats.client.impl.Headers; import io.nats.vertx.NatsClient; import io.nats.vertx.NatsOptions; -import io.vertx.codegen.annotations.Nullable; -import io.vertx.core.Future; -import io.vertx.core.Handler; -import io.vertx.core.Promise; import io.vertx.core.Vertx; -import io.vertx.core.eventbus.EventBus; -import io.vertx.core.eventbus.MessageConsumer; -import io.vertx.core.json.JsonObject; -import io.vertx.core.logging.Logger; -import io.vertx.core.logging.LoggerFactory; -import org.entcore.broker.api.dto.NATSResponseDTO; -import org.entcore.broker.listener.BrokerListener; -import org.entcore.broker.nats.model.NATSContract; -import org.entcore.broker.nats.model.NATSEndpoint; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.*; -import java.util.concurrent.atomic.AtomicLong; - -import static org.apache.commons.lang3.StringUtils.isBlank; - -public class NATSBrokerClient implements BrokerClient { - - private static final Logger log = LoggerFactory.getLogger(NATSBrokerClient.class); - - private final NatsClient natsClient; - private final ObjectMapper mapper = new ObjectMapper(); - private final String charset = "UTF-8"; - - private final long defaultTimeout; - private final Vertx vertx; - private final String serverId = UUID.randomUUID().toString(); - private final Set subscriptions = new HashSet<>(); - private final Map listeners = new HashMap<>(); - private final String queueName; - private MessageConsumer publishConsumer; - - public NATSBrokerClient(final Vertx vertx) { - this.vertx = vertx; - final JsonObject conf = vertx.getOrCreateContext().config(); - final JsonObject natsRawConf = conf.getJsonObject("nats"); - defaultTimeout = conf.getLong("request-default-imeout", 5000L); - queueName = conf.getString("queue-name", "entcore"); - io.nats.client.Options.Builder builder = new io.nats.client.Options.Builder(getNatsProperties(natsRawConf)); - final NatsOptions natsOptions = new NatsOptions() - .setNatsBuilder(builder) - .setVertx(vertx); - natsClient = NatsClient.create(natsOptions); - } - - @Override - public Future start() { - assertNatsClient("cannot.start.null.client"); - return natsClient.connect() - .onSuccess(e -> log.info("NATS client connected on " + natsClient.getConnection().getConnectedUrl())) - .compose(e -> loadListeners("META-INF/nats.json")) - .onSuccess(this::registerListeners) - .compose(e -> this.loadListeners("META-INF/broker-proxy.nats.json")) - .onSuccess(this::registerProxies) - .compose(e -> this.setupEventBusPublishHandler()) - .compose(e -> this.setupEventBusRequestHandler()) - .mapEmpty(); - } - - private Future setupEventBusPublishHandler() { - final Promise promise = Promise.promise(); - // Unregister any existing consumer - if(this.publishConsumer != null){ - this.publishConsumer.unregister(); +import org.entcore.broker.config.NATSBrokersConfig; + +import java.util.Arrays; +import java.util.List; + +/** + * Legacy NATS broker client for single connection. + * + * @deprecated Use {@link NATSMultiBrokerClient} for new implementations. + * This class is maintained for backward compatibility only. + * + * Migration guide: + * - Replace NATSBrokerClient with NATSMultiBrokerClient configured with a single broker + * for equivalent functionality with better architecture and multi-broker support. + */ +@Deprecated +public class NATSBrokerClient extends AbstractNATSBrokerClient { + + private final NatsClient natsClient; + private final String queueName; + + /** + * Creates a NATS broker client from Vertx configuration. + * This is the legacy constructor for backward compatibility. + * Uses NATSBrokersConfig in legacy mode (single broker). + * + * @param vertx The Vert.x instance + * @param config The NATSBrokersConfig + */ + public NATSBrokerClient(final Vertx vertx, final NATSBrokersConfig config) { + super(vertx); + this.queueName = vertx.getOrCreateContext().config().getString("queue-name", "entcore"); + + // will use legacy "nats" config + final NATSBrokersConfig.NATSBrokerConfig brokerConfig = config.getDefaultBroker(); + + final NatsOptions natsOptions = new NatsOptions() + .setNatsBuilder(brokerConfig.getOptionsBuilder()) + .setVertx(vertx); + this.natsClient = NatsClient.create(natsOptions); } - // Set up an event bus handler for "broker.publish" messages - this.publishConsumer = vertx.eventBus().consumer("broker.publish", message -> { - final JsonObject body = message.body(); - final String subject = body.getString("subject"); - final String messageStr = body.getString("message"); - - if (subject == null || subject.isEmpty()) { - message.fail(400, "Subject cannot be empty"); - return; - } - - try { - // If the message is null, send an empty message - final byte[] payload = messageStr != null ? messageStr.getBytes(charset) : new byte[0]; - - natsClient.publish(subject, payload) - .onSuccess(v -> { - log.debug("Successfully published message to subject: " + subject); - message.reply(null); // Empty reply to indicate success - }) - .onFailure(err -> { - log.error("Failed to publish message to subject: " + subject, err); - message.fail(500, err.getMessage()); - }); - } catch (Exception e) { - log.error("Error publishing message to subject: " + subject, e); - message.fail(500, e.getMessage()); - } - }); - - log.info("Set up event bus handler for broker.publish"); - promise.complete(); - - return promise.future(); - } - - private Future setupEventBusRequestHandler() { - final Promise promise = Promise.promise(); - // Set up an event bus handler for "broker.request" messages - vertx.eventBus().consumer("broker.request", message -> { - final JsonObject body = message.body(); - final String subject = body.getString("subject"); - final String requestStr = body.getString("message"); - final long timeout = body.getLong("timeout", defaultTimeout); - - if (subject == null || subject.isEmpty()) { - message.fail(400, "Subject cannot be empty"); - return; - } - - try { - // If the message is null, send an empty message - final Object requestObj = requestStr != null ? mapper.readValue(requestStr, Object.class) : null; - - this.request(subject, requestObj, timeout) - .onSuccess(response -> { - try { - // Serialize the response to JSON - final String responseJson = mapper.writeValueAsString(response); - message.reply(responseJson); - } catch (Exception e) { - log.error("Error serializing response", e); - message.fail(500, e.getMessage()); - } - }) - .onFailure(err -> { - log.error("Failed to make request to subject: " + subject, err); - message.fail(500, err.getMessage()); - }); - } catch (Exception e) { - log.error("Error processing request message for subject: " + subject, e); - message.fail(500, e.getMessage()); - } - }); - - log.info("Set up event bus handler for broker.request"); - promise.complete(); - - return promise.future(); - } - - private void registerProxies(NATSContract contract) { - final EventBus eb = this.vertx.eventBus(); - for (NATSEndpoint endpoint : contract.getEndpoints()) { - if(!endpoint.isProxy()) { - continue; - } - try { - final Handler messageHandler = getProxyMessageHandler(eb); - final Future future; - if(endpoint.isBroadcast()) { - future = this.natsClient.subscribe(transformToWildcard(endpoint.getSubject()), messageHandler); - } else { - future = this.natsClient.subscribe(transformToWildcard(endpoint.getSubject()), this.queueName, messageHandler); - } - future.onSuccess(e -> log.info("Registered proxy for subject: " + endpoint.getSubject())) - .onFailure(th -> log.error("Error while registering proxy for subject: " + endpoint.getSubject(), th)); - } catch (Exception e) { - throw new RuntimeException("Error while registering listener for subject: " + endpoint, e); - } + + // ============================================================ + // ABSTRACT METHODS IMPLEMENTATION + // ============================================================ + + @Override + protected NatsClient getNatsClientForSubject(final String subject) { + // Single broker always returns the same client + return natsClient; } - } - - private Handler getProxyMessageHandler(final EventBus eb) { - return msg -> { - final Promise promise = Promise.promise(); - try { - eb.request(msg.getSubject(), getDataFromMessage(msg)) - .onSuccess(response -> { - try { - promise.tryComplete(response.body()); - } catch (Exception e) { - log.error("Error serializing response to JSON", e); - promise.tryFail(e); - } - }) - .onFailure(th -> { - log.error("Error calling subject " + msg.getSubject(), th); - promise.tryFail(th); - }); - } catch (IOException e) { - promise.tryFail(e); - } - promise.future().onSuccess(response -> { - try { - final byte[] payload = ((String) response).getBytes(charset); - natsClient.publish(msg.getReplyTo(), payload); - } catch (Exception e) { - sendError(msg, e); - } - }).onFailure(th -> { - sendError(msg, th); - }); - }; - } - - - private @Nullable Object getDataFromMessage(Message msg) throws IOException { - byte[] data = msg.getData(); - final JsonNode rootNode = mapper.readTree(data); - JsonNode dataNode = rootNode.get("data"); - if (dataNode != null) { - data = dataNode.toString().getBytes(StandardCharsets.UTF_8); + + @Override + protected List getAllNatsClients() { + // Single broker has only one client + return Arrays.asList(natsClient); } - return data; - } - - private void sendError(Message msg, Throwable e) { - final String message = e.getMessage() == null ? String.valueOf(e) : e.getMessage(); - final NATSResponseDTO natsResponseDTO = new NATSResponseDTO(null, message, false, null); - try { - natsClient.publish(msg.getReplyTo(), mapper.writeValueAsBytes(natsResponseDTO)); - } catch (JsonProcessingException ex) { - log.error("Cannot serialize error", ex); - natsClient.publish(msg.getReplyTo(), message.getBytes(StandardCharsets.UTF_8)); + + @Override + protected String getQueueName() { + return queueName; } - } - - private void registerListeners(NATSContract contracts) { - for (NATSEndpoint endpoint : contracts.getEndpoints()) { - try { - final Class listenerClass = Class.forName(endpoint.getClassName()); - final Object listener = getListener(listenerClass); - final Class requestType = Class.forName(endpoint.getRequestType()); - final Class responseType = isBlank(endpoint.getResponseType()) ? Void.class : Class.forName(endpoint.getRequestType()); - final Method handler = listenerClass.getMethod(endpoint.getMethodName(), requestType); - //noinspection unchecked - this.subscribe(endpoint.getSubject(), new BrokerListener() { - @Override - public Class getRequestType() { - return requestType; - } - - @Override - public Class getResponseType() { - return responseType; - } - - @Override - public Future onMessage(Object request, String subject) { - try { - Object response = handler.invoke(listener, request); - if(response instanceof Future) { - return (Future) response; - } else { - return Future.succeededFuture(response); - } - } catch (Exception e) { - log.error("Error invoking method", e); - return Future.failedFuture(e); - } - } - }).onSuccess(v -> { - subscriptions.add(endpoint.getSubject()); - }).onFailure(e -> { - log.error("Error while subscribing to subject: " + endpoint.getSubject(), e); - }); - } catch (Exception e) { - throw new RuntimeException("Error while registering listener for subject: " + endpoint, e); - } - } - } - - private Object getListener(Class listenerClass) { - return listeners.computeIfAbsent(listenerClass.getCanonicalName(), k -> { - try { - return listenerClass.getConstructor(Vertx.class).newInstance(vertx); - } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { - throw new IllegalStateException("Could not create listener of type " + listenerClass, e); - } - }); - } - - @Override - public Future sendMessage(String subject, K message) { - assertNatsClient("cannot.send.message.null.client"); - final byte[] payload; - try { - payload = mapper.writeValueAsString(message).getBytes(charset); - return natsClient.publish(subject, payload) - .onFailure(e -> log.error("Error while sending message to NATS", e)); - } catch (Exception e) { - log.error("An error occurred while serializing message to JSON : " + message, e); - return Future.failedFuture(e); - } - } - - @Override - public Future request(String subject, K message) { - return this.request(subject, message, defaultTimeout); - } - - @Override - public Future request(String subject, K message, long timeout) { - assertNatsClient("cannot.request.message.null.client"); - try { - final byte[] payload = mapper.writeValueAsString(message).getBytes(charset); - - Promise future = Promise.promise(); - // Start by creating a subscription for the reply which will be where the response will be sent - natsClient.request(subject, payload) - .onSuccess(e -> { - log.debug("Message sent to subject: " + subject); - try { - String responseStr = new String(e.getData(), charset); - @SuppressWarnings("unchecked") - V response = mapper.readValue(responseStr, (Class) Object.class); - future.complete(response); - } catch(Exception err) { - future.fail(err); - } - }) - .onFailure(e -> { - log.error("Error while sending message to NATS", e); - future.fail(e); - }); - return future.future(); - } catch (Exception e) { - log.error("Error while serializing message to JSON", e); - return Future.failedFuture(e); - } - } - - @Override - public Future subscribe(String subject, BrokerListener listener) { - assertNatsClient("cannot.subscribe.null.client"); - if (subscriptions.contains(subject)) { - throw new IllegalStateException("already.listening.on.subject." + subject); - } - return natsClient.subscribe(transformToWildcard(subject), this.queueName, msg -> { - try { - final K request = mapper.readValue(new String(msg.getData(), charset), listener.getRequestType()); - final Future futureResponse = listener.onMessage(request, msg.getSubject()); - final String replyTo = msg.getReplyTo(); - if (replyTo != null && !replyTo.trim().isEmpty()) { - futureResponse.onSuccess(response -> { - try { - final byte[] payload = mapper.writeValueAsString(response).getBytes(charset); - natsClient.publish(replyTo, payload); - } catch (Exception e) { - log.error("Error serializing response to JSON", e); - } - }).onFailure(e -> { - log.error("Error while processing message", e); - }); - } - } catch (Exception e) { - log.error("Error deserializing request : " + msg, e); - } - }) - .onSuccess(e -> log.info("Listening on subject: " + subject)) - .onFailure(th -> log.error("Failed to subscribe to subject: " + subject, th)); - } - - @Override - public Future close() { - assertNatsClient("cannot.close.null.client"); - return natsClient.close(); - } - - private void assertNatsClient(final String message) { - if (natsClient == null) { - throw new IllegalStateException(message); - } - } - - private Properties getNatsProperties(JsonObject natsRawConf) { - Properties properties = new Properties(); - if (natsRawConf != null) { - natsRawConf.getMap().forEach((key, value) -> { - if (value instanceof String) { - properties.put(key, value); - } else if (value instanceof Number) { - properties.put(key, ((Number) value).intValue()); - } else if (value instanceof Boolean) { - properties.put(key, value); - } - }); - } - return properties; - } - - @Override - public Future unsubscribe(String subject) { - assertNatsClient("cannot.unsubscribe.null.client"); - return natsClient.unsubscribe(subject) - .onSuccess(e -> log.info("Successfully unsubscribed from subject: " + subject)) - .onFailure(e -> log.error("Error while unsubscribing from subject: " + subject, e)); - } - - private Future loadListeners(final String natsJsonPath) { - final InputStream is = this.getClass().getClassLoader().getResourceAsStream(natsJsonPath); - if (is == null) { - log.error("Cannot find " + natsJsonPath + " file in classpath"); - return Future.failedFuture(new FileNotFoundException("Cannot find " + natsJsonPath + " file in classpath")); - } else { - try { - final NATSContract contracts = this.mapper.readValue(is, NATSContract.class); - return Future.succeededFuture(contracts); - } catch (IOException e) { - return Future.failedFuture(e); - } - } - } - - /** - * Transforms a subject pattern with {param} placeholders to a NATS wildcard pattern - * Example: "users.{id}.profile" -> "users.*.profile" - * - * @param subject The original subject pattern with {param} placeholders - * @return A NATS-compatible wildcard pattern - */ - private String transformToWildcard(String subject) { - if (subject == null || !subject.contains("{")) { - return subject; - } - // Simple regex to replace all {param} patterns with NATS wildcards (*) - return subject.replaceAll("\\{[^}]+\\}", "*"); - } - } diff --git a/broker-parent/broker/src/main/java/org/entcore/broker/client/NATSMultiBrokerClient.java b/broker-parent/broker/src/main/java/org/entcore/broker/client/NATSMultiBrokerClient.java new file mode 100644 index 0000000000..1deaccd8de --- /dev/null +++ b/broker-parent/broker/src/main/java/org/entcore/broker/client/NATSMultiBrokerClient.java @@ -0,0 +1,122 @@ +package org.entcore.broker.client; + +import io.nats.vertx.NatsClient; +import io.nats.vertx.NatsOptions; +import io.vertx.core.Vertx; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; +import org.entcore.broker.config.NATSBrokersConfig; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + *

+ * NATSMultiBrokerClient manages multiple NATS connections and provides advanced subject-based routing. + *
+ * Features: + *

    + *
  • Supports multiple NATS clusters, each with its own configuration and credentials.
  • + *
  • Subject-based routing: each broker can define routing patterns using NATS wildcards (*, >).
  • + *
  • Automatic selection of the correct NATS client for each subject, based on the routing list.
  • + *
  • Fallback to a default broker if no routing rule matches.
  • + *
  • Full compatibility with the NATSBrokersConfig configuration system (see that class for config examples).
  • + *
+ * + * How routing works: + *
    + *
  • When a message is published or a subscription is created, the subject is matched against each broker's routing patterns.
  • + *
  • The first matching non-default broker is selected; otherwise, the default broker is used.
  • + *
  • Routing patterns support NATS wildcards (* for a single segment, > for multiple segments).
  • + *
  • Overlapping and catch-all rules are supported (e.g., * or >).
  • + *
+ * + * Usage notes: + *
    + *
  • Legacy single-broker deployments should use NATSBrokerClient for backward compatibility.
  • + *
  • See NATSBrokersConfig for configuration details and examples.
  • + *
+ */ +public class NATSMultiBrokerClient extends AbstractNATSBrokerClient { + + private static final Logger log = LoggerFactory.getLogger(NATSMultiBrokerClient.class); + + private final Map clients; + private final NATSBrokersConfig config; + private final String queueName; + + /** + * Creates a multi-broker NATS client from configuration. + * + * @param vertx The Vert.x instance + * @param config The brokers configuration + */ + public NATSMultiBrokerClient(Vertx vertx, NATSBrokersConfig config) { + super(vertx); + this.config = config; + this.clients = new HashMap<>(); + this.queueName = vertx.getOrCreateContext().config().getString("queue-name", "entcore"); + + // Create a NatsClient for each broker configuration + for (NATSBrokersConfig.NATSBrokerConfig brokerConfig : config.getBrokers()) { + NatsClient client = createNatsClient(vertx, brokerConfig); + clients.put(brokerConfig.getName(), client); + log.info("Created NATS client for broker: " + brokerConfig.getName()); + } + } + + /** + * Creates a NatsClient from broker configuration. + * + * @param vertx The Vert.x instance + * @param brokerConfig The broker configuration + * @return The created NatsClient + */ + private NatsClient createNatsClient(Vertx vertx, NATSBrokersConfig.NATSBrokerConfig brokerConfig) { + final NatsOptions natsOptions = new NatsOptions() + .setNatsBuilder(brokerConfig.getOptionsBuilder()) + .setVertx(vertx); + return NatsClient.create(natsOptions); + } + + // ============================================================ + // ABSTRACT METHODS IMPLEMENTATION + // ============================================================ + + @Override + protected NatsClient getNatsClientForSubject(final String subject) { + // Use routing configuration to find the appropriate broker + NATSBrokersConfig.NATSBrokerConfig brokerConfig = config.getBrokerForSubject(subject); + NatsClient client = clients.get(brokerConfig.getName()); + + if (client == null) { + log.warn("No broker client found for subject: " + subject + ", using default"); + // Try to get default broker + brokerConfig = config.getDefaultBroker(); + if (brokerConfig != null) { + client = clients.get(brokerConfig.getName()); + } + + if (client == null && !clients.isEmpty()) { + // Fallback to first available client + client = clients.values().iterator().next(); + log.warn("Using fallback client for subject: " + subject); + } + } + + return client; + } + + @Override + protected List getAllNatsClients() { + // Multi-broker returns all managed clients + return new ArrayList<>(clients.values()); + } + + @Override + protected String getQueueName() { + return queueName; + } +} diff --git a/broker-parent/broker/src/main/java/org/entcore/broker/client/NoOpBrokerClient.java b/broker-parent/broker/src/main/java/org/entcore/broker/client/NoOpBrokerClient.java index bc1457e442..706ed1af39 100644 --- a/broker-parent/broker/src/main/java/org/entcore/broker/client/NoOpBrokerClient.java +++ b/broker-parent/broker/src/main/java/org/entcore/broker/client/NoOpBrokerClient.java @@ -3,6 +3,7 @@ import io.vertx.core.Future; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; +import org.apache.commons.lang3.NotImplementedException; import org.entcore.broker.listener.BrokerListener; /** @@ -97,7 +98,12 @@ public Future subscribe(String subject, BrokerListener listen return Future.succeededFuture(); } - /** + @Override + public Future sendRawMessage(String subject, String message) { + throw new NotImplementedException(); + } + + /** * Closes the NoOpBrokerClient. Unregisters the event bus consumer and always succeeds. */ @Override diff --git a/broker-parent/broker/src/main/java/org/entcore/broker/client/RESTBrokerClient.java b/broker-parent/broker/src/main/java/org/entcore/broker/client/RESTBrokerClient.java index ae7dd34164..cc3a5fe4fd 100644 --- a/broker-parent/broker/src/main/java/org/entcore/broker/client/RESTBrokerClient.java +++ b/broker-parent/broker/src/main/java/org/entcore/broker/client/RESTBrokerClient.java @@ -19,7 +19,12 @@ public Future sendMessage(String subject, K message) { throw new NotImplementedException(); } - @Override + @Override + public Future sendRawMessage(String subject, String message) { + throw new NotImplementedException(); + } + + @Override public Future request(String subject, K message) { throw new NotImplementedException(); } diff --git a/broker-parent/broker/src/main/java/org/entcore/broker/config/NATSBrokersConfig.java b/broker-parent/broker/src/main/java/org/entcore/broker/config/NATSBrokersConfig.java new file mode 100644 index 0000000000..d980a5b65d --- /dev/null +++ b/broker-parent/broker/src/main/java/org/entcore/broker/config/NATSBrokersConfig.java @@ -0,0 +1,262 @@ +package org.entcore.broker.config; + +import io.nats.client.Options; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; + +import java.util.*; +import java.util.regex.Pattern; + +/** + *

+ * NATSBrokersConfig provides a unified configuration mechanism for NATS brokers in both legacy (single broker) and modern (multi-broker) modes.
+ * Features: + *

    + *
  • Supports both legacy and multi-broker configuration using a single nats key.
  • + *
  • Automatic detection of configuration mode based on the type of nats (JsonObject or JsonArray).
  • + *
  • Flexible routing: each broker can define routing patterns using NATS wildcards (*, >).
  • + *
+ * + *

NATS Wildcards Syntax

+ *

+ * NATS uses a subject-based routing system with two types of wildcards: + *

    + *
  • * (star): Matches exactly one subject segment.
    + * Example: foo.* matches foo.bar but not foo.bar.baz.
  • + *
  • > (chevron): Matches zero or more subject segments (greedy).
    + * Example: foo.> matches foo.bar, foo.bar.baz, foo, etc.
  • + *
+ *

+ * This is the standard NATS subject matching syntax. For more details, see the NATS documentation. + * + *

    + *
  • Default broker selection: at least one broker must be marked as default, otherwise the first is used.
  • + *
  • Full support for all NATS Java client properties (e.g., io.nats.client.url, io.nats.client.username, etc.).
  • + *
  • Backward compatibility: legacy configuration is still supported for seamless migration.
  • + *
  • Security: all sensitive connection parameters (username, password, TLS, etc.) are supported via the NATS client properties.
  • + *
  • Routing patterns can be as broad or as specific as needed, and support overlapping rules.
  • + *
  • Multi-broker mode enables advanced scenarios such as routing by subject
  • + *
+ * + * Example legacy configuration (single broker): + *
+ * {
+ *   "nats": {
+ *     "io.nats.client.url": "tls://nats.example.com:4222",
+ *     "io.nats.client.username": "demo",
+ *     "io.nats.client.password": "demo",
+ *     "io.nats.client.pinginterval": "PT15s"
+ *   },
+ *   "queue-name": "entcore"
+ * }
+ * 
+ * + * Example multi-broker configuration: + *
+ * {
+ *   "nats": [
+ *     {
+ *       "name": "broker1",
+ *       "default": true,
+ *       "routing": ["foo.*", "bar.>", "*"],
+ *       "client": {
+ *         "io.nats.client.url": "tls://broker1.example.com:4222",
+ *         "io.nats.client.username": "user1",
+ *         "io.nats.client.password": "pass1",
+ *         "io.nats.client.pinginterval": "PT10s"
+ *       }
+ *     },
+ *     {
+ *       "name": "broker2",
+ *       "routing": ["baz.>", "qux.*"],
+ *       "client": {
+ *         "io.nats.client.url": "tls://broker2.example.com:4222",
+ *         "io.nats.client.username": "user2",
+ *         "io.nats.client.password": "pass2",
+ *         "io.nats.client.pinginterval": "PT20s"
+ *       }
+ *     }
+ *   ],
+ *   "queue-name": "entcore"
+ * }
+ * 
+ */ +public class NATSBrokersConfig { + + private static final Logger log = LoggerFactory.getLogger(NATSBrokersConfig.class); + + private final List brokers; + private final boolean isMultiBrokerMode; + + /** + * Configuration for a single NATS broker + */ + public static class NATSBrokerConfig { + private final String name; + private final boolean isDefault; + private final List routing; + private final Options.Builder optionsBuilder; + + public NATSBrokerConfig(final String name, final boolean isDefault, final List routing, final Options.Builder optionsBuilder) { + this.name = name; + this.isDefault = isDefault; + this.routing = routing != null ? routing : Collections.emptyList(); + this.optionsBuilder = optionsBuilder; + } + + public String getName() { return name; } + public boolean isDefault() { return isDefault; } + public List getRouting() { return routing; } + public io.nats.client.Options.Builder getOptionsBuilder() { return optionsBuilder; } + } + + /** + * Create configuration from Vertx config + */ + public NATSBrokersConfig(final Vertx vertx) { + final JsonObject config = vertx.getOrCreateContext().config(); + final Object natsValue = config.getValue("nats"); + if (natsValue instanceof JsonArray) { + // Multi-broker configuration + this.isMultiBrokerMode = true; + this.brokers = parseMultiBrokerConfig((JsonArray) natsValue); + log.info("Initialized multi-broker mode with " + brokers.size() + " brokers"); + } else if (natsValue instanceof JsonObject) { + // Legacy single broker configuration + this.isMultiBrokerMode = false; + this.brokers = parseLegacyConfig((JsonObject) natsValue); + log.info("Initialized legacy single broker mode"); + } else { + throw new IllegalStateException("No NATS configuration found. Expected 'nats' as JsonObject (legacy) or JsonArray (multi-broker) in configuration"); + } + } + + private List parseMultiBrokerConfig(final JsonArray natsBrokersArray) { + final List configs = new ArrayList<>(); + + for (int i = 0; i < natsBrokersArray.size(); i++) { + final JsonObject brokerObj = natsBrokersArray.getJsonObject(i); + final String name = brokerObj.getString("name"); + if (name == null || name.trim().isEmpty()) { + throw new IllegalStateException("Broker name is required for multi-broker configuration"); + } + + final boolean isDefault = brokerObj.getBoolean("default", false); + final JsonArray routingArray = brokerObj.getJsonArray("routing"); + final List routing = new ArrayList<>(); + if (routingArray != null) { + for (int j = 0; j < routingArray.size(); j++) { + routing.add(routingArray.getString(j)); + } + } + + final JsonObject natsConfig = brokerObj.getJsonObject("client"); + if (natsConfig == null) { + throw new IllegalStateException("NATS configuration is required for broker: " + name); + } + + final Options.Builder optionsBuilder = createOptionsBuilder(natsConfig); + + configs.add(new NATSBrokerConfig(name, isDefault, routing, optionsBuilder)); + } + + // Ensure at least one default broker + final boolean hasDefault = configs.stream().anyMatch(NATSBrokerConfig::isDefault); + if (!hasDefault && !configs.isEmpty()) { + // Create new config with isDefault=true + final NATSBrokerConfig firstConfig = configs.get(0); + final NATSBrokerConfig defaultConfig = new NATSBrokerConfig( + firstConfig.getName(), + true, + firstConfig.getRouting(), + firstConfig.getOptionsBuilder() + ); + configs.set(0, defaultConfig); + } + + return configs; + } + + private List parseLegacyConfig(final JsonObject natsConfig) { + final Options.Builder optionsBuilder = createOptionsBuilder(natsConfig); + final NATSBrokerConfig config = new NATSBrokerConfig("default", true, Arrays.asList("*"), optionsBuilder); + return Arrays.asList(config); + } + + private Options.Builder createOptionsBuilder(final JsonObject natsConfig) { + final Properties properties = new Properties(); + natsConfig.getMap().forEach((key, value) -> { + if (value instanceof String) { + properties.put(key, value); + } else if (value instanceof Number) { + properties.put(key, ((Number) value).intValue()); + } else if (value instanceof Boolean) { + properties.put(key, value); + } + }); + return new Options.Builder(properties); + } + + public List getBrokers() { + return brokers; + } + + public boolean isMultiBrokerMode() { + return isMultiBrokerMode; + } + + public NATSBrokerConfig getDefaultBroker() { + return brokers.stream() + .filter(NATSBrokerConfig::isDefault) + .findFirst() + .orElse(brokers.get(0)); + } + + /** + * Get the broker configuration for a given subject based on routing rules + */ + public NATSBrokerConfig getBrokerForSubject(final String subject) { + // Check specific routing first (non-default brokers) + for (NATSBrokerConfig broker : brokers) { + if (!broker.isDefault() && matchesRouting(subject, broker.getRouting())) { + return broker; + } + } + // Fall back to default broker + return getDefaultBroker(); + } + + /** + * Check if a subject matches any of the routing patterns + */ + private boolean matchesRouting(String subject, List routing) { + for (String pattern : routing) { + if (matchesPattern(subject, pattern)) { + return true; + } + } + return false; + } + + /** + * Match a subject against a NATS-style pattern + * Supports wildcards: * (single segment) and > (multiple segments) + */ + private boolean matchesPattern(String subject, String pattern) { + if ("*".equals(pattern)) { + return true; // Match everything + } + + // Convert NATS pattern to regex + final String regex = pattern + .replace(".", "\\.") // Escape dots + .replace("*", "[^.]+") // * matches single segment + .replace(">", ".*"); // > matches rest + + final Pattern compiledPattern = Pattern.compile("^" + regex + "$"); + return compiledPattern.matcher(subject).matches(); + } +} \ No newline at end of file diff --git a/broker-parent/broker/src/test/java/org/entcore/broker/config/NATSBrokersConfigTest.java b/broker-parent/broker/src/test/java/org/entcore/broker/config/NATSBrokersConfigTest.java new file mode 100644 index 0000000000..4411160fe4 --- /dev/null +++ b/broker-parent/broker/src/test/java/org/entcore/broker/config/NATSBrokersConfigTest.java @@ -0,0 +1,180 @@ +package org.entcore.broker.config; + +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +@RunWith(VertxUnitRunner.class) +public class NATSBrokersConfigTest { + + private Vertx vertx; + + @Before + public void setUp() { + vertx = Vertx.vertx(); + } + + @After + public void tearDown() { + if (vertx != null) { + vertx.close(); + } + } + + @Test + public void testLegacyConfig() { + // Given + JsonObject config = new JsonObject() + .put("nats", new JsonObject() + .put("io.nats.client.url", "nats://localhost:4222") + .put("io.nats.client.username", "test") + ); + vertx.getOrCreateContext().config().mergeIn(config); + + // When + NATSBrokersConfig brokersConfig = new NATSBrokersConfig(vertx); + + // Then + assertFalse(brokersConfig.isMultiBrokerMode()); + assertEquals(1, brokersConfig.getBrokers().size()); + assertEquals("default", brokersConfig.getBrokers().get(0).getName()); + assertTrue(brokersConfig.getBrokers().get(0).isDefault()); + assertEquals(1, brokersConfig.getBrokers().get(0).getRouting().size()); + assertEquals("*", brokersConfig.getBrokers().get(0).getRouting().get(0)); + } + + @Test + public void testMultiBrokerConfig() { + // Given + JsonObject config = new JsonObject() + .put("nats", new io.vertx.core.json.JsonArray() + .add(new JsonObject() + .put("name", "default") + .put("default", true) + .put("routing", new io.vertx.core.json.JsonArray().add("*")) + .put("client", new JsonObject() + .put("io.nats.client.url", "nats://localhost:4222") + ) + ) + .add(new JsonObject() + .put("name", "analytics") + .put("routing", new io.vertx.core.json.JsonArray().add("analytics.*")) + .put("client", new JsonObject() + .put("io.nats.client.url", "nats://analytics:4222") + ) + ) + ); + vertx.getOrCreateContext().config().mergeIn(config); + + // When + NATSBrokersConfig brokersConfig = new NATSBrokersConfig(vertx); + + // Then + assertTrue(brokersConfig.isMultiBrokerMode()); + assertEquals(2, brokersConfig.getBrokers().size()); + + // Check default broker + NATSBrokersConfig.NATSBrokerConfig defaultBroker = brokersConfig.getBrokers().get(0); + assertEquals("default", defaultBroker.getName()); + assertTrue(defaultBroker.isDefault()); + assertEquals("*", defaultBroker.getRouting().get(0)); + + // Check analytics broker + NATSBrokersConfig.NATSBrokerConfig analyticsBroker = brokersConfig.getBrokers().get(1); + assertEquals("analytics", analyticsBroker.getName()); + assertFalse(analyticsBroker.isDefault()); + assertEquals("analytics.*", analyticsBroker.getRouting().get(0)); + } + + @Test + public void testRouting() { + // Given + JsonObject config = new JsonObject() + .put("nats", new io.vertx.core.json.JsonArray() + .add(new JsonObject() + .put("name", "default") + .put("default", true) + .put("routing", new io.vertx.core.json.JsonArray().add("*")) + .put("client", new JsonObject().put("io.nats.client.url", "nats://default:4222")) + ) + .add(new JsonObject() + .put("name", "analytics") + .put("routing", new io.vertx.core.json.JsonArray().add("analytics.*.*").add("logs.*")) + .put("client", new JsonObject().put("io.nats.client.url", "nats://analytics:4222")) + ) + .add(new JsonObject() + .put("name", "blog") + .put("routing", new io.vertx.core.json.JsonArray().add("blog.>")) + .put("client", new JsonObject().put("io.nats.client.url", "nats://blog:4222")) + ) + ); + vertx.getOrCreateContext().config().mergeIn(config); + NATSBrokersConfig brokersConfig = new NATSBrokersConfig(vertx); + + // When & Then + assertEquals("default", brokersConfig.getBrokerForSubject("system.health").getName()); + assertEquals("analytics", brokersConfig.getBrokerForSubject("analytics.user.created").getName()); + assertEquals("analytics", brokersConfig.getBrokerForSubject("logs.error").getName()); + assertEquals("blog", brokersConfig.getBrokerForSubject("blog.error").getName()); + assertEquals("blog", brokersConfig.getBrokerForSubject("blog.resource.list").getName()); + assertEquals("default", brokersConfig.getBrokerForSubject("unknown.topic").getName()); + } + + @Test + public void testWildcardRouting() { + // Given + JsonObject config = new JsonObject() + .put("nats", new io.vertx.core.json.JsonArray() + .add(new JsonObject() + .put("name", "default") + .put("default", true) + .put("routing", new io.vertx.core.json.JsonArray().add("*")) + .put("client", new JsonObject().put("io.nats.client.url", "nats://default:4222")) + ) + .add(new JsonObject() + .put("name", "events") + .put("routing", new io.vertx.core.json.JsonArray().add("events.>")) + .put("client", new JsonObject().put("io.nats.client.url", "nats://events:4222")) + ) + ); + vertx.getOrCreateContext().config().mergeIn(config); + NATSBrokersConfig brokersConfig = new NATSBrokersConfig(vertx); + + // When & Then + assertEquals("events", brokersConfig.getBrokerForSubject("events.user.created").getName()); + assertEquals("events", brokersConfig.getBrokerForSubject("events.system.notification.email.sent").getName()); + assertEquals("default", brokersConfig.getBrokerForSubject("system.health").getName()); + } + + @Test + public void testAutoDefaultBroker() { + // Given - no default specified + JsonObject config = new JsonObject() + .put("nats", new io.vertx.core.json.JsonArray() + .add(new JsonObject() + .put("name", "broker1") + .put("routing", new io.vertx.core.json.JsonArray().add("topic1.*")) + .put("client", new JsonObject().put("io.nats.client.url", "nats://broker1:4222")) + ) + .add(new JsonObject() + .put("name", "broker2") + .put("routing", new io.vertx.core.json.JsonArray().add("topic2.*")) + .put("client", new JsonObject().put("io.nats.client.url", "nats://broker2:4222")) + ) + ); + vertx.getOrCreateContext().config().mergeIn(config); + + // When + NATSBrokersConfig brokersConfig = new NATSBrokersConfig(vertx); + + // Then + assertTrue(brokersConfig.getDefaultBroker().isDefault()); + assertEquals("broker1", brokersConfig.getDefaultBroker().getName()); + } +} \ No newline at end of file diff --git a/broker-parent/pom.xml b/broker-parent/pom.xml index 1bbb714096..12bc56ee1d 100644 --- a/broker-parent/pom.xml +++ b/broker-parent/pom.xml @@ -14,8 +14,7 @@ pom 2.1.0 - 2.20.5 - + 2.20.5 diff --git a/conversation/frontend/package.json.template b/conversation/frontend/package.json.template index a2b0a7c047..9100582ef6 100644 --- a/conversation/frontend/package.json.template +++ b/conversation/frontend/package.json.template @@ -32,11 +32,11 @@ "typecheck": "tsc -b --noEmit --pretty" }, "dependencies": { - "@edifice.io/react": "2.5.4", - "@edifice.io/bootstrap": "2.5.4", - "@edifice.io/client": "2.5.4", - "@edifice.io/utilities": "2.5.4", - "@edifice.io/tiptap-extensions": "2.5.4", + "@edifice.io/react": "%packageVersion%", + "@edifice.io/bootstrap": "%packageVersion%", + "@edifice.io/client": "%packageVersion%", + "@edifice.io/utilities": "%packageVersion%", + "@edifice.io/tiptap-extensions": "%packageVersion%", "@react-spring/web": "^9.7.5", "@tanstack/react-query": "^5.59.13", "clsx": "^2.1.1", diff --git a/package.json b/package.json index 65d4b35fcc..84412a157c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ent-core", - "version": "6.11.0", + "version": "6.11-develop-pedago.1", "description": "", "main": "gulpfile.js", "directories": { @@ -28,7 +28,7 @@ "angular-sanitize": "1.8.3", "axios": "0.15.3", "core-js": "^2.4.1", - "entcore": "4.8.14", + "entcore": "develop-pedago", "entcore-generic-icons": "https://github.com/edificeio/generic-icons.git", "entcore-toolkit": "^1.0.1", "humane-js": "^3.2.2", @@ -44,6 +44,7 @@ "@types/core-js": "0.9.36", "@types/jasmine": "~2.5.53", "@types/jasminewd2": "~2.0.2", + "@types/jquery": "^3.2.1", "@types/jquery.noty": "2.4.0", "@types/node": "~6.0.60", "angular-router-loader": "0.5.0", @@ -76,8 +77,9 @@ "karma-jasmine": "~1.1.0", "karma-jasmine-html-reporter": "^0.2.2", "merge2": "^1.0.3", - "ode-ngjs-front": "1.4.19", - "ode-ts-client": "1.2.4", + "ode-ngjs-front": "develop-pedago", + "ode-ts-client": "develop-pedago", + "rxjs": "5.4.2", "sass-loader": "^13.0.2", "source-map-loader": "^0.1.5", "ts-loader": "^3.1.1", diff --git a/pom.xml b/pom.xml index 0e87f33c44..31836bb02c 100644 --- a/pom.xml +++ b/pom.xml @@ -57,20 +57,20 @@ - 6.11.0 + 6.11-develop-pedago-SNAPSHOT 4.13.2 1.19.3 - 2.0.1 - 4.0.2 - 3.2.1 - 3.0.3 + 2.0-SNAPSHOT + 4.0-develop-pedago-SNAPSHOT + 3.2-develop-pedago-SNAPSHOT + 3.0-develop-pedago-SNAPSHOT 2.9.4 2.1 1.11.4 20220608.1 2.15.2 - 0.2.3 - 3.0.0 + 0.2-SNAPSHOT + 3.0-SNAPSHOT 3.0.2 0.2.0 3.9 diff --git a/portal/backend/src/main/resources/i18n/fr.json b/portal/backend/src/main/resources/i18n/fr.json index e09a46e803..14091a819e 100644 --- a/portal/backend/src/main/resources/i18n/fr.json +++ b/portal/backend/src/main/resources/i18n/fr.json @@ -709,6 +709,7 @@ "linker.warning": "Recherchez des documents dans vos applications pour créer un lien. N'oubliez pas de les partager pour les rendre accessibles à vos lecteurs.", "lock": "Bloquer", "lool": "Suite Bureautique", + "assistancetic": "Assistance technique et informatique collège", "lystore": "Lystore", "magneto": "Magneto", "manager": "Gérer", diff --git a/portal/frontend/package.json.template b/portal/frontend/package.json.template index a1ebe12beb..bdb49d9f78 100644 --- a/portal/frontend/package.json.template +++ b/portal/frontend/package.json.template @@ -40,7 +40,7 @@ "i18next": "23.8.1", "i18next-http-backend": "2.4.2", "framer-motion": "^12.18.1", - "ode-explorer": "2.4.3", + "ode-explorer": "%packageVersion%", "react": "^18.3.1", "react-dom": "^18.3.1", "react-error-boundary": "4.0.13", 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 diff --git a/workspace/src/main/java/org/entcore/workspace/controllers/WorkspaceController.java b/workspace/src/main/java/org/entcore/workspace/controllers/WorkspaceController.java index 0dfb07f729..2076d38871 100644 --- a/workspace/src/main/java/org/entcore/workspace/controllers/WorkspaceController.java +++ b/workspace/src/main/java/org/entcore/workspace/controllers/WorkspaceController.java @@ -1781,6 +1781,7 @@ public void view(final HttpServerRequest request) { context.put("enableScratch", config.getBoolean("enable-scratch", false)); context.put("enableGeogebra", config.getBoolean("enable-geogebra", false)); context.put("enableNextcloud", config.getBoolean("enable-nextcloud", false)); + context.put("useNextcloudSniplet", config.getBoolean("use-nextcloud-sniplet", true)); context.put("lazyMode", config.getJsonObject("publicConf", new JsonObject()).getBoolean("lazy-mode", false)); context.put("cacheDocTTl", config.getJsonObject("publicConf", new JsonObject()).getInteger("ttl-documents", -1)); context.put("cacheFolderTtl", config.getJsonObject("publicConf", new JsonObject()).getInteger("ttl-folders", -1)); diff --git a/workspace/src/main/resources/i18n/fr.json b/workspace/src/main/resources/i18n/fr.json index 64ecaf3771..4e42567aa1 100644 --- a/workspace/src/main/resources/i18n/fr.json +++ b/workspace/src/main/resources/i18n/fr.json @@ -1,268 +1,308 @@ { - "appDocuments": "Documents ajoutés dans les applis", - "bit": "Bit(s)", - "blocked.extension": "Erreur l'extension du fichier est interdite.", - "blur": "Flouter", - "byte": "Octet(s)", - "cancel": "Annuler", - "choose": "Parcourir", - "comments": "[[document.comments.length]] [[folder.comments.length]] commentaire(s)", - "confirm": "Confirmer", - "confirm.remove": "Attention, si vous supprimez une image ou du son, il ne sera plus visible dans les applications qui l'utilisent (blog, actualités...). Êtes-vous sûr de vouloir supprimer le document ?", - "crop": "Rogner", - "document.error.missing.fileid": "Le fichier recherché n'existe pas", - "document.error.preview.kind.unknown": "Prévisualisation impossible. Le type du document n'a pas été reconnu.", - "document.preview.save.failed": "Une erreur est survenue lors de la génération de la prévisualisation.", - "documents": "Documents personnels", - "dropzone.overlay.action": "J'ai compris", - "dropzone.overlay.label": "Déposez les fichiers", - "dropzone.overlay.solution": "Veuillez importer un ou plusieurs fichiers", - "dropzone.overlay.warning": "Il est impossible d'importer un dossier", - "e400": "L'application a rencontré une erreur. Si ce problème se reproduit, veuillez vous adresser au support.", - "e401": "Vous n'avez pas les droits d'accès pour ce contenu", - "e403": "Le serveur a refusé votre requête", - "e404": "Adresse introuvable", - "e408": "Temps d'attente dépassé, veuillez réessayer", - "e500": "L'application a rencontré une erreur. Si ce problème se reproduit, veuillez vous adresser au support.", - "e504": "La demande a dépassé le délai imparti.", - "externalDocs": "Mes ressources interactives", - "file.too.large": "Espace insuffisant pour stocker le fichier", - "file.too.large.limit": "Fichier(s) trop volumineux. Taille maximum autorisée : ", - "file.too.large.max": "Espace insuffisant pour stocker le(s) fichier(s). Espace restant : ", - "files.header": "Charger plusieurs fichiers", - "files.select": "Sélectionner les fichiers", - "files.select.please": "Veuillez sélectionner un document", - "files.too.large": "Espace insuffisant pour stocker les fichiers.", - "files.warning.identical": "Importer un exemplaire supplémentaire du(des) même(s) fichier(s) ?", - "folder.new": "Créer un dossier", - "folder.new.shared": "Créer un dossier partagé", - "folder.new.shared.title": "Créer un dossier partagé", - "folder.new.title": "Créer un dossier", - "folder.zip.upload.notfound": "Le téléchargement du ZIP a échoué. Veuillez réessayer.", - "folder.zip.upload.partial": "Certains élements du fichiers ZIP n'ont pas pu être importé", - "folderpicker.folder.edit": "Créer un nouveau dossier", - "from": "Auteur", - "gb": "Go", - "gigabyte": "Giga-octet(s)", - "group.AdminLocal": "Administrateurs locaux de {0}.", - "group.Direction": "Directeurs de {0}.", - "group.Discipline": "Enseignants de discipline {0}.", - "group.Func": "Personnels de fonction {0}.", - "group.Guest": "Invités du groupe {0}.", - "group.HeadTeacher": "Professeurs principaux de {0}.", - "group.Personnel": "Personnels du groupe {0}.", - "group.Relative": "Parents du groupe {0}.", - "group.Student": "Élèves du groupe {0}.", - "group.Teacher": "Enseignants du groupe {0}.", - "group.class.Guest": "Invités de la classe {0}.", - "group.class.Personnel": "Personnels de la classe {0}.", - "group.class.Relative": "Parents de la classe {0}.", - "group.class.Student": "Élèves de la classe {0}.", - "group.class.Teacher": "Enseignants de la classe {0}.", - "group.contrib": "Contributeurs de la communauté {0}.", - "group.manager": "Gestionnaires de la communauté {0}.", - "group.read": "Lecteurs de la communauté {0}.", - "group.school.Guest": "Invités de l'{0}.", - "group.school.Personnel": "Personnels de l'{0}.", - "group.school.Relative": "Parents de l'{0}.", - "group.school.Student": "Élèves de l'{0}.", - "group.school.Teacher": "Enseignants de l'{0}.", - "import.file": "Importer", - "kilobyte": "Kilo-octet(s)", - "loading": "Chargement", - "max.file.size": "52428800", - "mb": "Mo", - "megabyte": "Méga-octet(s)", - "modified": "Dernière modification", - "move": "Déplacer", - "name": "Titre", - "nofile": "Aucun fichier", - "owner": "Propriétaire", - "quota.title": "Espace utilisé", - "rack": "Mon casier", - "rack.sent.message": "Votre document a bien été envoyé", - "resize": "Redimensionner", - "root": "Racine", - "rotate": "Pivoter", - "seemore": "Voir la suite", - "send": "Envoyer", - "sent": "Date d'envoi", - "share.groups": "Groupes", - "share.me": "Moi", - "share.more": "Voir la suite", - "share.search.placeholder": "Ex : Sabine, Dupont, Enseignants de l'école...", - "share.search.title": "Rechercher des utilisateurs et des groupes", - "share.title": "Partager avec...", - "share.users": "Utilisateurs", - "shared": "Documents partagés", - "shared_tree": "Documents partagés", - "size": "Poids", - "terabyte": "Téra-octet(s)", - "terminate": "Terminer", - "to": "Destinataire", - "trash": "Corbeille", - "tuto.action.open": "Double-cliquez", - "tuto.action.select": "Cliquez", - "tuto.text.open": "pour ouvrir un dossier ou un fichier.", - "tuto.text.select": "pour sélectionner et accéder au menu d'options,", - "tuto.title": "Naviguez au sein de vos documents en vue vignettes :", - "upload": "Envoyer", - "user": "Utilisateur", - "workspace.add.document": "Importer", - "workspace.add.version": "Nouvelle version", - "workspace.comment": "Commenter", - "workspace.comments": "Commentaires", - "workspace.continue": "Continuer", - "workspace.contrib": "Contribuer", - "workspace.contrib.cant": "Vous n'avez pas les droits de contributions dans ce dossier", - "workspace.copied.toowner": "Document(s) copié(s) dans vos documents personnels", - "workspace.copy": "Copier", - "workspace.copy.title": "Copier des fichiers et/ou dossiers", - "workspace.copy.window.action": "Copier", - "workspace.copy.window.finished": "Copie effectuée avec succès!", - "workspace.copy.window.info": "Rechercher et/ou sélectionner le dossier où vous voulez copier", - "workspace.copy.window.processing": "Copie en cours...", - "workspace.copy.window.title": "Copier de(s) fichier(s) et/ou dossier(s)", - "workspace.create.cancel.content": "Si vous ne partagez pas ce dossier, vous ne pouvez pas le laisser à la racine des documents partagés. Voulez-vous le créer dans vos documents personnels ?", - "workspace.create.cancel.delete": "Supprimer le dossier", - "workspace.create.cancel.own": "Créer dans mes documents personnels", - "workspace.create.cancel.title": "Attention!", - "workspace.create.share.warning": "Attention ! Vous devez obligatoirement partager le dossier créé, sinon", - "workspace.create.share.warning2": " créez le dans votre espace personnel", - "workspace.create.workspace": "Importer un document", - "workspace.delete": "Supprimer", - "workspace.delete.confirm.action": "Confirmer la suppression", - "workspace.delete.confirm.content": "En supprimant ces fichiers ou dossiers vous les supprimerez également pour les utilisateurs qui voient ces fichiers ou dossiers ou qui en sont propriétaires.", - "workspace.delete.confirm.title": "Attention!", - "workspace.delete.empty.trash": "Voulez-vous vraiment vider la corbeille ?", - "workspace.delete.version": "Supprimer la version", - "workspace.deleted.message": "L'élément a été supprimé définitivement.", - "workspace.document.comment": "Commenter", - "workspace.document.comment.hide": "Masquer les commentaires", - "workspace.document.comment.show": "Voir les commentaires", - "workspace.document.delete": "Supprimer un document", - "workspace.document.file": "Fichier", - "workspace.document.get": "Lire un document", - "workspace.document.name": "Nom du document", - "workspace.documents.warning.move": "Le déplacement d'un fichier supprimera tous les partages attribués au fichier et lui réattribuera les droits du dossier de destination.", - "workspace.download": "Télécharger", - "workspace.element.uri.notfound": "Les fichiers ou dossiers ont été déplacés dans un dossier auquel vous n'avez pas accès, vous pouvez les retrouver en effectuant une recherche dans tout l'espace documentaire", - "workspace.empty.trash": "Vider la corbeille", - "workspace.empty.trash.confirm": "La corbeille a été vidée.", - "workspace.external.open": "Éditer", - "workspace.files.warning.content": "

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
+

[[ngModel.name]]

+ owner[[ngModel.ownerDisplayName]] +
+
+
+ + +
+
+
+ + + + +
+
+
+
+
+ + + + + + + + +
+ +
+
+
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..14027902e1 --- /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/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/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..c123e3ffc3 --- /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..653f6b392a --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share-documents-options.html @@ -0,0 +1,45 @@ +
+
+ +

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

+ +
+
+
+
+
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..82c0c56a21 100644 --- a/workspace/src/main/resources/public/ts/app.ts +++ b/workspace/src/main/resources/public/ts/app.ts @@ -1,48 +1,82 @@ -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 { syncDocumentViewer } from "./directives/syncDocumentViewer"; +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); ng.directives.push(importFiles); ng.directives.push(fileViewer); +ng.directives.push(syncDocumentViewer); 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..841afa724e --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/contentViewer.component.ts @@ -0,0 +1,544 @@ +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; + + openDocument(document?: SyncDocument): any; + closeViewFile(): void; +} + +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 deleteDocuments( + document: SyncDocument, + ): Promise { + $scope.selectedDocuments.push(document); + const selectedSet: Set = new Set( + $scope.selectedDocuments, + ); + + const paths: Array = Array.from(selectedSet).map( + (doc: SyncDocument) => doc.path + ); + + return nextcloudService.deleteDocuments(model.me.userId, paths); + } + + function processMoveToNextcloud( + document: SyncDocument, + target: SyncDocument, + selectedFolderFromNextcloudTree: SyncDocument, + ): void { + if(target.isStaticFolder && target.staticFolderType === "trashbin") { + deleteDocuments(document) + .then(() => { + updateDocList(selectedFolderFromNextcloudTree); + }) + .catch((err: AxiosError) => { + updateDocList(selectedFolderFromNextcloudTree); + const message: string = + "Error while attempting to delete nextcloud document"; + console.error(message + err.message); + }) + return; + } + + 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.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 { + $scope.openDocument(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..23e279f7ae --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbar.component.ts @@ -0,0 +1,551 @@ +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; + viewFile: SyncDocument; + + 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 viewFile: 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) return; + this.vm.openDocument(); + } + + 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..692ef6e591 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbarShare.components.ts @@ -0,0 +1,96 @@ +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; + 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; + } + } + + 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..f8f15271ba --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/nextcloudFolder.directive.ts @@ -0,0 +1,587 @@ +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(); + + // Resolve user has the following actions: + // 1. Fetch user info + // 1.a) If user exists, fetch user info + // 1.b) If user does not exist, it will create its nextcloud user + nextcloudUserService + .resolveUser(model.me.userId) + .then((user) => { + nextcloudUserService + .getUserInfo(model.me.userId) + .then(async (nextcloudUserInfo: UserNextcloud) => { + $scope.userInfo = nextcloudUserInfo; + $scope.documents = [new SyncDocument().initParent()]; + $scope.initTree($scope.documents); + $scope.initDraggable(); + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get("folder") === "synced") { + await $scope.openDocument($scope.documents[0]); + await $scope.folderTree.openFolder($scope.documents[0]); + $scope.setSwitchDisplayHandler(); + template.open("documents", "nextcloud/content/workspace-nextcloud-content"); + WorkspaceEntcoreUtils.toggleWorkspaceContentDisplay(false); + } + + safeApply($scope); + }) + .catch((err: Error) => { + const message: string = + "Error while attempting to fetch user info"; + console.error(message + err.message); + }); + }) + .catch((err: Error) => { + const message: string = "Error while attempting to resolve user"; + 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); + } + + 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; + }, + }; + }; + + function removeDropTarget(event: DragEvent) { + const target: HTMLElement = event.target as HTMLElement; + const droppableElement: HTMLElement = target.closest('.folder-list-item') || target; + if (droppableElement) { + droppableElement.classList.remove("droptarget"); + } + } + + $scope.resolveDragTarget = async (event: DragEvent): Promise => { + removeDropTarget(event); + // 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/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(); + } + } + }; + }, + }; + }, +]); 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 @@

+
+
+ + + + + +
+