diff --git a/.gitignore b/.gitignore index e0fc77a..1a00e91 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ target out/ *.iml *.ipr -*.iws \ No newline at end of file +*.iws +data/saves diff --git a/README.md b/README.md index e811267..162e9bb 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,32 @@ You can set all the following variables: * `HTTP_PROXY_READ_TIMEOUT` * when calling the `proxy` endpoint, the value of `HTTP_PROXY_READ_TIMEOUT` will be the connection read timeout in milliseconds * Default value: `10000` (10 seconds) +* `PLANTUML_SAVE_DIR` + * Local directory used by the save/load API for storing diagrams. + * Default value: `data/saves` +* `PLANTUML_SAVE_MAX_SIZE` + * Maximum payload size (in bytes) accepted by the save endpoint. + * Default value: `200000` +* `PLANTUML_SAVE_TOKEN` + * Optional shared token to protect the save/load/delete endpoints. If set, requests must provide the header `X-PlantUML-Token`. + * Default value: `null` (no auth) + + +## Save / Load API + +The UI exposes “Save” and “Load” buttons to persist PlantUML sources on the server. + +- Endpoints (JSON): + - `POST /api/diagrams` with `{ "id": "my-diagram", "name": "optional name", "uml": "@startuml..." }` + - `GET /api/diagrams/{id}` returns the stored diagram + - `GET /api/diagrams` returns a lightweight list of stored diagrams + - `DELETE /api/diagrams/{id}` deletes a stored diagram +- Storage: + - Files are written to `PLANTUML_SAVE_DIR` (default `data/saves`). You can structure ids with folders, e.g. `teamA/sequence1`. + - Max payload is limited by `PLANTUML_SAVE_MAX_SIZE` (default 200 KB). + - Optional protection: set `PLANTUML_SAVE_TOKEN` and pass `X-PlantUML-Token` header when calling the API. + +When hosting publicly, enable a token (or protect via reverse proxy) so user saves are not world-writeable. ## Alternate: How to build your docker image diff --git a/pom.parent.xml b/pom.parent.xml index bdea925..507ad99 100644 --- a/pom.parent.xml +++ b/pom.parent.xml @@ -187,6 +187,12 @@ ${fop.version} runtime + + com.google.code.gson + gson + 2.11.0 + compile + diff --git a/src/main/java/net/sourceforge/plantuml/servlet/DiagramStorageServlet.java b/src/main/java/net/sourceforge/plantuml/servlet/DiagramStorageServlet.java new file mode 100644 index 0000000..b27f97c --- /dev/null +++ b/src/main/java/net/sourceforge/plantuml/servlet/DiagramStorageServlet.java @@ -0,0 +1,220 @@ +package net.sourceforge.plantuml.servlet; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import net.sourceforge.plantuml.servlet.storage.DiagramStorageService; +import net.sourceforge.plantuml.servlet.storage.StoredDiagram; +import net.sourceforge.plantuml.servlet.storage.StoredDiagramSummary; + +/** + * REST-like servlet to save and load diagram sources on the server. + */ +public class DiagramStorageServlet extends HttpServlet { + + private static final long serialVersionUID = 1L; + private static final String TOKEN_HEADER = "X-PlantUML-Token"; + + private final DiagramStorageService storage = new DiagramStorageService(); + private final Gson gson = new GsonBuilder().disableHtmlEscaping().create(); + private final String saveToken = System.getenv("PLANTUML_SAVE_TOKEN"); + + @Override + protected void doGet( + final HttpServletRequest req, + final HttpServletResponse resp + ) throws ServletException, IOException { + resp.setCharacterEncoding("UTF-8"); + resp.setContentType("application/json"); + + final String id = extractId(req); + if (id == null) { + final List list; + try { + list = storage.list(); + } catch (IOException e) { + writeError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unable to list diagrams"); + return; + } + writeJson(resp, HttpServletResponse.SC_OK, list); + return; + } + if (!storage.isValidId(id)) { + writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "Invalid diagram id"); + return; + } + final StoredDiagram diagram; + try { + diagram = storage.read(id); + } catch (IOException e) { + writeError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unable to read diagram"); + return; + } + if (diagram == null) { + writeError(resp, HttpServletResponse.SC_NOT_FOUND, "Diagram not found"); + return; + } + writeJson(resp, HttpServletResponse.SC_OK, diagram); + } + + @Override + protected void doPost( + final HttpServletRequest req, + final HttpServletResponse resp + ) throws ServletException, IOException { + if (!isAuthorized(req)) { + writeError(resp, HttpServletResponse.SC_UNAUTHORIZED, "Missing or invalid token"); + return; + } + final String body; + try { + body = readBody(req, storage.getMaxPayloadBytes()); + } catch (IOException e) { + writeError(resp, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Payload too large"); + return; + } + final DiagramPayload payload = gson.fromJson(body, DiagramPayload.class); + if (payload == null || payload.getUml() == null || payload.getUml().trim().isEmpty()) { + writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "Field 'uml' is required"); + return; + } + final String id = resolveId(payload.getId()); + if (!storage.isValidId(id)) { + writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "Invalid diagram id"); + return; + } + final StoredDiagram stored; + try { + stored = storage.save( + new StoredDiagram( + id, + normalize(payload.getName()), + payload.getUml(), + null, + payload.getMetadata() + ) + ); + } catch (IOException e) { + writeError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unable to store diagram"); + return; + } + writeJson(resp, HttpServletResponse.SC_OK, stored); + } + + @Override + protected void doDelete( + final HttpServletRequest req, + final HttpServletResponse resp + ) throws ServletException, IOException { + writeError(resp, HttpServletResponse.SC_METHOD_NOT_ALLOWED, "Delete is disabled"); + } + + private String extractId(final HttpServletRequest req) { + final String pathInfo = req.getPathInfo(); + if (pathInfo == null || pathInfo.isBlank() || "/".equals(pathInfo)) { + return null; + } + final String trimmed = pathInfo.startsWith("/") ? pathInfo.substring(1) : pathInfo; + return trimmed.trim(); + } + + private String readBody(final HttpServletRequest req, final long maxSize) throws IOException { + final long contentLength = req.getContentLengthLong(); + if (contentLength > maxSize) { + throw new IOException("Payload too large"); + } + final StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(req.getInputStream(), StandardCharsets.UTF_8) + )) { + int read; + final char[] buffer = new char[2048]; + long total = 0; + while ((read = reader.read(buffer)) != -1) { + total += read; + if (total > maxSize) { + throw new IOException("Payload too large"); + } + sb.append(buffer, 0, read); + } + } + return sb.toString(); + } + + private void writeJson( + final HttpServletResponse resp, + final int status, + final Object payload + ) throws IOException { + resp.setStatus(status); + resp.setContentType("application/json"); + resp.setCharacterEncoding("UTF-8"); + gson.toJson(payload, resp.getWriter()); + } + + private void writeError( + final HttpServletResponse resp, + final int status, + final String message + ) throws IOException { + writeJson(resp, status, Map.of("message", message)); + } + + private boolean isAuthorized(final HttpServletRequest req) { + if (saveToken == null || saveToken.isEmpty()) { + return true; + } + final String provided = req.getHeader(TOKEN_HEADER); + return saveToken.equals(provided); + } + + private String resolveId(final String provided) { + if (provided == null || provided.isBlank()) { + return UUID.randomUUID().toString(); + } + return provided.trim(); + } + + private String normalize(final String value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static final class DiagramPayload { + private String id; + private String name; + private String uml; + private Map metadata; + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getUml() { + return uml; + } + + public Map getMetadata() { + return metadata; + } + } +} diff --git a/src/main/java/net/sourceforge/plantuml/servlet/storage/DiagramStorageService.java b/src/main/java/net/sourceforge/plantuml/servlet/storage/DiagramStorageService.java new file mode 100644 index 0000000..f592fc6 --- /dev/null +++ b/src/main/java/net/sourceforge/plantuml/servlet/storage/DiagramStorageService.java @@ -0,0 +1,160 @@ +package net.sourceforge.plantuml.servlet.storage; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +/** + * Minimal file-based storage for UML diagrams. + */ +public class DiagramStorageService { + + /** + * Accept ids like "foo", "foo_bar", or "folder/subfolder/name". + * Each segment must be alphanumeric, dash or underscore. + */ + private static final Pattern ID_PATTERN = Pattern.compile("([A-Za-z0-9_-]+/)*[A-Za-z0-9_-]{1,64}"); + private static final String DEFAULT_SAVE_DIR = "data/saves"; + private static final long DEFAULT_MAX_SIZE = 200_000L; // 200 KB + private static final int DEFAULT_LIST_LIMIT = 100; + + private final Path baseDir; + private final long maxPayloadBytes; + private final int listLimit; + private final Gson gson; + + public DiagramStorageService() { + this.baseDir = Paths.get( + Optional.ofNullable(System.getenv("PLANTUML_SAVE_DIR")).orElse(DEFAULT_SAVE_DIR) + ).toAbsolutePath().normalize(); + this.maxPayloadBytes = parseLongEnv("PLANTUML_SAVE_MAX_SIZE", DEFAULT_MAX_SIZE); + this.listLimit = DEFAULT_LIST_LIMIT; + this.gson = new GsonBuilder().disableHtmlEscaping().create(); + } + + public long getMaxPayloadBytes() { + return maxPayloadBytes; + } + + public Path getBaseDir() { + return baseDir; + } + + public boolean isValidId(final String id) { + if (id == null) { + return false; + } + return ID_PATTERN.matcher(id).matches(); + } + + public StoredDiagram save(final StoredDiagram diagram) throws IOException { + Objects.requireNonNull(diagram, "diagram must not be null"); + if (!isValidId(diagram.getId())) { + throw new IOException("Invalid diagram id"); + } + final String timestamp = Instant.now().toString(); + final StoredDiagram toPersist = diagram.withUpdatedAt(timestamp); + final Path target = resolvePath(diagram.getId()); + Files.createDirectories(target.getParent()); + try (Writer writer = Files.newBufferedWriter( + target, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + )) { + gson.toJson(toPersist, writer); + } + return toPersist; + } + + public StoredDiagram read(final String id) throws IOException { + final Path path = resolvePath(id); + if (!Files.exists(path)) { + return null; + } + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + return gson.fromJson(reader, StoredDiagram.class); + } + } + + public List list() throws IOException { + if (!Files.exists(baseDir)) { + return Collections.emptyList(); + } + try (Stream stream = Files.walk(baseDir, 5)) { + return stream + .filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted(Comparator.comparing(this::getFileTimeSafe).reversed()) + .limit(listLimit) + .map(this::safeReadSummary) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } + } + + public boolean delete(final String id) throws IOException { + final Path path = resolvePath(id); + return Files.deleteIfExists(path); + } + + private Optional safeReadSummary(final Path path) { + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + final StoredDiagram diagram = gson.fromJson(reader, StoredDiagram.class); + if (diagram == null) { + return Optional.empty(); + } + return Optional.of(diagram.toSummary()); + } catch (IOException e) { + return Optional.empty(); + } + } + + private Path resolvePath(final String id) throws IOException { + if (!isValidId(id)) { + throw new IOException("Invalid diagram id"); + } + final Path path = baseDir.resolve(id + ".json").normalize(); + if (!path.startsWith(baseDir)) { + throw new IOException("Invalid diagram id"); + } + return path; + } + + private long getFileTimeSafe(final Path path) { + try { + return Files.getLastModifiedTime(path).toMillis(); + } catch (IOException e) { + return 0L; + } + } + + private long parseLongEnv(final String envKey, final long defaultValue) { + try { + final String value = System.getenv(envKey); + if (value == null) { + return defaultValue; + } + return Long.parseLong(value); + } catch (NumberFormatException ex) { + return defaultValue; + } + } +} diff --git a/src/main/java/net/sourceforge/plantuml/servlet/storage/StoredDiagram.java b/src/main/java/net/sourceforge/plantuml/servlet/storage/StoredDiagram.java new file mode 100644 index 0000000..d1fd056 --- /dev/null +++ b/src/main/java/net/sourceforge/plantuml/servlet/storage/StoredDiagram.java @@ -0,0 +1,61 @@ +package net.sourceforge.plantuml.servlet.storage; + +import java.util.Map; + +/** + * Simple DTO representing a stored diagram. + */ +public class StoredDiagram { + + private String id; + private String name; + private String uml; + private String updatedAt; + private Map metadata; + + public StoredDiagram() { + // For JSON serialization + } + + public StoredDiagram( + final String diagramId, + final String diagramName, + final String diagramUml, + final String lastUpdatedAt, + final Map diagramMetadata + ) { + this.id = diagramId; + this.name = diagramName; + this.uml = diagramUml; + this.updatedAt = lastUpdatedAt; + this.metadata = diagramMetadata; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getUml() { + return uml; + } + + public String getUpdatedAt() { + return updatedAt; + } + + public Map getMetadata() { + return metadata; + } + + public StoredDiagram withUpdatedAt(final String timestamp) { + return new StoredDiagram(id, name, uml, timestamp, metadata); + } + + public StoredDiagramSummary toSummary() { + return new StoredDiagramSummary(id, name, updatedAt); + } +} diff --git a/src/main/java/net/sourceforge/plantuml/servlet/storage/StoredDiagramSummary.java b/src/main/java/net/sourceforge/plantuml/servlet/storage/StoredDiagramSummary.java new file mode 100644 index 0000000..84aaedd --- /dev/null +++ b/src/main/java/net/sourceforge/plantuml/servlet/storage/StoredDiagramSummary.java @@ -0,0 +1,36 @@ +package net.sourceforge.plantuml.servlet.storage; + +/** + * Lightweight view returned by the listing endpoint. + */ +public class StoredDiagramSummary { + private String id; + private String name; + private String updatedAt; + + public StoredDiagramSummary() { + // For JSON serialization + } + + public StoredDiagramSummary( + final String diagramId, + final String diagramName, + final String lastUpdatedAt + ) { + this.id = diagramId; + this.name = diagramName; + this.updatedAt = lastUpdatedAt; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getUpdatedAt() { + return updatedAt; + } +} diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 30b7b65..0eb5b63 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -31,14 +31,12 @@ - @@ -50,7 +48,6 @@ exact - @@ -229,6 +226,15 @@ /metadata/* + + diagramstorageservlet + net.sourceforge.plantuml.servlet.DiagramStorageServlet + + + diagramstorageservlet + /api/diagrams/* + + diff --git a/src/main/webapp/assets/actions/load.svg b/src/main/webapp/assets/actions/load.svg new file mode 100644 index 0000000..04191c4 --- /dev/null +++ b/src/main/webapp/assets/actions/load.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/webapp/assets/actions/save.svg b/src/main/webapp/assets/actions/save.svg new file mode 100644 index 0000000..82c39eb --- /dev/null +++ b/src/main/webapp/assets/actions/save.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/webapp/components/app-head.jsp b/src/main/webapp/components/app-head.jsp index a60534e..151ea60 100644 --- a/src/main/webapp/components/app-head.jsp +++ b/src/main/webapp/components/app-head.jsp @@ -9,6 +9,11 @@ + + + + + diff --git a/src/main/webapp/components/header/header.jsp b/src/main/webapp/components/header/header.jsp index 87eb9fb..2e742a0 100644 --- a/src/main/webapp/components/header/header.jsp +++ b/src/main/webapp/components/header/header.jsp @@ -1,8 +1,32 @@ -

PlantUML Server

-<% if (showSocialButtons) { %> - <%@ include file="/components/header/social-buttons.jsp" %> -<% } %> -<% if (showGithubRibbon) { %> - <%@ include file="/components/header/github-ribbon.jsp" %> -<% } %> -

Create your PlantUML diagrams directly in your browser!

+ +

Serveur PlantUML

+
+ + +
+<% if (showSocialButtons) { %> <%@ include +file="/components/header/social-buttons.jsp" %> <% } %> <% if (showGithubRibbon) +{ %> <%@ include file="/components/header/github-ribbon.jsp" %> <% } %> +

+ Créez vos diagrammes PlantUML + directement dans votre navigateur. +

diff --git a/src/main/webapp/components/modals/diagram-export/diagram-export.jsp b/src/main/webapp/components/modals/diagram-export/diagram-export.jsp index a6b4d2a..729649b 100644 --- a/src/main/webapp/components/modals/diagram-export/diagram-export.jsp +++ b/src/main/webapp/components/modals/diagram-export/diagram-export.jsp @@ -1,16 +1,16 @@ diff --git a/src/main/webapp/components/modals/diagram-import/diagram-import.js b/src/main/webapp/components/modals/diagram-import/diagram-import.js index 43e5540..ae14b85 100644 --- a/src/main/webapp/components/modals/diagram-import/diagram-import.js +++ b/src/main/webapp/components/modals/diagram-import/diagram-import.js @@ -54,8 +54,8 @@ function initDiagramImport() { const type = getImageFileType(file); const isCode = type === undefined ? isDiagramCode(file) : false; if (!type && !isCode) { - errorMessageElement.innerText = "File not supported. " + - "Only PNG and SVG diagram images as well as PlantUML code text files are supported." + errorMessageElement.innerText = "Fichier non pris en charge. " + + "Seuls les PNG, SVG ou fichiers texte PlantUML sont acceptés." } return { type, isDiagramCode: isCode, valid: type || isCode }; } @@ -90,8 +90,8 @@ function initDiagramImport() { resolve(); } else { // this error should already be handled. - errorMessageElement.innerText = "File not supported. " + - "Only PNG and SVG diagram images as well as PlantUML code text files are supported."; + errorMessageElement.innerText = "Fichier non pris en charge. " + + "Seuls les PNG, SVG ou fichiers texte PlantUML sont acceptés."; reject(); } }).then(() => closeDialog(), () => {}).finally(() => dialogElement.classList.remove("wait")); diff --git a/src/main/webapp/components/modals/diagram-import/diagram-import.jsp b/src/main/webapp/components/modals/diagram-import/diagram-import.jsp index e38a8d7..91194f6 100644 --- a/src/main/webapp/components/modals/diagram-import/diagram-import.jsp +++ b/src/main/webapp/components/modals/diagram-import/diagram-import.jsp @@ -1,7 +1,7 @@ diff --git a/src/main/webapp/components/modals/diagram-storage/diagram-storage.css b/src/main/webapp/components/modals/diagram-storage/diagram-storage.css new file mode 100644 index 0000000..640a322 --- /dev/null +++ b/src/main/webapp/components/modals/diagram-storage/diagram-storage.css @@ -0,0 +1,115 @@ +.saved-list { + margin-top: 1rem; + border: 1px solid var(--border-color, #ccc); + border-radius: 6px; + display: flex; + flex-direction: column; + min-height: 340px; + max-height: 70vh; + background: #f7f9fb; +} + +.saved-list-header { + display: flex; + justify-content: space-between; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border-color, #ccc); + font-weight: 600; +} + +.saved-list-header .hint { + font-weight: 400; + color: #666; + font-size: 0.9rem; +} + +.saved-list-body { + overflow-y: auto; + padding: 0.5rem 0.75rem; + max-height: 70vh; +} + +.modal-storage { + max-width: 54rem; + width: 94vw; +} + +.saved-item { + padding: 0.35rem 0.5rem; + border-radius: 4px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; +} + +.saved-item:hover { + background: rgba(0, 0, 0, 0.05); +} + +.saved-item .path { + font-weight: 600; +} + +.saved-item .meta { + color: #666; + font-size: 0.85rem; + margin-left: 0.75rem; +} + +.tree { + list-style: none; + padding-left: 0.25rem; + margin: 0; +} + +.tree li { + padding: 0.25rem 0.25rem 0.25rem 0.5rem; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.35rem; + user-select: none; +} + +.tree li:hover { + background: rgba(0, 0, 0, 0.05); +} + +.tree .caret { + width: 0.9rem; + text-align: center; + font-size: 0.85rem; +} + +.tree .label { + font-weight: 600; +} + +.tree .meta { + color: #666; + font-size: 0.85rem; + margin-left: auto; +} + +.tree .icon-folder::before { + content: "📂"; +} + +.tree .icon-file::before { + content: "📄"; +} + +#diagram-storage-status { + min-height: 1.4rem; + color: #444; +} + +#diagram-storage-status.error { + color: #c0392b; +} + +#diagram-storage-status.success { + color: #1e8449; +} diff --git a/src/main/webapp/components/modals/diagram-storage/diagram-storage.jsp b/src/main/webapp/components/modals/diagram-storage/diagram-storage.jsp new file mode 100644 index 0000000..917d1a9 --- /dev/null +++ b/src/main/webapp/components/modals/diagram-storage/diagram-storage.jsp @@ -0,0 +1,36 @@ + diff --git a/src/main/webapp/components/modals/modals.css b/src/main/webapp/components/modals/modals.css index bbabe7c..cf40c5e 100644 --- a/src/main/webapp/components/modals/modals.css +++ b/src/main/webapp/components/modals/modals.css @@ -20,7 +20,7 @@ margin: auto; padding: 2rem; border: 3px solid var(--border-color); - max-width: 30rem; + max-width: 50rem; box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); -webkit-animation-name: modal-animatetop; -webkit-animation-duration: 0.4s; diff --git a/src/main/webapp/components/modals/modals.js b/src/main/webapp/components/modals/modals.js index 89cfeef..26ce422 100644 --- a/src/main/webapp/components/modals/modals.js +++ b/src/main/webapp/components/modals/modals.js @@ -49,6 +49,7 @@ function initModals(view) { if (view !== "previewer") { initDiagramExport(); initDiagramImport(); + initDiagramStorage(); } } diff --git a/src/main/webapp/components/modals/settings/settings.jsp b/src/main/webapp/components/modals/settings/settings.jsp index 597e18d..3942986 100644 --- a/src/main/webapp/components/modals/settings/settings.jsp +++ b/src/main/webapp/components/modals/settings/settings.jsp @@ -1,19 +1,19 @@ diff --git a/src/main/webapp/index.jsp b/src/main/webapp/index.jsp index 6506a1b..06cda13 100644 --- a/src/main/webapp/index.jsp +++ b/src/main/webapp/index.jsp @@ -36,6 +36,7 @@ <%@ include file="/components/modals/diagram-import/diagram-import.jsp" %> <%@ include file="/components/modals/diagram-export/diagram-export.jsp" %> + <%@ include file="/components/modals/diagram-storage/diagram-storage.jsp" %> diff --git a/src/main/webapp/js/storage.js b/src/main/webapp/js/storage.js new file mode 100644 index 0000000..6f30359 --- /dev/null +++ b/src/main/webapp/js/storage.js @@ -0,0 +1,379 @@ +/********************* +* Diagram Save / Load * +**********************/ + +(function() { + const API_BASE = "api/diagrams"; + const TOKEN_HEADER = "X-PlantUML-Token"; + const TOKEN_STORAGE_KEY = "plantuml.save.token"; + const ID_REGEX = /^([A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]{1,64}$/; + + let dom = {}; + const expandedPaths = new Set(); + function log() { + if (console && console.log) { + console.log("[diagram-storage]", ...arguments); + } + } + + function getToken() { + return window.localStorage.getItem(TOKEN_STORAGE_KEY) || ""; + } + + function setToken() { + const current = getToken(); + const value = window.prompt("Définir le jeton de sauvegarde (laisser vide pour effacer) :", current || "") || ""; + if (value.trim()) { + window.localStorage.setItem(TOKEN_STORAGE_KEY, value.trim()); + setStatus("Jeton enregistré localement.", "success"); + } else { + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + setStatus("Jeton supprimé.", "success"); + } + } + + function authHeaders(headers = {}) { + const token = getToken(); + if (token) { + headers[TOKEN_HEADER] = token; + } + return headers; + } + + function setStatus(message, type = "") { + if (!dom.status) return; + dom.status.textContent = message || ""; + dom.status.className = `status-message ${type}`; + if (message) { + log("status:", message, type); + } + } + + function validatePath(path) { + if (!path || !path.trim()) { + setStatus("Merci de saisir un nom ou un chemin.", "error"); + return null; + } + const cleaned = path.trim().replace(/^\/+|\/+$/g, "").replace(/\/{2,}/g, "/"); + if (!ID_REGEX.test(cleaned)) { + setStatus("Chemin invalide. Utilisez lettres, chiffres, '-' ou '_' avec dossiers optionnels (ex : dossier/nom).", "error"); + return null; + } + return cleaned; + } + + function handleResponse(resp) { + const parse = () => resp.json().catch(() => ({})); + if (resp.ok) { + return parse(); + } + return parse().then(body => { + const err = new Error(body.message || resp.statusText); + err.status = resp.status; + throw err; + }); + } + + function buildTree(items) { + const root = { name: "", children: new Map(), item: null }; + items.forEach(item => { + const parts = item.id.split("/"); + let node = root; + parts.forEach((part, idx) => { + const isLast = idx === parts.length - 1; + if (!node.children.has(part)) { + node.children.set(part, { name: part, children: new Map(), item: null }); + } + node = node.children.get(part); + if (isLast) { + node.item = item; + } + }); + }); + return root; + } + + function isFolder(node) { + return node.children.size > 0 || !node.item; + } + + function renderTreeNode(node, prefix, depth) { + const ul = document.createElement("ul"); + ul.className = "btv"; + const entries = Array.from(node.children.values()).sort((a, b) => { + const aFolder = isFolder(a); + const bFolder = isFolder(b); + if (aFolder !== bFolder) { + return aFolder ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + entries.forEach(child => { + const li = document.createElement("li"); + li.style.paddingLeft = `${depth * 1.2}rem`; + const fullPath = prefix ? `${prefix}/${child.name}` : child.name; + + const caret = document.createElement("span"); + caret.className = "caret"; + const folder = isFolder(child); + const isExpanded = depth === 0 || expandedPaths.has(fullPath); + caret.textContent = folder ? (isExpanded ? "▾" : "▸") : ""; + li.appendChild(caret); + + const label = document.createElement("span"); + label.className = "label " + (folder ? "icon-folder" : "icon-file"); + label.textContent = child.name; + li.appendChild(label); + + if (child.item) { + const meta = document.createElement("span"); + meta.className = "meta"; + const updated = child.item.updatedAt ? new Date(child.item.updatedAt).toLocaleString() : ""; + meta.textContent = [updated, child.item.name].filter(Boolean).join(" • "); + li.appendChild(meta); + } + + li.addEventListener("click", evt => { + evt.stopPropagation(); + dom.path.value = fullPath; + dom.name.value = child.item?.name || ""; + if (folder) { + toggleFolder(li, fullPath, caret); + } + }); + + li.addEventListener("dblclick", evt => { + evt.stopPropagation(); + if (child.item) { + loadDiagram(fullPath); + } + }); + + if (folder) { + const nested = renderTreeNode(child, fullPath, depth + 1); + nested.style.display = expandedPaths.has(fullPath) || depth === 0 ? "block" : "none"; + li.appendChild(nested); + } + ul.appendChild(li); + }); + return ul; + } + + function renderList(items) { + if (!dom.list) return; + dom.list.innerHTML = ""; + if (!items || items.length === 0) { + const empty = document.createElement("div"); + empty.className = "saved-item"; + empty.textContent = "Aucun diagramme sauvegarde."; + dom.list.appendChild(empty); + return; + } + // reset expansion: top-level open, rest collapsed unless selected + expandedPaths.clear(); + expandedPaths.add(""); + if (dom.path?.value) { + const parts = dom.path.value.split("/"); + let prefix = ""; + parts.forEach((p, idx) => { + prefix = idx === 0 ? p : `${prefix}/${p}`; + expandedPaths.add(prefix); + }); + } + const tree = buildTree(items); + const treeEl = renderTreeNode(tree, "", 0); + dom.list.appendChild(treeEl); + } + + function toggleFolder(li, fullPath, caret) { + const nested = li.querySelector("ul"); + if (!nested) { + return; + } + const willExpand = nested.style.display === "none"; + nested.style.display = willExpand ? "block" : "none"; + caret.textContent = willExpand ? "▾" : "▸"; + if (willExpand) { + expandedPaths.add(fullPath); + } else { + expandedPaths.delete(fullPath); + } + } + + function refreshList(showStatus = false) { + const headers = authHeaders({}); + log("refresh list"); + return fetch(API_BASE, { method: "GET", headers }) + .then(handleResponse) + .then(items => { + renderList(items); + if (showStatus) { + setStatus("Liste rafraichie.", "success"); + } + }) + .catch(err => { + setStatus(`Impossible de charger la liste : ${err.message}`, "error"); + console.error("[diagram-storage] list error", err); + }); + } + + function saveDiagram() { + log("save click"); + if (!document.editor || typeof document.editor.getValue !== "function") { + setStatus("Editeur non pret.", "error"); + return; + } + const path = validatePath(dom.path.value); + if (!path) { + return; + } + const payload = { + id: path, + name: dom.name.value || null, + uml: document.editor.getValue(), + }; + const headers = authHeaders({ "Content-Type": "application/json" }); + fetch(API_BASE, { + method: "POST", + headers, + body: JSON.stringify(payload), + }) + .then(handleResponse) + .then(data => { + setStatus(`Diagramme "${data.id}" sauvegarde.`, "success"); + refreshList(); + }) + .catch(err => { + if (err.status === 401) { + setToken(); + } else { + setStatus(`Echec de la sauvegarde : ${err.message}`, "error"); + console.error("[diagram-storage] save error", err); + } + }); + } + + function loadDiagram(pathValue) { + log("load click", pathValue || dom.path.value); + const path = validatePath(pathValue || dom.path.value); + if (!path) { + return; + } + const headers = authHeaders({}); + fetch(`${API_BASE}/${encodeURIComponent(path)}`, { method: "GET", headers }) + .then(handleResponse) + .then(data => { + if (typeof setEditorValue === "function" && document.editor) { + setEditorValue(document.editor, data.uml, { suppressEditorChangedMessage: false }); + } + dom.path.value = data.id; + dom.name.value = data.name || ""; + setStatus(`Diagramme "${data.id}" charge.`, "success"); + }) + .catch(err => { + if (err.status === 401) { + setToken(); + } else { + setStatus(`Echec du chargement : ${err.message}`, "error"); + console.error("[diagram-storage] load error", err); + } + }); + } + + function bindMenuButtons() { + const candidates = [ + { id: "menu-item-editor-save", label: "menu save click" }, + { id: "menu-item-editor-load", label: "menu load click" }, + { id: "header-save-btn", label: "header save click" }, + { id: "header-load-btn", label: "header load click" }, + ]; + candidates.forEach(candidate => { + const el = document.getElementById(candidate.id); + if (el) { + el.addEventListener("click", () => { + log(candidate.label); + openModal("diagram-storage"); + }); + } + }); + } + + let initialized = false; + + function initDiagramStorage() { + if (initialized) { + log("already initialized"); + return; + } + log("init UI"); + dom = { + modal: document.getElementById("diagram-storage"), + path: document.getElementById("diagram-storage-path"), + name: document.getElementById("diagram-storage-name"), + save: document.getElementById("diagram-storage-save"), + load: document.getElementById("diagram-storage-load"), + del: document.getElementById("diagram-storage-delete"), + refresh: document.getElementById("diagram-storage-refresh"), + token: document.getElementById("diagram-storage-token"), + status: document.getElementById("diagram-storage-status"), + list: document.getElementById("diagram-storage-list"), + }; + + if (dom.save) { + dom.save.addEventListener("click", saveDiagram); + } + if (dom.load) { + dom.load.addEventListener("click", () => loadDiagram()); + } + if (dom.del) { + dom.del.addEventListener("click", () => {}); + } + if (dom.refresh) { + dom.refresh.addEventListener("click", () => refreshList(true)); + } + if (dom.token) { + dom.token.addEventListener("click", setToken); + } + + registerModalListener( + "diagram-storage", + () => { + if (dom.modal) { + setVisibility(dom.modal, true, true); + } + setStatus(""); + if (dom.path && !dom.path.value) { + dom.path.value = "mon-diagramme"; + } + refreshList(); + dom.path?.focus(); + log("modal opened"); + }, + () => { + if (dom.modal) { + setVisibility(dom.modal, false); + } + } + ); + + bindMenuButtons(); + initialized = true; + } + + window.initDiagramStorage = initDiagramStorage; + window.addEventListener("load", initDiagramStorage); +})(); + function toggleFolder(li, fullPath, caret) { + const nested = li.querySelector("ul"); + if (!nested) { + return; + } + const next = nested.style.display === "none"; + nested.style.display = next ? "block" : "none"; + caret.textContent = next ? "▾" : "▸"; + if (next) { + expandedPaths.add(fullPath); + } else { + expandedPaths.delete(fullPath); + } + } diff --git a/src/main/webapp/js/treeview-stub.js b/src/main/webapp/js/treeview-stub.js new file mode 100644 index 0000000..3087cba --- /dev/null +++ b/src/main/webapp/js/treeview-stub.js @@ -0,0 +1,70 @@ +(function() { + // Global expansion set used by minified code expecting `expandedPaths` + window.expandedPaths = window.expandedPaths || new Set(); + + function renderNode(node) { + const li = document.createElement("li"); + li.dataset.id = node.id || ""; + const caret = document.createElement("span"); + caret.className = "caret"; + const hasChildren = node.children && node.children.length > 0; + caret.textContent = hasChildren ? "▾" : ""; + li.appendChild(caret); + + const label = document.createElement("span"); + label.className = "label"; + label.textContent = node.label || node.id || ""; + li.appendChild(label); + + if (node.meta) { + const meta = document.createElement("span"); + meta.className = "meta"; + meta.textContent = node.meta; + li.appendChild(meta); + } + + if (hasChildren) { + const ul = document.createElement("ul"); + ul.className = "tree"; + node.children.forEach(child => ul.appendChild(renderNode(child))); + li.appendChild(ul); + } + return li; + } + + window.TreeView = { + create(container, data, { onClick, onDblClick } = {}) { + if (!container) return; + container.innerHTML = ""; + const ul = document.createElement("ul"); + ul.className = "tree"; + data.forEach(node => ul.appendChild(renderNode(node))); + ul.addEventListener("click", evt => { + const li = evt.target.closest("li"); + if (!li) return; + const sub = li.querySelector(":scope > ul"); + if (sub) { + const caret = li.querySelector(":scope > .caret"); + const hidden = sub.style.display === "none"; + sub.style.display = hidden ? "block" : "none"; + if (caret) caret.textContent = hidden ? "▾" : "▸"; + if (hidden) { + window.expandedPaths.add(li.dataset.id || ""); + } else { + window.expandedPaths.delete(li.dataset.id || ""); + } + } + onClick?.(li.dataset.id || ""); + }); + ul.addEventListener("dblclick", evt => { + const li = evt.target.closest("li"); + if (!li) return; + const sub = li.querySelector(":scope > ul"); + if (!sub) { + onDblClick?.(li.dataset.id || ""); + } + }); + container.appendChild(ul); + }, + }; +})(); diff --git a/src/main/webapp/min/plantuml.min.css b/src/main/webapp/min/plantuml.min.css index 1705a6d..a58b1bd 100644 --- a/src/main/webapp/min/plantuml.min.css +++ b/src/main/webapp/min/plantuml.min.css @@ -6,7 +6,9 @@ html{font-family:arial,helvetica,sans-serif}body{background-color:var(--bg-color .monaco-editor-container .menu-kebab{flex-direction:column;position:relative;transition:all 300ms cubic-bezier(0.175,0.885,0.32,1.275)}.monaco-editor-container .menu-kebab .kebab-circle:nth-child(4),.monaco-editor-container .menu-kebab .kebab-circle:nth-child(5){position:absolute;opacity:0;top:50%;margin-top:-6px;left:50%}.monaco-editor-container .menu-kebab .kebab-circle:nth-child(4){margin-left:-25px}.monaco-editor-container .menu-kebab .kebab-circle:nth-child(5){margin-left:13px}.monaco-editor-container .editor-menu:hover .menu-kebab,.monaco-editor-container .editor-menu:focus .menu-kebab{transform:rotate(45deg)} .monaco-editor-container .editor-menu:hover .menu-kebab .kebab-circle,.monaco-editor-container .editor-menu:focus .menu-kebab .kebab-circle{opacity:1}.monaco-editor-container .editor-menu .menu-item{display:none;margin:1rem 0;height:1.75rem;opacity:.5;position:relative;-webkit-animation-name:editor-menu-animateitem;-webkit-animation-duration:.4s;animation-name:editor-menu-animateitem;animation-duration:.4s}@-webkit-keyframes editor-menu-animateitem{from{top:-50%;opacity:0}to{top:0;opacity:.5}}@keyframes editor-menu-animateitem{from{top:-50%;opacity:0} to{top:0;opacity:.5}}.monaco-editor-container .editor-menu .menu-item:hover{opacity:1}.monaco-editor-container .editor-menu:hover .menu-item,.monaco-editor-container .editor-menu:focus .menu-item{display:block}.editor .btn-input{align-items:center;border-bottom:3px solid var(--border-color);box-sizing:border-box;display:flex;justify-content:center}.editor .btn-input input[type=text]{border:0;flex:1 1 1px;font-family:monospace;font-size:medium;padding:.2em;text-overflow:ellipsis}.editor .btn-input input[type=text]:focus{border:0;box-shadow:none;outline:0}.editor .btn-input input[type="image"]{height:1rem;margin-left:.7em;padding:0 .3em}#diagram-export.modal .label-input-pair label{min-width:8rem}#diagram-import p.error-message{color:darkred;padding-left:1rem;padding-right:1rem}#diagram-import input[type="file"]{display:block;width:100%;border:.2rem dashed var(--border-color);border-radius:.4rem;box-sizing:border-box;padding:5rem 2rem}#diagram-import input[type="file"],#diagram-import input[type="file"]::file-selector-button{background-color:var(--modal-bg-color)}#diagram-import input[type="file"]:hover,#diagram-import input[type="file"].drop-able{border-color:var(--border-color-2);background-color:var(--file-drop-color)} -#diagram-import input[type="file"]:hover::file-selector-button,#diagram-import input[type="file"].drop-able::file-selector-button{background-color:var(--file-drop-color)}.modal{display:block;position:fixed;z-index:1;padding:5%;left:0;top:0;bottom:0;right:0;overflow:auto;background-color:#000;background-color:rgba(0,0,0,0.4)}.modal .modal-content{background-color:var(--modal-bg-color);margin:auto;padding:2rem;border:3px solid var(--border-color);max-width:30rem;box-shadow:0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19);-webkit-animation-name:modal-animatetop;-webkit-animation-duration:.4s;animation-name:modal-animatetop;animation-duration:.4s;position:relative;top:50%;transform:translateY(-50%)} +#diagram-import input[type="file"]:hover::file-selector-button,#diagram-import input[type="file"].drop-able::file-selector-button{background-color:var(--file-drop-color)}.saved-list{margin-top:1rem;border:1px solid var(--border-color,#ccc);border-radius:6px;display:flex;flex-direction:column;min-height:260px;max-height:70vh;background:#f7f9fb}.saved-list-header{display:flex;justify-content:space-between;padding:.5rem .75rem;border-bottom:1px solid var(--border-color,#ccc);font-weight:600}.saved-list-header .hint{font-weight:400;color:#666;font-size:.9rem}.saved-list-body{overflow-y:auto;padding:.5rem .75rem;max-height:70vh}.modal-storage{max-width:48rem;width:90vw} +.saved-item{padding:.35rem .5rem;border-radius:4px;cursor:pointer;display:flex;justify-content:space-between;align-items:center}.saved-item:hover{background:rgba(0,0,0,0.05)}.saved-item .path{font-weight:600}.saved-item .meta{color:#666;font-size:.85rem;margin-left:.75rem}.tree{list-style:none;padding-left:.25rem;margin:0}.tree li{padding:.25rem .25rem .25rem .5rem;border-radius:4px;cursor:pointer;display:flex;align-items:center;gap:.35rem;user-select:none}.tree li:hover{background:rgba(0,0,0,0.05)} +.tree .caret{width:.9rem;text-align:center;font-size:.85rem}.tree .label{font-weight:600}.tree .meta{color:#666;font-size:.85rem;margin-left:auto}.tree .icon-folder::before{content:"📂"}.tree .icon-file::before{content:"📄"}#diagram-storage-status{min-height:1.4rem;color:#444}#diagram-storage-status.error{color:#c0392b}#diagram-storage-status.success{color:#1e8449}.modal{display:block;position:fixed;z-index:1;padding:5%;left:0;top:0;bottom:0;right:0;overflow:auto;background-color:#000;background-color:rgba(0,0,0,0.4)}.modal .modal-content{background-color:var(--modal-bg-color);margin:auto;padding:2rem;border:3px solid var(--border-color);max-width:30rem;box-shadow:0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19);-webkit-animation-name:modal-animatetop;-webkit-animation-duration:.4s;animation-name:modal-animatetop;animation-duration:.4s;position:relative;top:50%;transform:translateY(-50%)} @-webkit-keyframes modal-animatetop{from{top:-50%;opacity:0}to{top:50%;opacity:1}}@keyframes modal-animatetop{from{top:-50%;opacity:0}to{top:50%;opacity:1}}.modal .modal-header h2{margin:0}.modal .modal-main{flex:1}.modal .modal-footer{margin-top:1rem;text-align:right}.modal input,.modal select{border:1px solid var(--border-color)}.modal input:not(:focus):invalid{border-bottom-color:red}.modal input[type="file"]::file-selector-button{border:1px solid var(--border-color)} .modal input.ok,.modal input.cancel{min-width:5rem}.modal input.ok[disabled],.modal input.cancel[disabled]{color:var(--font-color-disabled)}.modal input.ok:not([disabled]):hover{border-bottom-color:green}.modal input.cancel:not([disabled]):hover{border-bottom-color:darkred}.modal .label-input-pair{margin:1rem 0;overflow:hidden}.modal .label-input-pair:first-child{margin-top:0}.modal .label-input-pair:last-child{margin-bottom:0}.modal .label-input-pair label{display:inline-block;min-width:15rem}.modal .label-input-pair label+input,.modal .label-input-pair label+select{box-sizing:border-box;display:inline-block;min-width:10rem}#settings #settings-monaco-editor{height:17rem;border:1px solid var(--border-color)}.diagram{height:100%;overflow:auto}.diagram[data-diagram-type="pdf"]{overflow:hidden}.diagram>div{margin:1rem 0;text-align:center}.diagram[data-diagram-type="pdf"]>div{height:20em;width:100%}.diagram img,.diagram svg,.diagram pre{border:3px solid var(--border-color);box-sizing:border-box;padding:10px}@media screen and (min-width:900px){.diagram{position:relative}.diagram>div{margin:0}.diagram:not([data-diagram-type="pdf"])>div{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);max-height:100%;max-width:100%} .diagram[data-diagram-type="pdf"]>div{height:100%}}.preview-menu{margin-left:5%;margin-right:5%}.diagram-link img,.btn-dock{width:2.5rem}.btn-settings{width:2.2rem;margin-left:auto;margin-right:.25rem}.menu-r{min-width:3rem}.menu-r .btn-float-r{float:right;margin-left:.25rem;text-align:right}.diagram-links{align-items:center;display:flex}.diagram-link{margin-left:.25rem;margin-right:.25rem}.diagram-links .diagram-link:first-of-type{margin-left:.5rem}.diagram-links .diagram-link:last-of-type{margin-right:0}#paginator{text-align:center;margin-bottom:1rem}.previewer-container{height:100%}@media screen and (max-width:900px){.previewer-container{height:initial}.previewer-main{flex:none}} \ No newline at end of file diff --git a/src/main/webapp/min/plantuml.min.js b/src/main/webapp/min/plantuml.min.js index 8555579..ed88884 100644 --- a/src/main/webapp/min/plantuml.min.js +++ b/src/main/webapp/min/plantuml.min.js @@ -1,9 +1,9 @@ -'use strict';var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(a){var b=0;return function(){return b>>0,$jscomp.propertyToPolyfillSymbol[e]=$jscomp.IS_SYMBOL_NATIVE? $jscomp.global.Symbol(e):$jscomp.POLYFILL_PREFIX+c+"$"+e),$jscomp.defineProperty(d,$jscomp.propertyToPolyfillSymbol[e],{configurable:!0,writable:!0,value:b})))};$jscomp.assign=$jscomp.TRUST_ES6_POLYFILLS&&"function"==typeof Object.assign?Object.assign:function(a,b){for(var c=1;c=r&&(u=r-1);makeRequest("POST","coder",{data:t}).then(function(v){sendMessage({sender:p,data:{encodedDiagram:v,numberOfDiagramPages:r,index:u},synchronize:!0})})}k=void 0===k?!0:k;var q=function(){return function(){var r=document.editor.getModel();m=m||new PlantUmlLanguageFeatures; -m.validateCode(r).then(function(u){return monaco.editor.setModelMarkers(r,"plantuml",u)})}}();p&&k&&n();q()}var m,l=monaco.editor.createModel(function(){var t=document.getElementById("initCode"),p=t.value;t.remove();return p}(),"apex",monaco.Uri.parse("inmemory://plantuml")),h=0;l.onDidChangeContent(function(){clearTimeout(h);document.appConfig.autoRefreshState="waiting";h=setTimeout(function(){return g(l.getValue(),"editor")},document.appConfig.editorWatcherTimeout)});return l}function d(){return{get:function(){}, +var $jscomp$destructuring$var0=function(){return{setEditorValue:function(a,b,c){c=void 0===c?{}:c;var d=void 0===c.forceMoveMarkers?void 0:c.forceMoveMarkers;(void 0===c.suppressEditorChangedMessage?0:c.suppressEditorChangedMessage)&&a===document.editor&&suppressNextMessage("editor");a.executeEdits("",[{range:a.getModel().getFullModelRange(),text:b,forceMoveMarkers:d}])},initEditor:function(a){function b(){return new Promise(function(g,n){require.config({paths:{vs:"webjars/monaco-editor/0.36.1/min/vs"}}); +require(["vs/editor/editor.main"],g)})}function c(){function g(t,u,m){function r(){document.appConfig.autoRefreshState="started";var h=getNumberOfDiagramPagesFromCode(t),y=document.appData.index;void 0===y||1===h?y=void 0:y>=h&&(y=h-1);makeRequest("POST","coder",{data:t}).then(function(D){sendMessage({sender:u,data:{encodedDiagram:D,numberOfDiagramPages:h,index:y},synchronize:!0})})}m=void 0===m?!0:m;var v=function(){return function(){var h=document.editor.getModel();n=n||new PlantUmlLanguageFeatures; +n.validateCode(h).then(function(y){return monaco.editor.setModelMarkers(h,"plantuml",y)})}}();u&&m&&r();v()}var n,p=monaco.editor.createModel(function(){var t=document.getElementById("initCode"),u=t.value;t.remove();return u}(),"apex",monaco.Uri.parse("inmemory://plantuml")),l=0;p.onDidChangeContent(function(){clearTimeout(l);document.appConfig.autoRefreshState="waiting";l=setTimeout(function(){return g(p.getValue(),"editor")},document.appConfig.editorWatcherTimeout)});return p}function d(){return{get:function(){}, getBoolean:function(g){return"expandSuggestionDocs"===g},getNumber:function(){return 0},remove:function(){},store:function(){},onWillSaveState:function(){},onDidChangeStorage:function(){},onDidChangeValue:function(){}}}var e,f;return $jscomp.asyncExecutePromiseGeneratorProgram(function(g){if(1==g.nextAddress)return g.yield(b(),2);"previewer"!==a&&(e=c(),f=d(),document.editor=monaco.editor.create(document.getElementById("monaco-editor"),Object.assign({},{model:e},document.appConfig.editorCreateOptions), {storageService:f}),document.addEventListener("resize",function(){return document.editor.layout()}),initEditorUrlInput(),initEditorMenu());g.jumpToEnd()})}}}(),setEditorValue=$jscomp$destructuring$var0.setEditorValue,initEditor=$jscomp$destructuring$var0.initEditor; function initEditorMenu(){document.getElementById("menu-item-editor-code-copy").addEventListener("click",function(){var a=document.editor.getModel().getFullModelRange();document.editor.focus();document.editor.setSelection(a);a=document.editor.getValue();var b;null==(b=navigator.clipboard)||b.writeText(a).catch(function(){})})} @@ -36,41 +36,56 @@ function(c){var d,e;return $jscomp.asyncExecutePromiseGeneratorProgram(function( function(){b.focus();b.select();var c;null==(c=navigator.clipboard)||c.writeText(b.value).catch(function(){})})}}}(),setUrlValue=$jscomp$destructuring$var3.setUrlValue,initEditorUrlInput=$jscomp$destructuring$var3.initEditorUrlInput; function initDiagramExport(){function a(){setVisibility(document.getElementById("diagram-export"),!0,!0);var f=document.editor.getValue();f=Array.from(f.matchAll(/^\s*@start[a-zA-Z]*\s+([a-zA-Z-_\u00e4\u00f6\u00fc\u00c4\u00d6\u00dc\u00df ]+)\s*$/gm),function(g){return g[1]})[0]||"diagram";d.value=f+".puml";e.value="code";d.focus()}function b(f){var g=f.lastIndexOf(".");return 1>g?{name:f,ext:null}:g===f.length-1?{name:f.slice(0,-1),ext:null}:{name:f.substring(0,g),ext:f.substring(g+1)}}function c(f){if(!f)return f; f=f.toLowerCase();switch(f){case "puml":case "plantuml":case "code":return"code";case "ascii":return"txt";default:return f}}var d=document.getElementById("download-name"),e=document.getElementById("download-type");registerModalListener("diagram-export",a);d.addEventListener("change",function(f){f=b(f.target.value).ext;if(f=c(f))e.value=f});e.addEventListener("change",function(f){f=f.target.value;a:switch(f){case "epstext":f="eps";break a;case "code":f="puml"}var g=b(d.value).name;d.value=g+"."+f}); -document.getElementById("diagram-export-ok-btn").addEventListener("click",function(){var f=d.value,g=e.value,m=document.createElement("a");m.download=f;"code"===g?(f=document.editor.getValue(),m.href="data:,"+encodeURIComponent(f)):m.href=void 0!==document.appData.index?g+"/"+document.appData.index+"/"+document.appData.encodedDiagram:g+"/"+document.appData.encodedDiagram;m.click()});window.addEventListener("keydown",function(f){"s"===f.key&&(isMac?f.metaKey:f.ctrlKey)&&(f.preventDefault(),isModalOpen("diagram-export")|| +document.getElementById("diagram-export-ok-btn").addEventListener("click",function(){var f=d.value,g=e.value,n=document.createElement("a");n.download=f;"code"===g?(f=document.editor.getValue(),n.href="data:,"+encodeURIComponent(f)):n.href=void 0!==document.appData.index?g+"/"+document.appData.index+"/"+document.appData.encodedDiagram:g+"/"+document.appData.encodedDiagram;n.click()});window.addEventListener("keydown",function(f){"s"===f.key&&(isMac?f.metaKey:f.ctrlKey)&&(f.preventDefault(),isModalOpen("diagram-export")|| a())},!1)} -function initDiagramImport(){function a(h){h=void 0===h?!0:h;setVisibility(f,!0,!0);f.dataset.isOpenManually=h.toString();g.value="";c(g)}function b(){g.value="";c(g);f.removeAttribute("data-is-open-manually");setVisibility(f,!1)}function c(h){l.innerText="";var t;m.disabled=1>(null==(t=h.files)?void 0:t.length)}function d(h){function t(k){var n=k.name,q=k.type;k=["plain","text","plantuml","puml"];if(0p.length)return t();p=p[0];var k=d(p);if(!k.valid)return t();"true"!==f.dataset.isOpenManually&&(t(),e(p,k))},!1);g.addEventListener("change",function(h){return c(h.target)});m.addEventListener("click",function(){var h=g.files[0];e(h,d(h))});registerModalListener("diagram-import",a,b)} +function initDiagramImport(){function a(l){l=void 0===l?!0:l;setVisibility(f,!0,!0);f.dataset.isOpenManually=l.toString();g.value="";c(g)}function b(){g.value="";c(g);f.removeAttribute("data-is-open-manually");setVisibility(f,!1)}function c(l){p.innerText="";var t;n.disabled=1>(null==(t=l.files)?void 0:t.length)}function d(l){function t(m){var r=m.name,v=m.type;m=["plain","text","plantuml","puml"];if(0u.length)return t();u=u[0];var m=d(u);if(!m.valid)return t();"true"!==f.dataset.isOpenManually&&(t(),e(u,m))},!1);g.addEventListener("change",function(l){return c(l.target)});n.addEventListener("click",function(){var l=g.files[0];e(l,d(l))});registerModalListener("diagram-import",a,b)} var $jscomp$destructuring$var16=function(){var a={};return{registerModalListener:function(b,c,d){a[b]={fnOpen:c,fnClose:d}},openModal:function(b){var c=$jscomp.getRestArguments.apply(1,arguments),d,e=null==(d=a[b])?void 0:d.fnOpen;e?e.apply(null,$jscomp.arrayFromIterable(c)):setVisibility(document.getElementById(b),!0,!0)},closeModal:function(b){var c=$jscomp.getRestArguments.apply(1,arguments),d,e=null==(d=a[b])?void 0:d.fnClose;e?e.apply(null,$jscomp.arrayFromIterable(c)):setVisibility(document.getElementById(b), !1)}}}(),registerModalListener=$jscomp$destructuring$var16.registerModalListener,openModal=$jscomp$destructuring$var16.openModal,closeModal=$jscomp$destructuring$var16.closeModal; -function initModals(a){function b(c){"Escape"===c.key||"Esc"===c.key?(c.preventDefault(),closeModal(c.target.closest(".modal").id)):"Enter"===c.key&&(c.preventDefault(),(c=c.target.closest(".modal").querySelector('input.ok[type\x3d"button"]'))&&!c.disabled&&c.click())}document.querySelectorAll(".modal").forEach(function(c){c.addEventListener("keydown",b,!1)});initSettings();"previewer"!==a&&(initDiagramExport(),initDiagramImport())} +function initModals(a){function b(c){"Escape"===c.key||"Esc"===c.key?(c.preventDefault(),closeModal(c.target.closest(".modal").id)):"Enter"===c.key&&(c.preventDefault(),(c=c.target.closest(".modal").querySelector('input.ok[type\x3d"button"]'))&&!c.disabled&&c.click())}document.querySelectorAll(".modal").forEach(function(c){c.addEventListener("keydown",b,!1)});initSettings();"previewer"!==a&&(initDiagramExport(),initDiagramImport(),initDiagramStorage())} function isModalOpen(a){return isVisible(document.getElementById(a))}function closeAllModals(){document.querySelectorAll(".modal").forEach(function(a){return closeModal(a.id)})} function initSettings(){function a(){setVisibility(document.getElementById("settings"),!0,!0);b.value=document.appConfig.theme;c.value=document.appConfig.diagramPreviewType;d.value=document.appConfig.editorWatcherTimeout;setEditorValue(document.settingsEditor,JSON.stringify(document.appConfig.editorCreateOptions,null," "))}var b=document.getElementById("theme"),c=document.getElementById("diagramPreviewType"),d=document.getElementById("editorWatcherTimeout");document.settingsEditor=monaco.editor.create(document.getElementById("settings-monaco-editor"), Object.assign({},{language:"json"},document.appConfig.editorCreateOptions));b.addEventListener("change",function(e){e=e.target.value;var f=document.settingsEditor.getValue();setEditorValue(document.settingsEditor,f.replace(new RegExp('("theme"\\s*:\\s*)"'+("dark"===e?"vs":"vs-dark")+'"',"gm"),'$1"'+("dark"===e?"vs-dark":"vs")+'"'))});document.getElementById("settings-ok-btn").addEventListener("click",function(){var e=Object.assign({},document.appConfig);e.theme=b.value;e.editorWatcherTimeout=d.value; e.diagramPreviewType=c.value;e.editorCreateOptions=JSON.parse(document.settingsEditor.getValue());updateConfig(e);closeModal("settings")});window.addEventListener("keydown",function(e){","===e.key&&(isMac?e.metaKey:e.ctrlKey)&&(e.preventDefault(),isModalOpen("settings")||a())},!1);registerModalListener("settings",a)} function initializeDiagram(){return $jscomp.asyncExecutePromiseGeneratorProgram(function(a){if("png"!==document.appConfig.diagramPreviewType)return a.return(setDiagram(document.appConfig.diagramPreviewType,document.appData.encodedDiagram,document.appData.index));a.jumpToEnd()})} -function setDiagram(a,b,c){function d(k,n,q){return $jscomp.asyncExecutePromiseGeneratorProgram(function(r){return r.return(makeRequest("GET",buildUrl(k,n,q)))})}var e,f,g,m,l,h,t,p;return $jscomp.asyncExecutePromiseGeneratorProgram(function(k){switch(k.nextAddress){case 1:e=document.getElementById("diagram");f=document.getElementById("diagram-png");g=document.getElementById("diagram-txt");m=document.getElementById("diagram-pdf");k.setCatchFinallyBlocks(2);if("png"===a)return f.src=buildUrl("png", -b,c),k.yield(d("map",b,c),11);if("svg"===a)return k.yield(d("svg",b,c),10);if("txt"!==a){if("pdf"===a)m.data=buildUrl("pdf",b,c);else return l="unknown diagram type: "+a,(console.error||console.log)(l),k.return(Promise.reject(l));k.jumpTo(5);break}h=g;return k.yield(d("txt",b,c),9);case 9:h.innerHTML=k.yieldResult;k.jumpTo(5);break;case 10:t=k.yieldResult;var n=document.getElementById("diagram-svg"),q=document.createElement("div");q.innerHTML=t;q=q.querySelector("svg");q.id="diagram-svg";q.classList= -n.classList;q.style.cssText=n.style.cssText;n.parentNode.replaceChild(q,n);k.jumpTo(5);break;case 11:if(p=k.yieldResult,n=document.getElementById("plantuml_map"),q=document.getElementById("map-diagram-link"),p){var r=document.createElement("div");r.innerHTML=p;n.parentNode.replaceChild(r.firstChild,n);setVisibility(q,!0)}else removeChildren(n),setVisibility(q,!1);case 5:n=document.getElementById("plantuml_map");q=document.getElementById("diagram-svg");e.setAttribute("data-diagram-type",a);setVisibility(f, -"png"===a);setVisibility(n,"png"===a);setVisibility(q,"svg"===a);setVisibility(g,"txt"===a);setVisibility(m,"pdf"===a);k.leaveTryBlock(0);break;case 2:k.enterCatchBlock(),k.jumpToEnd()}})}function getNumberOfDiagramPagesFromCode(a){var b;return(null==(b=a.match(/^\s*newpage\s?.*$/gm))?void 0:b.length)+1||1} +function setDiagram(a,b,c){function d(m,r,v){return $jscomp.asyncExecutePromiseGeneratorProgram(function(h){return h.return(makeRequest("GET",buildUrl(m,r,v)))})}var e,f,g,n,p,l,t,u;return $jscomp.asyncExecutePromiseGeneratorProgram(function(m){switch(m.nextAddress){case 1:e=document.getElementById("diagram");f=document.getElementById("diagram-png");g=document.getElementById("diagram-txt");n=document.getElementById("diagram-pdf");m.setCatchFinallyBlocks(2);if("png"===a)return f.src=buildUrl("png", +b,c),m.yield(d("map",b,c),11);if("svg"===a)return m.yield(d("svg",b,c),10);if("txt"!==a){if("pdf"===a)n.data=buildUrl("pdf",b,c);else return p="unknown diagram type: "+a,(console.error||console.log)(p),m.return(Promise.reject(p));m.jumpTo(5);break}l=g;return m.yield(d("txt",b,c),9);case 9:l.innerHTML=m.yieldResult;m.jumpTo(5);break;case 10:t=m.yieldResult;var r=document.getElementById("diagram-svg"),v=document.createElement("div");v.innerHTML=t;v=v.querySelector("svg");v.id="diagram-svg";v.classList= +r.classList;v.style.cssText=r.style.cssText;r.parentNode.replaceChild(v,r);m.jumpTo(5);break;case 11:if(u=m.yieldResult,r=document.getElementById("plantuml_map"),v=document.getElementById("map-diagram-link"),u){var h=document.createElement("div");h.innerHTML=u;r.parentNode.replaceChild(h.firstChild,r);setVisibility(v,!0)}else removeChildren(r),setVisibility(v,!1);case 5:r=document.getElementById("plantuml_map");v=document.getElementById("diagram-svg");e.setAttribute("data-diagram-type",a);setVisibility(f, +"png"===a);setVisibility(r,"png"===a);setVisibility(v,"svg"===a);setVisibility(g,"txt"===a);setVisibility(n,"pdf"===a);m.leaveTryBlock(0);break;case 2:m.enterCatchBlock(),m.jumpToEnd()}})}function getNumberOfDiagramPagesFromCode(a){var b;return(null==(b=a.match(/^\s*newpage\s?.*$/gm))?void 0:b.length)+1||1} function updatePaginatorSelection(){var a=document.getElementById("paginator"),b=document.appData.index;if(void 0===b||a.childNodes.length<=b)for(a=$jscomp.makeIterator(a.childNodes),b=a.next();!b.done;b=a.next())b.value.checked=!1;else a.childNodes[b].checked=!0} var updatePaginator=function(){function a(b,c){for(;b.childElementCount>c;)b.removeChild(b.lastChild);for(;b.childElementCount 0; + } + + function renderNode(node, expandedPaths, depth) { + const li = document.createElement("li"); + li.dataset.id = node.id || ""; + const row = document.createElement("div"); + row.className = "row"; + row.style.paddingLeft = `${(depth + 1) * 1.2}rem`; + + const caret = document.createElement("span"); + caret.className = "caret"; + const folder = isFolder(node); + const expanded = expandedPaths.has(node.id) || depth === 0; + caret.textContent = folder ? (expanded ? "▾" : "▸") : ""; + row.appendChild(caret); + + const label = document.createElement("span"); + label.className = "label " + (folder ? "icon-folder" : "icon-file"); + label.textContent = node.text || node.id || ""; + row.appendChild(label); + + if (node.meta) { + const meta = document.createElement("span"); + meta.className = "meta"; + meta.textContent = node.meta; + row.appendChild(meta); + } + + li.appendChild(row); + + if (folder) { + const ul = document.createElement("ul"); + ul.className = "btv"; + ul.style.display = expanded ? "block" : "none"; + node.nodes.forEach(child => { + ul.appendChild(renderNode(child, expandedPaths, depth + 1)); + }); + li.appendChild(ul); + } + return li; + } + + function create(container, data, { expandedPaths = new Set(), onClick, onDblClick } = {}) { + if (!container) return; + container.innerHTML = ""; + const ul = document.createElement("ul"); + ul.className = "btv"; + data.forEach(node => ul.appendChild(renderNode(node, expandedPaths, 0))); + + ul.addEventListener("click", evt => { + const row = evt.target.closest(".row"); + const li = evt.target.closest("li"); + if (!li) return; + const sub = li.querySelector(":scope > ul"); + if (sub) { + const caret = row?.querySelector(".caret") || li.querySelector(":scope > .row .caret"); + const hidden = sub.style.display === "none"; + sub.style.display = hidden ? "block" : "none"; + if (caret) caret.textContent = hidden ? "▾" : "▸"; + if (hidden) { + expandedPaths.add(li.dataset.id); + } else { + expandedPaths.delete(li.dataset.id); + } + } + onClick?.(li.dataset.id || ""); + }); + + ul.addEventListener("dblclick", evt => { + const li = evt.target.closest("li"); + if (!li) return; + const sub = li.querySelector(":scope > ul"); + if (!sub) { + onDblClick?.(li.dataset.id || ""); + } + }); + + container.appendChild(ul); + } + + window.BootstrapTreeView = { create }; +})();