diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..fa2cda69bd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +eXo Documents is an add-on for the eXo Platform: the documents/files management feature +(drives, file timeline/folder browsing, trash, public access links, attachments, previews, +WebDAV access, search and notifications). It is a multi-module Maven project whose backend +runs inside the eXo Platform Kernel (an IoC container configured via XML) and whose frontend +is a set of Vue 2 portlet apps bundled with Webpack. + +## Build & test + +JDK 21 and Maven. The project inherits from `org.exoplatform:maven-exo-parent-pom` and depends +on `org.exoplatform.jcr:jcr-parent` (7.2.x-SNAPSHOT). Most dependencies resolve from +`https://repository.exoplatform.org/public`, so a network/Nexus connection is required. + +```bash +# Full build (compiles Java, runs the frontend build via frontend-maven-plugin, runs tests) +mvn clean install + +# Build skipping tests +mvn clean install -DskipTests + +# CI build profiles (see .github/workflows/prbuild.yml) +mvn clean install -Pexo-release,coverage + +# Run the test suite for one module +mvn test -pl documents-services + +# Run a single test class / single method +mvn test -pl documents-services -Dtest=DocumentFileServiceTest +mvn test -pl documents-services -Dtest=DocumentFileServiceTest#methodName +``` + +Frontend (run inside `documents-webapp/`; also wired into the Maven build via frontend-maven-plugin): + +```bash +npm run build # production webpack build -> target/documents-portlet/ +npm run watch # webpack --watch +npm run lint # eslint --fix over src/main/webapp/vue-app +npm run eslint-check # eslint without fixing (CI check) +``` + +## Module architecture + +The Maven reactor builds these modules in order; the dependency direction is API ← storage/services ← webapp: + +- **documents-api** — pure contracts: service interfaces (`service/`), storage SPI interfaces + (`storage/`), and the domain model POJOs (`model/`) + constants. No implementations here. + Both other backend modules depend on this. +- **documents-storage-jcr** — implements the storage SPI against eXo JCR (Java Content Repository). + This is where documents physically live as JCR nodes. Contains bulk actions, JCR search, + WebDAV node handling, and the legacy search layer. +- **documents-services** — implements the service interfaces, REST endpoints (`rest/`), DAOs/entities + (RDBMS via JPA, e.g. for public-access links), event listeners (`listener/`), notification + plugins/providers (`notification/`), filters, and websocket service. +- **documents-webapp** — the portlet WAR: Vue frontend (`src/main/webapp/vue-app/`) plus the eXo + Kernel/portlet wiring under `WEB-INF/conf` and `WEB-INF/*.xml`. +- **documents-webdav-webapp** — separate WAR exposing documents over WebDAV. +- **documents-packaging** — assembles the deployable add-on zip. + +### Backend wiring (important) + +There is no Spring/CDI. Components are registered with the **eXo Kernel container** through XML in +`documents-webapp/src/main/webapp/WEB-INF/conf/documents/`. To add or change a backend service, +listener, REST resource, search connector, or filter, you must edit the corresponding +`*-configuration.xml` (chiefly `documents-service-configuration.xml`) — registering the class in +code alone does nothing. The pattern is `` (key = interface, type = impl) and +`` (attaching plugins/listeners to a target component such as +`ListenerService`, `SpaceService`, `SearchService`, `ExtensibleFilter`). + +The layering at runtime: REST (`DocumentFileRest`) → Service impl (`DocumentFileServiceImpl`, +`PublicDocumentAccessServiceImpl`, `ExternalDownloadServiceImpl`) → Storage SPI +(`DocumentFileStorage`, `TrashStorage`, `JCRDeleteFileStorage`, `PublicDocumentAccessStorage`) → +JCR / JPA implementations in documents-storage-jcr and documents-services. + +### Frontend architecture + +Each subdirectory of `documents-webapp/src/main/webapp/vue-app/` is an independent Vue 2 app with a +`main.js` entry, registered as a separate Webpack bundle in `webpack.prod.js` (see the `entry` map — +e.g. `documents`, `attachment`, `trash-management`, `files-search`, `restrictedDrive`, the offline +and pwa extensions). Bundles output to `target/documents-portlet/js/[name].bundle.js` with +`libraryTarget: 'amd'`. `vue`, `vuetify`, and `jquery` are **externals** provided by the host +platform, not bundled. ESLint uses `eslint-config-meedsio` plus the Vue and vuejs-accessibility +plugins. + +## Conventions + +- Backend tests use JUnit/Mockito and live in `src/test/java` mirroring the package of the class + under test. They run inside a mocked Kernel context, not a live container. +- i18n: resource bundles are managed through Crowdin (`crowdin.yml`, `translations.properties`, and + the `*-crowdin*.yml` workflows). Edit the source English bundle; translations sync via CI. +- Version is `7.2.x-SNAPSHOT`; the default branch is `develop` and PRs target it. +- JIRA issue keys (e.g. `EXO-XXXXX`) are referenced in commit/PR titles following the + `type: summary - EXO-XXXXX` convention seen in git history. diff --git a/documents-api/pom.xml b/documents-api/pom.xml index fbfa57f068..8b646ac506 100644 --- a/documents-api/pom.xml +++ b/documents-api/pom.xml @@ -3,7 +3,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT documents-api eXo Documents - API diff --git a/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java b/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java index 68fc343364..1421d2e9cd 100644 --- a/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java +++ b/documents-api/src/main/java/org/exoplatform/documents/service/DocumentFileService.java @@ -316,6 +316,20 @@ void updatePermissions(String documentId, AbstractNode createFolder(long ownerId,String folderId, String folderPath, String name, long authenticatedUserId) throws IllegalAccessException, ObjectAlreadyExistsException, ObjectNotFoundException; + /** + * Creates a new empty office document (Word/Excel/PowerPoint/ODF) from the + * platform's blank template, delegating to the ecms {@code DocumentService}. + * + * @param ownerId owner identity id (used when folderId is blank) + * @param folderId identifier of the parent folder node + * @param folderPath optional relative path under the parent folder + * @param title document title (should already include the file extension) + * @param documentType office document extension (docx, xlsx, pptx, odt, ods, odp) + * @param authenticatedUserId identity id of the user creating the document + * @return the created {@link AbstractNode} + */ + AbstractNode createDocumentFromTemplate(long ownerId, String folderId, String folderPath, String title, String documentType, long authenticatedUserId) throws IllegalAccessException, ObjectNotFoundException, ObjectAlreadyExistsException; + String getNewName(long ownerId, String folderId, String folderPath, String name) throws IllegalAccessException, ObjectAlreadyExistsException, ObjectNotFoundException; void renameDocument(long ownerId, String documentID, String name, long authenticatedUserId) throws IllegalAccessException, ObjectAlreadyExistsException, ObjectNotFoundException; diff --git a/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java b/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java index d12f697694..2e9ab9653e 100644 --- a/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java +++ b/documents-api/src/main/java/org/exoplatform/documents/storage/DocumentFileStorage.java @@ -240,6 +240,30 @@ void moveDocument(Session session, AbstractNode createFolder(long ownerId, String folderId, String folderPath, String title, Identity aclIdentity) throws IllegalAccessException, ObjectAlreadyExistsException, ObjectNotFoundException; + /** + * Creates a new empty office document (Word/Excel/PowerPoint/ODF) from the + * platform's blank template, by delegating to the ecms {@code DocumentService}. + * + * @param ownerId owner identity id (used when folderId is blank to resolve the + * identity root folder) + * @param folderId identifier of the parent folder node + * @param folderPath optional relative path under the parent folder + * @param title document title (should already include the file extension) + * @param documentType office document extension (e.g. docx, xlsx, pptx, odt, + * ods, odp) matched against the installed template providers + * @param aclIdentity ACL identity of the user creating the document + * @return the created {@link AbstractNode} + * @throws IllegalAccessException when the user isn't allowed to edit the parent + * folder + * @throws ObjectNotFoundException when the parent folder or a matching template + * doesn't exist + * @throws ObjectAlreadyExistsException when a document with the same name + * already exists in the target folder + */ + AbstractNode createDocumentFromTemplate(long ownerId, String folderId, String folderPath, String title, String documentType, Identity aclIdentity) throws IllegalAccessException, + ObjectNotFoundException, + ObjectAlreadyExistsException; + String getNewName(long ownerId, String folderId, String folderPath, diff --git a/documents-packaging/pom.xml b/documents-packaging/pom.xml index fee4594745..4d3880f120 100644 --- a/documents-packaging/pom.xml +++ b/documents-packaging/pom.xml @@ -4,7 +4,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT documents-packaging pom diff --git a/documents-services/pom.xml b/documents-services/pom.xml index 1c19fcdd0e..05eae89345 100644 --- a/documents-services/pom.xml +++ b/documents-services/pom.xml @@ -3,7 +3,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT documents-services eXo Documents - Services @@ -41,6 +41,28 @@ jar provided + + + io.meeds.mcp-server + mcp-server-tools + 7.3.x-ai-contribution-SNAPSHOT + provided + + + + org.exoplatform.ecms + ecms-core-services + ${addon.exo.ecms.version} + provided + + + + io.meeds.notes + notes-service + provided + io.meeds.social @@ -62,6 +84,16 @@ documents-services + + org.apache.maven.plugins + maven-compiler-plugin + + + + -parameters + + + io.openapitools.swagger swagger-maven-plugin diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java new file mode 100644 index 0000000000..ab3ae8b4e2 --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/DocumentMcpTool.java @@ -0,0 +1,1427 @@ +/** + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp; + +import static io.meeds.mcp.server.tool.util.McpToolPluginUtils.getInteger; +import static io.meeds.mcp.server.util.McpToolUtils.formatDate; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import javax.jcr.RepositoryException; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import org.exoplatform.commons.ObjectAlreadyExistsException; +import org.exoplatform.commons.exception.ObjectNotFoundException; +import org.exoplatform.commons.file.services.FileService; +import org.exoplatform.container.ExoContainerContext; +import org.exoplatform.documents.constant.FileListingType; +import org.exoplatform.documents.mcp.model.BreadcrumbItemModel; +import org.exoplatform.documents.mcp.model.DocumentFileModel; +import org.exoplatform.documents.mcp.model.DocumentFolderModel; +import org.exoplatform.documents.mcp.model.DocumentModel; +import org.exoplatform.documents.mcp.model.DocumentPublicLinkModel; +import org.exoplatform.documents.mcp.model.DocumentTreeItemModel; +import org.exoplatform.documents.mcp.model.DocumentVersionModel; +import org.exoplatform.documents.mcp.model.DocumentsSizeModel; +import org.exoplatform.documents.model.AbstractNode; +import org.exoplatform.documents.model.BreadCrumbItem; +import org.exoplatform.documents.model.DocumentFolderFilter; +import org.exoplatform.documents.model.DocumentTimelineFilter; +import org.exoplatform.documents.model.DocumentsSize; +import org.exoplatform.documents.model.FileNode; +import org.exoplatform.documents.model.FileVersion; +import org.exoplatform.documents.model.FolderNode; +import org.exoplatform.documents.model.FullTreeItem; +import org.exoplatform.documents.model.NodePermission; +import org.exoplatform.documents.model.PermissionEntry; +import org.exoplatform.documents.model.PermissionRole; +import org.exoplatform.documents.model.PublicDocumentAccess; +import org.exoplatform.documents.service.DocumentFileService; +import org.exoplatform.documents.service.PublicDocumentAccessService; +import org.exoplatform.portal.config.UserACL; +import org.exoplatform.portal.config.UserPortalConfigService; +import org.exoplatform.portal.rest.UserFieldValidator; +import org.exoplatform.services.attachments.service.AttachmentService; +import org.exoplatform.social.core.identity.model.Identity; +import org.exoplatform.social.core.manager.IdentityManager; +import org.exoplatform.social.core.profileproperty.ProfilePropertyService; +import org.exoplatform.social.core.space.model.Space; +import org.exoplatform.social.core.space.spi.SpaceService; +import org.exoplatform.social.metadata.favorite.FavoriteService; +import org.exoplatform.social.metadata.favorite.model.Favorite; +import org.exoplatform.upload.UploadService; +import org.exoplatform.wiki.WikiException; +import org.exoplatform.wiki.model.Page; +import org.exoplatform.wiki.service.NoteService; + +import io.meeds.mcp.server.plugin.McpToolPlugin; +import io.meeds.mcp.server.tool.model.UserModel; +import io.meeds.mcp.server.tool.util.UploadToolUtils; +import io.meeds.mcp.server.tool.util.UserToolUtils; +import io.meeds.social.translation.service.TranslationService; + +import lombok.SneakyThrows; + +/** + * MCP tools exposing the Documents (DMS) add-on to the AI agent (EVA). Every + * method acts as the current user, so document ACLs are enforced by + * {@link DocumentFileService}. These tools only READ or update document + * metadata / content; they carry no coupling to the AI service layer (the + * AI-powered document experiences remain in the enterprise edition). + */ +@Service +@Profile("mcp-server") +public class DocumentMcpTool implements McpToolPlugin { + + public static final String FOLDER_URL_FORMAT = "/portal/dw/documents?folderId=%s"; + + public static final String FILE_URL_FORMAT = "/portal/dw/documents?documentPreviewId=%s"; + + /** + * Path of the anonymous public-download portal page (global page + * download-document); the document node id is appended as an + * extra path segment, mirroring how the Documents front-end builds the + * shareable link ({origin}/{containerName}/download-document/{nodeId}). + */ + public static final String PUBLIC_LINK_URL_FORMAT = "/portal/download-document/%s"; + + /** + * Same password policy DocumentFileRest applies to public-link passwords + * (min length 9), so a link created here is openable with the given password. + */ + private static final UserFieldValidator PASSWORD_VALIDATOR = new UserFieldValidator("password", false, false, 9, 255); + + /** + * Favorite object type for files. Ground truth: the Documents app reads/writes + * file favorites as {@code metadataService} metadata "favorites" on object type + * {@code "file"} + the JCR node id (see {@code EntityBuilder}, the + * {@code DocumentsFavoriteButton.vue} button and the favorites drawer + * registration). Writing any other type produces a favorite that no surface in + * the app ever shows. + */ + private static final String FAVORITE_TYPE_FILE = "file"; + + /** + * Structured / binary office and PDF formats that cannot be produced from a + * plain text string: they are zipped packages (OOXML/ODF) or a binary layout + * (PDF), so writing text content as the file body would yield a corrupt file. + * {@link #createDocument} rejects these up front and points to + * {@code upload_document} (real file bytes) instead. + */ + private static final Set UNSUPPORTED_BINARY_EXTENSIONS = + Set.of("docx", "xlsx", "pptx", "ppsx", "potx", "dotx", "xltx", + "xls", "doc", "ppt", "odt", "ods", "odp", "pdf"); + + /** + * Office document types that {@link #createDocumentFromTemplate} can create as a + * new empty file from the platform's blank template (via the ecms + * {@code DocumentService}). Availability of a given type at runtime still + * depends on the installed document editor add-on (onlyoffice). + */ + private static final Set SUPPORTED_TEMPLATE_TYPES = + Set.of("docx", "xlsx", "pptx"); + + private final DocumentFileService documentFileService; + + private final AttachmentService attachmentService; + + /** + * Social attachment service (distinct from the ecms {@link AttachmentService} + * above), used to resolve a chat-attached file referenced by + * attachment_object_type / attachment_object_id back + * to its stored bytes, as the current user, so its ACL is enforced. + */ + private final org.exoplatform.social.attachment.AttachmentService socialAttachmentService; + + private final FileService fileService; + + private final IdentityManager identityManager; + + private final SpaceService spaceService; + + private final TranslationService translationService; + + private final ProfilePropertyService profilePropertyService; + + private final UserACL userAcl; + + private final UserPortalConfigService portalConfigService; + + private final UploadService uploadService; + + private final PublicDocumentAccessService publicDocumentAccessService; + + private final FavoriteService favoriteService; + + public DocumentMcpTool(DocumentFileService documentFileService, + AttachmentService attachmentService, + org.exoplatform.social.attachment.AttachmentService socialAttachmentService, + FileService fileService, + IdentityManager identityManager, + SpaceService spaceService, + TranslationService translationService, + ProfilePropertyService profilePropertyService, + UserACL userAcl, + UserPortalConfigService portalConfigService, + UploadService uploadService, + PublicDocumentAccessService publicDocumentAccessService, + FavoriteService favoriteService) { + this.documentFileService = documentFileService; + this.attachmentService = attachmentService; + this.socialAttachmentService = socialAttachmentService; + this.fileService = fileService; + this.identityManager = identityManager; + this.spaceService = spaceService; + this.translationService = translationService; + this.profilePropertyService = profilePropertyService; + this.userAcl = userAcl; + this.portalConfigService = portalConfigService; + this.uploadService = uploadService; + this.publicDocumentAccessService = publicDocumentAccessService; + this.favoriteService = favoriteService; + } + + public DocumentFolderModel getRootFolderBySpace(long spaceId) throws ObjectNotFoundException, IllegalAccessException { + FolderNode spaceRootFolder = documentFileService.getSpaceRootFolder(spaceId, getCurrentUserAclIdentity()); + return toDocumentFolderModel(spaceRootFolder); + } + + public void attachDocumentToContent(String documentId, + String contentType, + long contentId) throws IllegalAccessException { + // FIXME Why using this content type name ??!! + contentId = fixNoteContentId(contentType, contentId); + // FIXME Why using this content type name ??!! + contentType = fixNoteContentType(contentType); + attachmentService.linkAttachmentToEntity(getCurrentUserIdentityId(), contentId, contentType, documentId); + } + + public void updateDocumentDescription(String documentId, String htmlDescription) throws IllegalAccessException, Exception { // NOSONAR + long ownerId = documentFileService.getRootFolderOwnerId(documentId); + documentFileService.updateDocumentDescription(ownerId, documentId, htmlDescription, getCurrentUserIdentityId()); + } + + public List getDocumentsByFolderId(String folderId, + Boolean filesOnly, + Boolean foldersOnly, + Integer offset, + Integer limit) throws ObjectNotFoundException, + IllegalAccessException { + List documentItems = documentFileService.getDocumentItems(FileListingType.FOLDER, + new DocumentFolderFilter(folderId, + null, + null, + null), + getInteger(offset, DEFAULT_OFFSET), + getInteger(limit, DEFAULT_LIMIT), + getCurrentUserIdentityId(), + true); + return documentItems.stream() + .filter(d -> (filesOnly == null || !filesOnly || d instanceof FileNode) + && (foldersOnly == null || !foldersOnly || d instanceof FolderNode)) + .map(this::toDocumentModel) + .toList(); + } + + public DocumentFolderModel getRootFolderForUser() { + FolderNode personalRootFolder = documentFileService.getPersonalRootFolder(getCurrentUserAclIdentity()); + return toDocumentFolderModel(personalRootFolder); + } + + public DocumentModel getDocumentById(String documentId) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + AbstractNode document = documentFileService.getDocumentById(documentId, getCurrentUserName()); + return toDocumentModel(document); + } + + public String getDocumentContentById(String documentId) throws IllegalAccessException, ObjectNotFoundException { + checkCanAccessDocument(documentId); + return documentFileService.getFileContentAsText(documentId); + } + + public String getDocumentTranscriptionById(String documentId) throws IllegalAccessException, ObjectNotFoundException { + AbstractNode document = checkCanAccessDocument(documentId); + String audioTranscription = documentFileService.getAudioTranscription(documentId, getCurrentUserIdentityId()); + if (StringUtils.isNotBlank(audioTranscription)) { + return audioTranscription; + } else if (document instanceof FileNode fileNode + && StringUtils.startsWithAny(fileNode.getMimeType(), "video/", "audio/")) { + throw new IllegalStateException("No transcription is available yet for this audio/video file. " + + "Transcriptions are generated automatically once the media is processed; tell the user to try again later."); + } else { + throw new IllegalStateException("The document with id %s isn't an audio nor a video file, so it has no transcription." + .formatted(documentId)); + } + } + + public List searchDocuments(String query, + Long spaceId, + String parentFolderId, + Integer offset, + Integer limit, + Boolean isFavorites) throws ObjectNotFoundException, IllegalAccessException { + DocumentTimelineFilter filter = new DocumentTimelineFilter(); + filter.setQuery(query); + if (isFavorites != null && isFavorites.booleanValue()) { + filter.setFavorites(true); + } + if (StringUtils.isNotBlank(parentFolderId)) { + filter.setParentFolderId(parentFolderId); + } else if (spaceId != null && spaceId > 0) { + Space space = spaceService.getSpaceById(String.valueOf(spaceId)); + if (space == null) { + throw new ObjectNotFoundException("Space with id %s not found. Use 'search_my_spaces' tool to check accessible spaces"); + } else if (!spaceService.canViewSpace(space, getCurrentUserName())) { + throw new IllegalAccessException("Space with id %s not accessbile for current user. Use 'search_my_spaces' tool to check accessible spaces"); + } + Identity spaceIdentity = identityManager.getOrCreateSpaceIdentity(space.getPrettyName()); + filter.setOwnerId(spaceIdentity.getIdentityId()); + } + List files = documentFileService.search(filter, + getCurrentUserAclIdentity(), + getInteger(offset, DEFAULT_OFFSET), + getInteger(limit, DEFAULT_LIMIT)); + return files.stream().map(this::toDocumentFileModel).toList(); + } + + // --------------------------------------------------------------------------- + // Folder navigation and metadata (reads) + // --------------------------------------------------------------------------- + + public List listFolderChildren(String folderId, + Integer offset, + Integer limit) throws ObjectNotFoundException, IllegalAccessException { + checkFolderIdParameter(folderId); + DocumentFolderFilter filter = new DocumentFolderFilter(folderId, null, null, null); + List children = documentFileService.getFolderChildNodes(filter, + getInteger(offset, DEFAULT_OFFSET), + getInteger(limit, DEFAULT_LIMIT), + getCurrentUserIdentityId()); + return children.stream().map(this::toDocumentModel).toList(); + } + + public List getFolderBreadcrumb(String folderId) throws ObjectNotFoundException, + IllegalAccessException { + checkFolderIdParameter(folderId); + // Validate the folder exists and is readable by the user, so a bad id fails + // with a clean ObjectNotFoundException / IllegalAccessException. + getNode(folderId); + // folderId already identifies the JCR node uniquely; the storage 'folderPath' + // argument is a RELATIVE sub-path resolved *from* that node (getNodeByPath), + // so it must stay empty. Passing the absolute node path makes the storage try + // to navigate further down and fail with "Folder with path ... isn't found". + List breadcrumb = documentFileService.getBreadcrumb(getOwnerId(folderId), + folderId, + null, + getCurrentUserIdentityId()); + return breadcrumb.stream() + .map(item -> new BreadcrumbItemModel(item.getId(), item.getName(), item.getPath())) + .toList(); + } + + public List getFolderTree(String folderId, + Boolean withChildren) throws ObjectNotFoundException, + IllegalAccessException { + checkFolderIdParameter(folderId); + List tree = documentFileService.getFullTreeData(getOwnerId(folderId), + folderId, + null, + getCurrentUserIdentityId(), + withChildren == null || withChildren.booleanValue(), + false); + return tree.stream().map(this::toTreeItemModel).toList(); + } + + public List listDocumentVersions(String documentId) throws IllegalAccessException, + ObjectNotFoundException { + checkCanAccessDocument(documentId); + List versions = documentFileService.getFileVersions(documentId, getCurrentUserName()); + return versions.stream().map(this::toVersionModel).toList(); + } + + public DocumentsSizeModel getDocumentsSize(Long ownerId) throws ObjectNotFoundException, IllegalAccessException { + long resolvedOwnerId = ownerId == null || ownerId <= 0 ? getCurrentUserIdentityId() : ownerId; + long userIdentityId = getCurrentUserIdentityId(); + DocumentsSize size = documentFileService.getDocumentsSizeStat(resolvedOwnerId, userIdentityId); + // getDocumentsSizeStat only reads a previously computed analytics "documentsSize" + // stat (the size gadget POSTs addDocumentsSizeStat on demand). When no stat has + // been written yet, or it is stale (not from today), the read yields owner_id 0 / + // size 0, which is misleading. Trigger the on-demand computation so the tool + // returns the real current size for the correct owner. + if (size == null || size.getOwnerId() <= 0 || size.getToSize() <= 0 || !size.isTodaySize()) { + size = documentFileService.addDocumentsSizeStat(resolvedOwnerId, userIdentityId); + } + return new DocumentsSizeModel(resolvedOwnerId, size == null ? 0L : size.getToSize()); + } + + // --------------------------------------------------------------------------- + // Content management (writes, require approval) + // --------------------------------------------------------------------------- + + public DocumentModel createFolder(String parentFolderId, + String name) throws IllegalAccessException, ObjectNotFoundException { + checkFolderIdParameter(parentFolderId); + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("The 'name' parameter is mandatory to create a folder. Ask the user for a folder name."); + } + // Validate the parent folder exists and is readable by the user, so a bad id + // fails with a clean ObjectNotFoundException / IllegalAccessException before + // reaching the JCR storage. + getNode(parentFolderId); + try { + // parentFolderId already identifies the parent JCR node uniquely; the + // storage 'folderPath' argument is a RELATIVE sub-path resolved *from* that + // node (node.getNode(folderPath)), so it must stay empty. Passing the + // absolute node path made root-folder creation fail with + // "Folder with path : /Users/.../Private isn't found". + AbstractNode folder = documentFileService.createFolder(getOwnerId(parentFolderId), + parentFolderId, + null, + name, + getCurrentUserIdentityId()); + return toDocumentModel(folder); + } catch (ObjectAlreadyExistsException e) { + throw new IllegalStateException("A folder named '%s' already exists under this parent folder. Tell the user to pick a different name." + .formatted(name)); + } + } + + /** + * Creates a text-based document (markdown, HTML, plain text, CSV, JSON…) from + * a chat-authored content string. This is the chat-native way to + * turn generated text into a real file in the Documents app. The + * name must include a file extension: the extension drives the + * stored content type via the platform MimeTypeResolver, so use a registered + * text extension (.txt, .html, .csv, .json, .xml…) so the file has the correct + * type and is readable via get_document_content_by_id. + * + * @param parentFolderId the folder where the document is created (get it from + * get_root_folder_for_user / get_root_folder_by_space / + * get_documents_by_folder_id) + * @param name the document file name, including its extension (e.g. + * 'notes.md', 'report.txt', 'page.html'). The extension determines the + * file's content type (via the platform MimeTypeResolver); the name is + * used as-is. + * @param content the text / markdown / HTML body + * @return the created document + */ + public DocumentModel createDocument(String parentFolderId, + String name, + String content) throws IllegalAccessException, ObjectNotFoundException { + checkFolderIdParameter(parentFolderId); + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("The 'name' parameter is mandatory to create a document (e.g. 'meeting-notes.md'). Ask the user for a document name."); + } + String extension = StringUtils.substringAfterLast(StringUtils.lowerCase(StringUtils.trimToEmpty(name)), "."); + if (StringUtils.isBlank(extension)) { + throw new IllegalArgumentException("The document name must include a file extension (e.g. 'notes.md', 'report.txt', 'page.html') — the extension determines the file's content type."); + } + if (UNSUPPORTED_BINARY_EXTENSIONS.contains(extension)) { + throw new IllegalArgumentException(("create_document writes text content as the file body, so it cannot produce a valid" + + " '%s' file — office and PDF formats are structured packages, not text. To create a NEW EMPTY office document" + + " (Word/Excel/PowerPoint/ODF) use create_document_from_template. For text use .txt/.md/.html, or use" + + " upload_document with the real file bytes (base64, url, or a file attached in the conversation).").formatted(extension)); + } + if (StringUtils.isBlank(content)) { + throw new IllegalArgumentException("content is required to create a document (the text to write into the file)."); + } + return importContent(parentFolderId, name, content.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Creates a NEW EMPTY office document (Word/Excel/PowerPoint, or ODF) from the + * platform's blank template, by delegating to the ecms {@code DocumentService}. + * Use this when the user wants a blank Word/Excel/PowerPoint document to fill in + * (e.g. via the online editor): create_document cannot produce office files + * (they are structured packages, not text) and upload_document needs the real + * file bytes. The template is created synchronously; the file extension is added + * automatically from documentType. + * + * @param parentFolderId the folder where the document is created (get it from + * get_root_folder_for_user / get_root_folder_by_space / + * get_documents_by_folder_id) + * @param name the document name, WITHOUT extension (e.g. 'Q3 report'); the + * extension is appended automatically from documentType + * @param documentType the office document type: one of docx, xlsx, pptx. + * Availability depends on the installed editor add-on (onlyoffice). + * @return the created document + */ + public DocumentModel createDocumentFromTemplate(String parentFolderId, + String name, + String documentType) throws IllegalAccessException, ObjectNotFoundException { + checkFolderIdParameter(parentFolderId); + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("The 'name' parameter is mandatory to create a document (e.g. 'Q3 report'). Ask the user for a document name."); + } + if (StringUtils.isBlank(documentType)) { + throw new IllegalArgumentException("The 'document_type' parameter is mandatory: the office document type to create. Supported types: " + + SUPPORTED_TEMPLATE_TYPES + "."); + } + String type = StringUtils.lowerCase(StringUtils.trimToEmpty(documentType)); + if (type.startsWith(".")) { + type = type.substring(1); + } + if (!SUPPORTED_TEMPLATE_TYPES.contains(type)) { + throw new IllegalArgumentException("Unsupported document_type '%s'. Supported office document types are: %s.".formatted(documentType, + SUPPORTED_TEMPLATE_TYPES)); + } + // Validate the parent folder exists and is readable by the user, so a bad id + // fails with a clean ObjectNotFoundException / IllegalAccessException before + // reaching the JCR storage. + getNode(parentFolderId); + // ecms names the file after the title and does NOT append the extension, so + // build a title that ends with '.' (stripping a duplicate extension the + // user may have typed to avoid 'report.docx.docx'). + String baseName = StringUtils.trimToEmpty(name); + String suffix = "." + type; + if (StringUtils.endsWithIgnoreCase(baseName, suffix)) { + baseName = baseName.substring(0, baseName.length() - suffix.length()); + } + String title = baseName + suffix; + try { + // parentFolderId already identifies the parent JCR node uniquely; the storage + // 'folderPath' is a RELATIVE sub-path resolved from that node, so keep it null + // (same rule as createFolder). + AbstractNode document = documentFileService.createDocumentFromTemplate(getOwnerId(parentFolderId), + parentFolderId, + null, + title, + type, + getCurrentUserIdentityId()); + return toDocumentModel(document); + } catch (ObjectAlreadyExistsException e) { + throw new IllegalStateException("A document named '%s' already exists under this parent folder. Tell the user to pick a different name." + .formatted(title)); + } + } + + /** + * Uploads a real / binary document (PDF, image, office file…) from exactly one + * of three sources: a file/image the user attached in the chat conversation + * (resolved server-side as the current user via + * attachment_object_type / attachment_object_id), a + * base64 payload, or an http(s) URL (fetched server-side behind an SSRF + * guard). + * + * @param parentFolderId the destination folder id (get it from + * get_root_folder_for_user / get_root_folder_by_space / + * get_documents_by_folder_id) + * @param name the document file name including its extension (e.g. + * 'report.pdf'); when uploading from a URL or a chat attachment it may + * be left blank to reuse the source file name + * @param base64 the file bytes encoded in base64 (mutually exclusive with url / + * the chat attachment) + * @param url an http(s) URL to download the file from (mutually exclusive with + * base64 / the chat attachment) + * @param attachmentObjectType the object type of the file the user attached in + * chat; set automatically by the client — do not fill it from the + * model + * @param attachmentObjectId the object id of the file the user attached in + * chat; set automatically by the client — do not fill it from the + * model + * @return the created document + */ + public DocumentModel uploadDocument(String parentFolderId, + String name, + String base64, + String url, + String attachmentObjectType, + String attachmentObjectId) throws IllegalAccessException, ObjectNotFoundException { + checkFolderIdParameter(parentFolderId); + boolean hasAttachment = StringUtils.isNotBlank(attachmentObjectId); + boolean hasBase64 = StringUtils.isNotBlank(base64); + boolean hasUrl = StringUtils.isNotBlank(url); + int sources = (hasAttachment ? 1 : 0) + (hasBase64 ? 1 : 0) + (hasUrl ? 1 : 0); + if (sources == 0) { + throw new IllegalArgumentException(""" + Provide a file to upload from exactly one source: + a file/image the user attached in the conversation, a 'base64' payload, or an http(s) 'url'. + """); + } + if (sources > 1) { + throw new IllegalArgumentException("Provide the file to upload from only one source: the chat attachment, 'base64' or 'url' — not several."); + } + byte[] bytes; + String fileName = name; + try { + if (hasAttachment) { + // Reuse UploadToolUtils.resolveImage's attachment branch: it resolves the + // referenced file to its raw bytes as the current user (ACL enforced) and, + // unlike its base64/url branches, does NOT constrain to image mime types — + // so any attached file works. Pass null url/base64 to stay on that branch. + UploadToolUtils.FetchedContent fetched = UploadToolUtils.resolveImage(socialAttachmentService, + fileService, + getCurrentUserAclIdentity(), + null, + null, + attachmentObjectType, + attachmentObjectId, + UploadToolUtils.DEFAULT_MAX_BYTES); + bytes = fetched.bytes(); + if (StringUtils.isBlank(fileName)) { + fileName = fetched.fileName(); + } + } else if (hasBase64) { + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("The 'name' parameter is mandatory when uploading from base64; it becomes the document file name (e.g. 'report.pdf')."); + } + bytes = UploadToolUtils.decodeBase64(base64); + } else { + UploadToolUtils.FetchedContent fetched = UploadToolUtils.fetchUrl(url, UploadToolUtils.DEFAULT_MAX_BYTES, name); + bytes = fetched.bytes(); + if (StringUtils.isBlank(fileName)) { + fileName = fetched.fileName(); + } + } + } catch (IllegalArgumentException | IllegalStateException e) { + // Already an LLM-directed message (bad source, HTTP error, SSRF block, + // size cap, invalid base64…): the MCP wrapper passes these through verbatim. + throw e; + } catch (RuntimeException e) { + // Any other unexpected failure of the resolve/fetch path: rethrow as an + // LLM-directed type so it isn't swallowed by the generic wrapper message. + throw new IllegalStateException("Could not read the file to upload from the given source: " + e.getMessage()); + } + return importContent(parentFolderId, fileName, bytes); + } + + public void renameDocument(String documentId, String newName) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + if (StringUtils.isBlank(newName)) { + throw new IllegalArgumentException("The 'newName' parameter is mandatory to rename a document."); + } + try { + documentFileService.renameDocument(getOwnerId(documentId), documentId, newName, getCurrentUserIdentityId()); + } catch (ObjectAlreadyExistsException e) { + throw new IllegalStateException("A document named '%s' already exists in the same folder. Tell the user to pick a different name." + .formatted(newName)); + } + } + + public void moveDocument(String documentId, + String destinationFolderId, + String conflictAction) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + if (StringUtils.isBlank(destinationFolderId)) { + throw new IllegalArgumentException("The 'destinationFolderId' parameter is mandatory. Call get_document_by_id or get_documents_by_folder_id to find the target folder id."); + } + AbstractNode destination = getNode(destinationFolderId); + try { + documentFileService.moveDocument(getOwnerId(documentId), + documentId, + destination.getPath(), + getCurrentUserIdentityId(), + StringUtils.isBlank(conflictAction) ? "keepBoth" : conflictAction); + } catch (ObjectAlreadyExistsException e) { + throw new IllegalStateException("A document with the same name already exists in the destination folder. Retry with conflict_action set to 'keepBoth' (move as a renamed copy) or 'createNewVersion' (replace the existing file's content, keeping its history)."); + } + } + + public DocumentModel copyDocument(String documentId, + String destinationFolderId) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + if (StringUtils.isBlank(destinationFolderId)) { + throw new IllegalArgumentException("The 'destinationFolderId' parameter is mandatory. Call get_document_by_id or get_documents_by_folder_id to find the target folder id."); + } + // The storage copyDocument returns the DESTINATION folder node, not the copy, + // so snapshot the destination's file ids first and resolve the newly added + // file afterwards to return the actual copied document. + Set beforeFileIds = currentChildFileIds(destinationFolderId); + AbstractNode copyReturn = documentFileService.copyDocument(documentId, destinationFolderId, getCurrentUserIdentityId()); + DocumentModel copied = resolveAddedChildFile(destinationFolderId, beforeFileIds); + return copied != null ? copied : toDocumentModel(copyReturn); + } + + public DocumentModel duplicateDocument(String documentId, + String prefix) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + // The storage duplicateDocument returns the PARENT folder node, not the + // duplicate, so snapshot the parent's file ids first and resolve the newly + // added (suffixed/prefixed) file afterwards to return the actual duplicate. + AbstractNode source = getNode(documentId); + String parentFolderId = source.getParentFolderId(); + Set beforeFileIds = StringUtils.isBlank(parentFolderId) ? Set.of() : currentChildFileIds(parentFolderId); + AbstractNode duplicateReturn = documentFileService.duplicateDocument(getOwnerId(documentId), + documentId, + prefix, + getCurrentUserIdentityId()); + DocumentModel duplicate = StringUtils.isBlank(parentFolderId) ? null + : resolveAddedChildFile(parentFolderId, beforeFileIds); + return duplicate != null ? duplicate : toDocumentModel(duplicateReturn); + } + + public void deleteDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { + AbstractNode document = checkCanAccessDocument(documentId); + // delay = 0: move to trash immediately. favorite = true: also drop the file + // from the user's favorites (storage cleanup uses object type "file"), so a + // trashed favorite doesn't linger in the favorites view. + documentFileService.deleteDocument(document.getPath(), documentId, true, 0, getCurrentUserIdentityId()); + } + + /** + * Restores a document that was deleted with {@code delete_document}: since that + * tool trashes immediately (delay 0), the delayed-delete cancel path is a no-op, + * so the node is resolved from trash (it keeps its id/uuid after the move) and + * restored to its original location. + */ + public void undoDeleteDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + // Resolve the trashed node through the SYSTEM-session overload: all trash + // access in storage uses a system session, and a regular user session cannot + // read a node once it has been moved to trash. Using the user-session overload + // here would return null even though the node still exists, yielding a + // misleading "permanently deleted" error. + AbstractNode trashedNode = documentFileService.getDocumentById(documentId); + if (trashedNode == null) { + throw new IllegalStateException(("Document %s can no longer be found (it may have been permanently deleted from the trash), " + + "so it cannot be restored.").formatted(documentId)); + } + try { + documentFileService.restoreDocumentFromTrash(trashedNode.getPath()); + } catch (RepositoryException e) { + throw new IllegalStateException(("Could not restore document %s from trash: %s. It may not be in the trash " + + "(only documents deleted via delete_document can be restored this way).").formatted(documentId, e.getMessage())); + } + } + + public DocumentVersionModel restoreDocumentVersion(String versionId) { + if (StringUtils.isBlank(versionId)) { + throw new IllegalArgumentException("The 'versionId' parameter is mandatory. Call list_document_versions first to get a version_id."); + } + FileVersion version = documentFileService.restoreVersion(versionId, getCurrentUserName()); + return toVersionModel(version); + } + + public DocumentVersionModel updateVersionSummary(String documentId, String versionId, String summary) { + checkDocumentIdParameter(documentId); + if (StringUtils.isBlank(versionId)) { + throw new IllegalArgumentException("The 'versionId' parameter is mandatory. Call list_document_versions first to get a version_id."); + } + FileVersion stamped = documentFileService.updateVersionSummary(documentId, versionId, summary, getCurrentUserName()); + // The storage updateVersionSummary returns a near-empty FileVersion (only id + + // summary), so re-read the full version to return number/author/dates/size/etc. + FileVersion full = documentFileService.getFileVersions(documentId, getCurrentUserName()) + .stream() + .filter(v -> StringUtils.equals(v.getId(), versionId)) + .findFirst() + .orElse(stamped); + return toVersionModel(full); + } + + @SneakyThrows + public void setDocumentVisibility(String documentId, Boolean hidden) throws IllegalAccessException { + checkDocumentIdParameter(documentId); + if (hidden == null) { + throw new IllegalArgumentException("The 'hidden' parameter is mandatory (true to hide the document, false to make it visible)."); + } + documentFileService.setDocumentVisibility(getOwnerId(documentId), documentId, hidden, getCurrentUserIdentityId()); + } + + // --------------------------------------------------------------------------- + // Versioning, sharing, public links and favorites (writes, require approval) + // --------------------------------------------------------------------------- + + /** + * Uploads updated content onto an EXISTING file, creating a NEW version of it + * (the previous content stays retrievable via list_document_versions / + * restore_document_version). The new bytes come from exactly one source: a + * chat-authored text content string, a base64 + * payload, an http(s) url, or a file the user attached in the + * conversation. The file's name and type are unchanged; only its content and + * version history are updated. + * + * @param documentId the id of the existing file to add a version to (get it + * from get_document_by_id / get_documents_by_folder_id / + * search_documents) + * @param content plain text / markdown / HTML body to write as the new version + * (use for text files) + * @param base64 the new file bytes encoded in base64 + * @param url an http(s) URL to download the new content from (fetched + * server-side behind an SSRF guard) + * @param attachmentObjectType the object type of the file the user attached in + * chat; set automatically by the client — do not fill it from the model + * @param attachmentObjectId the object id of the file the user attached in + * chat; set automatically by the client — do not fill it from the model + * @param versionSummary an optional change note stored on the new version + * @return the updated document (with its refreshed size / modification info) + */ + public DocumentModel addDocumentVersion(String documentId, + String content, + String base64, + String url, + String attachmentObjectType, + String attachmentObjectId, + String versionSummary) throws IllegalAccessException, ObjectNotFoundException { + AbstractNode node = checkCanAccessDocument(documentId); + if (!(node instanceof FileNode)) { + throw new IllegalArgumentException("Only a file can have versions; the id %s points to a folder. Provide a file document_id." + .formatted(documentId)); + } + if (!documentFileService.hasEditPermissionOnDocument(documentId, getCurrentUserIdentityId())) { + throw new IllegalAccessException("You are not allowed to edit the document %s, so you cannot add a new version to it." + .formatted(documentId)); + } + byte[] bytes = resolveContentBytes(content, base64, url, attachmentObjectType, attachmentObjectId); + documentFileService.createNewVersion(documentId, getCurrentUserName(), new ByteArrayInputStream(bytes)); + if (StringUtils.isNotBlank(versionSummary)) { + applyVersionSummary(documentId, versionSummary); + } + return getDocumentById(documentId); + } + + /** + * Creates (or refreshes) an anonymous public share link for a document so it + * can be downloaded without signing in, optionally protected by a password + * and/or an expiration date. Requires edit permission on the document. + * + * @param documentId the id of the file to share publicly (get it from the read + * tools) + * @param password an optional password required to open the link + * @param expirationDate an optional expiration date, as epoch milliseconds; + * after it the link stops working. Leave blank for a link that never + * expires. + * @return the shareable link, together with its password and expiration + */ + public DocumentPublicLinkModel createPublicLink(String documentId, + String password, + Long expirationDate) throws IllegalAccessException, + ObjectNotFoundException { + checkCanAccessDocument(documentId); + long userIdentityId = getCurrentUserIdentityId(); + if (!documentFileService.hasEditPermissionOnDocument(documentId, userIdentityId)) { + throw new IllegalAccessException("You are not allowed to edit the document %s, so you cannot create a public link for it." + .formatted(documentId)); + } + if (StringUtils.isNotBlank(password)) { + // Apply the same password policy as DocumentFileRest (min 9 chars, etc.), so + // a link the LLM creates can actually be opened with the given password. + String error = PASSWORD_VALIDATOR.validate(getCurrentUserLocale(), password); + if (StringUtils.isNotBlank(error)) { + throw new IllegalArgumentException("The public link password is invalid: " + error); + } + } + long expiration = expirationDate == null ? 0L : expirationDate.longValue(); + PublicDocumentAccess access = publicDocumentAccessService.createPublicDocumentAccess(userIdentityId, + documentId, + StringUtils.isBlank(password) ? null + : password, + expiration, + false); + if (access == null) { + throw new IllegalStateException("The public link could not be created for document %s. Verify the document id and retry." + .formatted(documentId)); + } + Date expirationValue = access.getExpirationDate(); + return new DocumentPublicLinkModel(documentId, + PUBLIC_LINK_URL_FORMAT.formatted(documentId), + access.getDecodedPassword(), + expirationValue == null ? null : formatDate(expirationValue)); + } + + /** + * Creates a shortcut (symlink) to a document inside another folder, so the same + * file appears in a second location without being copied. + * + * @param documentId the id of the file or folder to create a shortcut to + * @param destinationFolderId the id of the folder where the shortcut is placed + * (get it from get_root_folder_for_user / get_root_folder_by_space / + * get_documents_by_folder_id) + * @param conflictAction behaviour when a node with the same name already exists + * in the destination; only 'keepBoth' is honoured, defaults to + * 'keepBoth' + */ + public void createDocumentShortcut(String documentId, + String destinationFolderId, + String conflictAction) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + if (StringUtils.isBlank(destinationFolderId)) { + throw new IllegalArgumentException("The 'destinationFolderId' parameter is mandatory. Call get_documents_by_folder_id or get_root_folder_for_user to find the target folder id."); + } + // Validate the source and the destination folder exist and are readable by + // the user before touching the JCR storage. + checkCanAccessDocument(documentId); + AbstractNode destination = getNode(destinationFolderId); + try { + documentFileService.createShortcut(documentId, + destination.getPath(), + getCurrentUserName(), + StringUtils.isBlank(conflictAction) ? "keepBoth" : conflictAction); + } catch (ObjectAlreadyExistsException e) { + throw new IllegalStateException("A node with the same name already exists in the destination folder. Retry with conflict_action set to 'keepBoth' to create the shortcut under a renamed entry."); + } + } + + /** + * Shares a document with another user or with a space: the recipient is GRANTED + * read access to the file and gets it in their "Shared" folder. Provide exactly + * one of username or spaceId. Requires edit permission + * on the document. + * + * @param documentId the id of the file to share (get it from the read tools) + * @param username the username to share the document with (mutually exclusive + * with space_id) + * @param spaceId the id of the space to share the document with (mutually + * exclusive with username) + * @param notify whether to notify the recipient; defaults to true + */ + public void shareDocument(String documentId, + String username, + Long spaceId, + Boolean notify) throws IllegalAccessException, ObjectNotFoundException { + AbstractNode node = checkCanAccessDocument(documentId); + boolean hasUser = StringUtils.isNotBlank(username); + boolean hasSpace = spaceId != null && spaceId > 0; + if (hasUser == hasSpace) { + throw new IllegalArgumentException("Provide exactly one recipient: either 'username' or 'space_id', not both and not none."); + } + if (!documentFileService.hasEditPermissionOnDocument(documentId, getCurrentUserIdentityId())) { + throw new IllegalAccessException("You are not allowed to edit the document %s, so you cannot share it.".formatted(documentId)); + } + Identity destIdentity; + if (hasUser) { + destIdentity = identityManager.getOrCreateUserIdentity(username); + if (destIdentity == null) { + throw new ObjectNotFoundException("No user found with username '%s'. Use search_users to find the exact username." + .formatted(username)); + } + } else { + Space space = spaceService.getSpaceById(String.valueOf(spaceId)); + if (space == null) { + throw new ObjectNotFoundException("Space with id %s not found. Use get_my_spaces to check accessible spaces." + .formatted(spaceId)); + } + destIdentity = identityManager.getOrCreateSpaceIdentity(space.getPrettyName()); + } + long destId = destIdentity.getIdentityId(); + // Route through updatePermissions like the REST does: shareDocument alone only + // copies the recipient's EXISTING permissions onto the symlink, so a recipient + // without prior access still can't open the file. updatePermissions GRANTS the + // recipient read access on the node (permissions + toShare) and then shares + // (creates the symlink). The current collaborators are preserved so they are + // not unshared by the storage's replace-then-unshare logic. + List permissions = preserveExistingCollaborators(node.getAcl()); + // Only grant the recipient "read" if it is not already a collaborator with an + // equal-or-greater permission. updatePermissions writes the map in list order, + // so appending (recipient,"read") after preserveExistingCollaborators already + // emitted (recipient,"edit") would silently DOWNGRADE the recipient to read + // (this also happens when a space document is shared back to its own space). + if (!alreadyGrantsAccess(permissions, destIdentity)) { + permissions.add(new PermissionEntry(destIdentity, "read", PermissionRole.ALL.name())); + } + Map toShare = new HashMap<>(); + toShare.put(destId, "read"); + Map toNotify = new HashMap<>(); + if (notify == null || notify.booleanValue()) { + toNotify.put(destId, "read"); + } + NodePermission nodePermission = node.getAcl() == null ? new NodePermission(true, false, false, false, permissions, toShare, toNotify, null) + : new NodePermission(node.getAcl().isCanAccess(), + node.getAcl().isCanEdit(), + node.getAcl().isCanDelete(), + node.getAcl().isPublic(), + permissions, + toShare, + toNotify, + null); + documentFileService.updatePermissions(documentId, nodePermission, getCurrentUserIdentityId()); + } + + /** + * Rebuilds the document's current collaborator list (as read/edit + * {@link PermissionEntry}) from its resolved ACL, so a share can be added + * through {@code updatePermissions} without the storage's replace-then-unshare + * logic dropping the users/spaces that already had access. Mirrors the + * read/edit derivation the REST layer uses ({@code EntityBuilder}). + */ + private List preserveExistingCollaborators(NodePermission acl) { + List result = new java.util.ArrayList<>(); + if (acl == null || acl.getPermissions() == null) { + return result; + } + // Collapse the per-permission ACL entries (each identity appears once per raw + // JCR permission) into one entry per identity+role, marking it "edit" when any + // of its raw permissions is a write permission, else "read". + Map identityByKey = new java.util.LinkedHashMap<>(); + Map editByKey = new HashMap<>(); + for (PermissionEntry entry : acl.getPermissions()) { + Identity identity = entry.getIdentity(); + if (identity == null) { + continue; + } + String key = identity.getProviderId() + "|" + identity.getRemoteId() + "|" + entry.getRole(); + identityByKey.putIfAbsent(key, identity); + editByKey.merge(key, isEditAclPermission(entry.getPermission()), Boolean::logicalOr); + } + identityByKey.forEach((key, identity) -> { + String role = key.substring(key.lastIndexOf('|') + 1); + result.add(new PermissionEntry(identity, Boolean.TRUE.equals(editByKey.get(key)) ? "edit" : "read", role)); + }); + return result; + } + + private static boolean isEditAclPermission(String permission) { + return permission != null + && (permission.contains("add_node") || permission.contains("set_property") || permission.contains("remove")); + } + + /** + * Whether the reconstructed collaborator list already contains the given + * identity with any grant (read or edit). When true, the share must NOT append + * a (recipient,"read") entry, as that would overwrite an existing higher grant + * (edit) when the storage writes the permissions map in list order. + */ + private static boolean alreadyGrantsAccess(List permissions, Identity identity) { + if (permissions == null || identity == null) { + return false; + } + return permissions.stream().anyMatch(entry -> { + Identity existing = entry.getIdentity(); + return existing != null && StringUtils.equals(existing.getProviderId(), identity.getProviderId()) + && StringUtils.equals(existing.getRemoteId(), identity.getRemoteId()); + }); + } + + /** + * Adds a file to the current user's favorites. Only files can be favorited; + * folders have no favorites view in the Documents app. + * + * @param documentId the id of the file to mark as favorite + */ + public void favoriteDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { + Favorite favorite = toFavorite(documentId); + try { + favoriteService.createFavorite(favorite); + } catch (ObjectAlreadyExistsException e) { + throw new IllegalStateException("The document %s is already in the user's favorites.".formatted(documentId)); + } + } + + /** + * Removes a file from the current user's favorites. Only files can be + * favorited; folders have no favorites view in the Documents app. + * + * @param documentId the id of the file to remove from favorites + */ + public void unfavoriteDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { + Favorite favorite = toFavorite(documentId); + try { + favoriteService.deleteFavorite(favorite); + } catch (ObjectNotFoundException e) { + throw new IllegalStateException("The document %s is not in the user's favorites, so it cannot be removed from them." + .formatted(documentId)); + } + } + + private Favorite toFavorite(String documentId) throws IllegalAccessException, ObjectNotFoundException { + AbstractNode node = checkCanAccessDocument(documentId); + if (!(node instanceof FileNode)) { + // Folders have no favorite surface in the Documents app (the favorite + // button and drawer only exist for files, object type "file"). Writing a + // "folder" favorite would silently never appear anywhere, so reject it. + throw new IllegalArgumentException("Only files can be favorited; folders have no favorites view."); + } + return new Favorite(FAVORITE_TYPE_FILE, documentId, null, getCurrentUserIdentityId()); + } + + /** + * Resolves the raw bytes for a new file version from exactly one of the + * supported sources (chat text, base64, url or a chat attachment), reusing + * {@code upload_document}'s attachment/url resolution so the same ACL and SSRF + * guards apply. + */ + private byte[] resolveContentBytes(String content, + String base64, + String url, + String attachmentObjectType, + String attachmentObjectId) throws IllegalAccessException, ObjectNotFoundException { + boolean hasContent = StringUtils.isNotBlank(content); + boolean hasBase64 = StringUtils.isNotBlank(base64); + boolean hasUrl = StringUtils.isNotBlank(url); + boolean hasAttachment = StringUtils.isNotBlank(attachmentObjectId); + int sources = (hasContent ? 1 : 0) + (hasBase64 ? 1 : 0) + (hasUrl ? 1 : 0) + (hasAttachment ? 1 : 0); + if (sources == 0) { + throw new IllegalArgumentException(""" + Provide the new version content from exactly one source: + a text 'content' string, a 'base64' payload, an http(s) 'url', or a file the user attached in the conversation. + """); + } + if (sources > 1) { + throw new IllegalArgumentException("Provide the new version content from only one source: 'content', 'base64', 'url' or the chat attachment — not several."); + } + try { + if (hasContent) { + return content.getBytes(StandardCharsets.UTF_8); + } else if (hasBase64) { + return UploadToolUtils.decodeBase64(base64); + } else if (hasAttachment) { + return UploadToolUtils.resolveImage(socialAttachmentService, + fileService, + getCurrentUserAclIdentity(), + null, + null, + attachmentObjectType, + attachmentObjectId, + UploadToolUtils.DEFAULT_MAX_BYTES) + .bytes(); + } else { + return UploadToolUtils.fetchUrl(url, UploadToolUtils.DEFAULT_MAX_BYTES, null).bytes(); + } + } catch (IllegalArgumentException | IllegalStateException e) { + throw e; + } catch (RuntimeException e) { + throw new IllegalStateException("Could not read the new version content from the given source: " + e.getMessage()); + } + } + + /** + * Stamps the summary onto the version that {@code createNewVersion} just made: + * that storage call returns an empty {@link FileVersion}, so the current + * version is looked up and its summary updated. + */ + private void applyVersionSummary(String documentId, String versionSummary) { + List versions = documentFileService.getFileVersions(documentId, getCurrentUserName()); + versions.stream() + .filter(FileVersion::isCurrent) + .findFirst() + .ifPresent(current -> documentFileService.updateVersionSummary(documentId, + current.getId(), + versionSummary, + getCurrentUserName())); + } + + private UserModel toUserModel(long identityId) { + Identity identity = identityManager.getIdentity(identityId); + if (identity == null || !identity.isUser()) { + return null; + } else { + return toUserModel(identity.getRemoteId()); + } + } + + private UserModel toUserModel(String username) { + if (StringUtils.isBlank(username)) { + return null; + } + return UserToolUtils.toUserModel(identityManager, + profilePropertyService, + userAcl, + translationService, + portalConfigService, + username, + getCurrentUserName(), + getCurrentUserLocale(), + true); + } + + @SneakyThrows + private String getUrl(AbstractNode abstractNode) { + if (abstractNode instanceof FileNode) { + return FILE_URL_FORMAT.formatted(abstractNode.getId()); + } else { + return FOLDER_URL_FORMAT.formatted(abstractNode.getId()); + } + } + + private AbstractNode checkCanAccessDocument(String documentId) throws IllegalAccessException, ObjectNotFoundException { + checkDocumentIdParameter(documentId); + return documentFileService.getDocumentById(documentId, getCurrentUserName()); + } + + private AbstractNode getNode(String documentId) throws IllegalAccessException, ObjectNotFoundException { + return documentFileService.getDocumentById(documentId, getCurrentUserName()); + } + + private long getOwnerId(String documentId) { + return documentFileService.getRootFolderOwnerId(documentId); + } + + private DocumentTreeItemModel toTreeItemModel(FullTreeItem item) { + List children = item.getChildren() == null ? null + : item.getChildren() + .stream() + .map(this::toTreeItemModel) + .toList(); + return new DocumentTreeItemModel(item.getId(), item.getName(), item.getPath(), children); + } + + private DocumentVersionModel toVersionModel(FileVersion version) { + return new DocumentVersionModel(version.getId(), + version.getVersionNumber(), + version.getTitle(), + version.getSummary(), + version.getAuthor(), + version.getAuthorFullName(), + formatDate(version.getCreatedDate()), + version.isCurrent(), + version.getSize()); + } + + private void checkFolderIdParameter(String folderId) { + if (StringUtils.isBlank(folderId)) { + throw new IllegalArgumentException(""" + The 'folderId' parameter is mandatory. + Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to obtain a folder id. + """); + } + } + + private void checkDocumentIdParameter(String documentId) { + if (StringUtils.isBlank(documentId)) { + throw new IllegalArgumentException(""" + The 'documentId' parameter is mandatory. + Ensure to retrieve the id from the expression '/document:ID'. + Example with documentId = be0688cd7f00010134384ab7e1b15a48, The expression would be /document:be0688cd7f00010134384ab7e1b15a48 + """); + } + } + + private DocumentModel toDocumentModel(AbstractNode document) { + if (document instanceof FileNode fileNode) { + return toDocumentFileModel(fileNode); + } else { + return toDocumentFolderModel(document); + } + } + + private DocumentFileModel toDocumentFileModel(FileNode fileNode) { + String documentId = fileNode.getId(); + return new DocumentFileModel(documentId, + fileNode.getName(), + fileNode.getPath(), + getUrl(fileNode), + fileNode.getParentFolderId(), + formatDate(fileNode.getCreatedDate()), + toUserModel(fileNode.getCreatorUserName()), + fileNode.getSize(), + fileNode.getMimeType(), + getAudioTranscription(documentId), + fileNode.getDescription(), + formatDate(fileNode.getModifiedDate()), + toUserModel(fileNode.getModifierId())); + } + + private String getAudioTranscription(String documentId) { + String audioTranscription; + try { + audioTranscription = documentFileService.getAudioTranscription(documentId, getCurrentUserIdentityId()); + } catch (IllegalAccessException e) { + audioTranscription = null; + } + return audioTranscription; + } + + private DocumentFolderModel toDocumentFolderModel(AbstractNode folder) { + return new DocumentFolderModel(folder.getId(), + folder.getName(), + folder.getPath(), + getUrl(folder), + folder.getParentFolderId(), + formatDate(folder.getCreatedDate()), + toUserModel(folder.getCreatorUserName())); + } + + // FIXME should be removed once Notes refactored and cleaned + private long fixNoteContentId(String contentType, long contentId) throws WikiException, IllegalAccessException { + if (StringUtils.contains(contentType, "note")) { + Page note = ExoContainerContext.getService(NoteService.class) + .getNoteByIdAndLang(contentId, + getCurrentUserAclIdentity(), + null, + getCurrentUserLocale().toLanguageTag()); + if (note != null && note.getLatestVersionId() != null) { + contentId = Long.parseLong(note.getLatestVersionId()); + } + } + return contentId; + } + + // FIXME should be removed once Notes refactored and cleaned + private String fixNoteContentType(String contentType) { + if (StringUtils.contains(contentType, "note")) { + return "WIKI_PAGE_VERSIONS"; + } else { + return contentType; + } + } + + // --------------------------------------------------------------------------- + // Content creation / upload helpers + // --------------------------------------------------------------------------- + + private static final int IMPORT_POLL_MAX_ATTEMPTS = 20; + + private static final long IMPORT_POLL_INTERVAL_MS = 500L; + + private static final int IMPORT_POLL_PAGE_SIZE = 200; + + /** + * Stages the given bytes as a single file and imports it into the folder, + * then resolves and returns the created document. {@code importFiles} expects + * a zip and unzips it, so the file is wrapped in a one-entry zip whose entry + * name becomes the created document title. + */ + private DocumentModel importContent(String parentFolderId, + String fileName, + byte[] bytes) throws IllegalAccessException, ObjectNotFoundException { + if (bytes == null || bytes.length == 0) { + throw new IllegalArgumentException("The document content is empty; there is nothing to create."); + } + // Validate the parent folder exists and is readable before staging anything, + // so a bad id fails with a clean ObjectNotFoundException / IllegalAccessException. + getNode(parentFolderId); + String entryName = sanitizeFileName(fileName); + String ownerId = String.valueOf(getOwnerId(parentFolderId)); + long userIdentityId = getCurrentUserIdentityId(); + // Snapshot the destination file ids before the import so the created node can + // be identified even when a same-name file already exists and the import + // creates a renamed ("duplicate") copy with a different, suffixed name. + Set beforeFileIds = currentChildFileIds(parentFolderId); + String uploadId = null; + try { + uploadId = UploadToolUtils.materialize(uploadService, + zipSingleEntry(entryName, bytes), + entryName + ".zip", + "application/zip"); + // folderPath stays null: parentFolderId already identifies the JCR node + // (same rule as createFolder). conflict "duplicate": on a name clash the + // import creates a renamed (keep-both) copy. NOT "rename" — the async import + // thread only understands "updateAll"/"duplicate", so "rename" would fall + // through to ignoredFiles and silently create nothing. + documentFileService.importFiles(ownerId, + parentFolderId, + null, + uploadId, + "duplicate", + getCurrentUserAclIdentity(), + userIdentityId); + // On success the asynchronous import owns the upload resource and releases + // it once the file is created, so it must not be released here. + uploadId = null; + } catch (IllegalAccessException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Could not create the document '%s': %s".formatted(entryName, e.getMessage())); + } finally { + if (uploadId != null) { + UploadToolUtils.release(uploadService, uploadId); + } + } + return findImportedDocument(parentFolderId, entryName, beforeFileIds); + } + + /** + * The import runs on a background thread, so the created file is polled for in + * the destination folder until it appears (or a short timeout elapses). The + * newly created node is identified as the file whose id was not present before + * the import, so it resolves the just-created (possibly suffixed) file rather + * than a pre-existing same-name document. + */ + private DocumentModel findImportedDocument(String parentFolderId, + String fileName, + Set beforeFileIds) throws ObjectNotFoundException, + IllegalAccessException { + for (int attempt = 0; attempt < IMPORT_POLL_MAX_ATTEMPTS; attempt++) { + DocumentModel match = resolveAddedChildFile(parentFolderId, beforeFileIds); + if (match != null) { + return match; + } + sleepQuietly(IMPORT_POLL_INTERVAL_MS); + } + throw new IllegalStateException(""" + The document '%s' was uploaded and is being created in the background. + Call list_folder_children on folder %s in a few seconds to retrieve it. + """.formatted(fileName, parentFolderId)); + } + + /** + * Lists the file ids currently under a folder, used to snapshot a destination + * before an operation that adds a file (import / copy / duplicate) so the newly + * created node can be identified afterwards. + */ + private Set currentChildFileIds(String folderId) throws ObjectNotFoundException, IllegalAccessException { + DocumentFolderFilter filter = new DocumentFolderFilter(folderId, null, null, null); + return documentFileService.getFolderChildNodes(filter, 0, IMPORT_POLL_PAGE_SIZE, getCurrentUserIdentityId()) + .stream() + .filter(FileNode.class::isInstance) + .map(AbstractNode::getId) + .collect(java.util.stream.Collectors.toSet()); + } + + /** + * Resolves the file that was just added to a folder as the child file whose id + * is not in the pre-operation snapshot; returns {@code null} if none is found + * yet. + */ + private DocumentModel resolveAddedChildFile(String folderId, + Set beforeFileIds) throws ObjectNotFoundException, + IllegalAccessException { + DocumentFolderFilter filter = new DocumentFolderFilter(folderId, null, null, null); + List children = documentFileService.getFolderChildNodes(filter, 0, IMPORT_POLL_PAGE_SIZE, + getCurrentUserIdentityId()); + return children.stream() + .filter(FileNode.class::isInstance) + .map(FileNode.class::cast) + .filter(file -> !beforeFileIds.contains(file.getId())) + .reduce((first, second) -> second) + .map(this::toDocumentFileModel) + .orElse(null); + } + + private static byte[] zipSingleEntry(String entryName, byte[] content) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry(entryName)); + zip.write(content); + zip.closeEntry(); + } catch (IOException e) { + throw new IllegalStateException("Could not package the document content: " + e.getMessage()); + } + return output.toByteArray(); + } + + private static String sanitizeFileName(String fileName) { + String cleaned = StringUtils.trimToEmpty(fileName).replaceAll("[/\\\\]", "_"); + return cleaned.isEmpty() ? "document" : cleaned; + } + + private static void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private long getCurrentUserIdentityId() { + return identityManager.getOrCreateUserIdentity(getCurrentUserName()) + .getIdentityId(); + } + +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/BreadcrumbItemModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/BreadcrumbItemModel.java new file mode 100644 index 0000000000..1220076d97 --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/BreadcrumbItemModel.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A single ancestor entry of a folder/file breadcrumb (from root to the node). + */ +@JsonInclude(Include.NON_EMPTY) +public record BreadcrumbItemModel(@JsonProperty("document_id") String id, String name, String path) { +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFileModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFileModel.java new file mode 100644 index 0000000000..11ac16f205 --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFileModel.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.meeds.mcp.server.tool.model.UserModel; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@JsonInclude(Include.NON_EMPTY) +public class DocumentFileModel extends DocumentModel { + + private long size; + + @JsonProperty("mime_type") + private String mimeType; + + private String transcription; + + private String description; + + @JsonProperty("updated_date") + private String updatedDate; + + @JsonProperty("last_updater_user") + private UserModel lastUpdaterUser; + + public DocumentFileModel(String id, // NOSONAR + String name, + String path, + String url, + String parentFolderId, + String createdDate, + UserModel creatorUser, + long size, + String mimeType, + String transcription, + String description, + String updatedDate, + UserModel lastUpdaterUser) { + super(id, name, path, url, parentFolderId, createdDate, creatorUser); + this.size = size; + this.mimeType = mimeType; + this.transcription = transcription; + this.description = description; + this.updatedDate = updatedDate; + this.lastUpdaterUser = lastUpdaterUser; + } + +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFolderModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFolderModel.java new file mode 100644 index 0000000000..2989b4a2cc --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentFolderModel.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import io.meeds.mcp.server.tool.model.UserModel; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@JsonInclude(Include.NON_EMPTY) +public class DocumentFolderModel extends DocumentModel { + + public DocumentFolderModel(String id, + String name, + String path, + String url, + String parentFolderId, + String createdDate, + UserModel creatorUser) { + super(id, name, path, url, parentFolderId, createdDate, creatorUser); + } + +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentModel.java new file mode 100644 index 0000000000..ce30a228a5 --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentModel.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.meeds.mcp.server.tool.model.UserModel; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonInclude(Include.NON_EMPTY) +public abstract class DocumentModel { + + @JsonProperty("document_id") + private String id; + + private String name; + + private String path; + + private String url; + + @JsonProperty("parent_folder_id") + private String parentFolderId; + + @JsonProperty("created_date") + private String createdDate; + + @JsonProperty("created_username") + private UserModel createdUser; + +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentPublicLinkModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentPublicLinkModel.java new file mode 100644 index 0000000000..047c5aae45 --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentPublicLinkModel.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A public (anonymous) share link created for a document, together with its + * optional password and expiration date. + */ +@JsonInclude(Include.NON_EMPTY) +public record DocumentPublicLinkModel(@JsonProperty("document_id") String documentId, + @JsonProperty("url") String url, + @JsonProperty("password") String password, + @JsonProperty("expiration_date") String expirationDate) { +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentTreeItemModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentTreeItemModel.java new file mode 100644 index 0000000000..d968c7340c --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentTreeItemModel.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A node of the folder tree, holding its child folders recursively. + */ +@JsonInclude(Include.NON_EMPTY) +public record DocumentTreeItemModel(@JsonProperty("document_id") String id, + String name, + String path, + List children) { +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentVersionModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentVersionModel.java new file mode 100644 index 0000000000..ac70741e32 --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentVersionModel.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A single version entry of a versioned document. + */ +@JsonInclude(Include.NON_EMPTY) +public record DocumentVersionModel(@JsonProperty("version_id") String id, + @JsonProperty("version_number") int versionNumber, + String title, + String summary, + String author, + @JsonProperty("author_full_name") String authorFullName, + @JsonProperty("created_date") String createdDate, + @JsonProperty("is_current") boolean current, + long size) { +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentsSizeModel.java b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentsSizeModel.java new file mode 100644 index 0000000000..9cee41b36a --- /dev/null +++ b/documents-services/src/main/java/org/exoplatform/documents/mcp/model/DocumentsSizeModel.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Aggregated storage size, in bytes, of a user or space drive. + */ +@JsonInclude(Include.NON_EMPTY) +public record DocumentsSizeModel(@JsonProperty("owner_id") long ownerId, + @JsonProperty("size_in_bytes") long sizeInBytes) { +} diff --git a/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java b/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java index 3cb164ee35..8bd61b59ba 100644 --- a/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java +++ b/documents-services/src/main/java/org/exoplatform/documents/service/DocumentFileServiceImpl.java @@ -369,6 +369,11 @@ public AbstractNode createFolder(long ownerId, String folderId, String folderPat return documentFileStorage.createFolder(ownerId, folderId, folderPath, name, getAclUserIdentity(authenticatedUserId)); } + @Override + public AbstractNode createDocumentFromTemplate(long ownerId, String folderId, String folderPath, String title, String documentType, long authenticatedUserId) throws IllegalAccessException, ObjectNotFoundException, ObjectAlreadyExistsException { + return documentFileStorage.createDocumentFromTemplate(ownerId, folderId, folderPath, title, documentType, getAclUserIdentity(authenticatedUserId)); + } + @Override public String getNewName(long ownerId, String folderId, String folderPath, String name) throws IllegalAccessException, ObjectAlreadyExistsException, ObjectNotFoundException { return documentFileStorage.getNewName(ownerId, folderId, folderPath, name); diff --git a/documents-services/src/main/resources/ai-tool-definitions.json b/documents-services/src/main/resources/ai-tool-definitions.json new file mode 100644 index 0000000000..91751595fe --- /dev/null +++ b/documents-services/src/main/resources/ai-tool-definitions.json @@ -0,0 +1,970 @@ +{ + "tools": [ + { + "name": "get_root_folder_by_space", + "title": "Get DMS root folder of a given space", + "description": "Retrieves the root folder associated with a specific space.", + "input_schema": { + "type": "object", + "properties": { + "space_id": { + "type": "integer", + "description": "space id for which the root folder is requested." + } + }, + "required": [ + "space_id" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_documents_by_folder_id", + "title": "Get DMS files and/or folders under a given folder", + "description": "Retrieves the files (and/or folders) located under a folder stored in the DMS.", + "input_schema": { + "type": "object", + "properties": { + "folder_id": { + "type": "string" + }, + "files_only": { + "type": "boolean" + }, + "folders_only": { + "type": "boolean" + }, + "offset": { + "type": "integer", + "description": "Pagination offset for result listing." + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return." + } + }, + "required": [ + "folder_id" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_root_folder_for_user", + "title": "Get DMS root personal folder", + "description": "Retrieves the root folder belonging to the currently authenticated user.", + "input_schema": { + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_document_by_id", + "title": "Get DMS folder or file by its id", + "description": "Fetches the metadata of a document given its unique identifier.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the document." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_document_content_by_id", + "title": "Get DMS file content", + "description": "Retrieves the textual or Markdown content of a document by its unique identifier.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the document." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "attach_document_to_content", + "title": "Link DMS file to a content", + "description": "Attach a file to a given content identified by its type (note, new, activity...) and its id", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "content_id": { + "type": "integer" + } + }, + "required": [ + "document_id", + "content_type", + "content_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "update_document_description", + "title": "Update DMS file summary", + "description": "Updates the file summary. This will not change the content of the file.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string" + }, + "html_description": { + "type": "string" + } + }, + "required": [ + "document_id", + "html_description" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "get_document_transcription_by_id", + "title": "Transcript an Audio DMS file", + "description": "Get the file transcription when it's an audio/video media content", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string" + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "search_documents", + "title": "Search for files", + "description": "Searches for documents matching the specified query and optional filters.", + "input_schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Full-text search query string." + }, + "space_id": { + "type": "integer", + "description": "Optional space identifier to restrict search scope." + }, + "parent_folder_id": { + "type": "string", + "description": "Optional parent folder ID to filter results." + }, + "offset": { + "type": "integer", + "description": "Pagination offset for result listing." + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return." + }, + "is_favorites": { + "type": "boolean", + "description": "If true, restricts results to favorite documents." + } + }, + "required": [ + "query" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "list_folder_children", + "title": "List DMS folder children", + "description": "Lists the direct child files and folders of a folder. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to obtain a folder_id.", + "input_schema": { + "type": "object", + "properties": { + "folder_id": { + "type": "string", + "description": "The unique identifier of the folder to list." + }, + "offset": { + "type": "integer", + "description": "Pagination offset for result listing." + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return." + } + }, + "required": [ + "folder_id" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_folder_breadcrumb", + "title": "Get DMS folder breadcrumb", + "description": "Returns the ancestor path (breadcrumb, from the drive root down to the folder) of a folder. Call get_documents_by_folder_id first to get a folder_id.", + "input_schema": { + "type": "object", + "properties": { + "folder_id": { + "type": "string", + "description": "The unique identifier of the folder." + } + }, + "required": [ + "folder_id" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_folder_tree", + "title": "Get DMS folder tree", + "description": "Returns the nested folder tree rooted at a folder. Call get_root_folder_for_user or get_documents_by_folder_id first to get a folder_id.", + "input_schema": { + "type": "object", + "properties": { + "folder_id": { + "type": "string", + "description": "The unique identifier of the root folder of the tree." + }, + "with_children": { + "type": "boolean", + "description": "If true (default), include the child folders recursively." + } + }, + "required": [ + "folder_id" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "list_document_versions", + "title": "List DMS file versions", + "description": "Lists the versions of a versioned file, newest first. Call get_document_by_id or get_documents_by_folder_id first to get a document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "get_documents_size", + "title": "Get DMS drive size", + "description": "Returns the total storage size (in bytes) of a user or space drive. When owner_id is omitted, returns the size of the current user's personal drive.", + "input_schema": { + "type": "object", + "properties": { + "owner_id": { + "type": "integer", + "description": "Identity id of the user or space whose drive size is requested. Optional; defaults to the current user." + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "create_folder", + "title": "Create a DMS folder", + "description": "Creates a new folder under a parent folder. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id.", + "input_schema": { + "type": "object", + "properties": { + "parent_folder_id": { + "type": "string", + "description": "The unique identifier of the parent folder." + }, + "name": { + "type": "string", + "description": "Name of the folder to create." + } + }, + "required": [ + "parent_folder_id", + "name" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "create_document", + "title": "Create a text document in the DMS", + "description": "Creates a TEXT-based document (plain text, markdown, HTML, CSV, JSON, XML) by writing the provided content string as-is into the file body. Use this to turn chat-generated text into a real file in the Documents app. The name must be a filename WITH an extension (e.g. 'notes.md', 'report.txt', 'page.html') — the extension sets the file's content type. It writes text as the file body, so use a text extension (.txt, .md, .html). It CANNOT produce binary formats: office documents (.docx, .xlsx, .pptx, .odt, etc.) and PDF are structured packages, not text, so they cannot be created here. To create a NEW EMPTY office document (Word/Excel/PowerPoint/ODF) use create_document_from_template; to store real file bytes use upload_document (base64, url or a chat attachment). Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. The stored content type is derived from the file extension by the platform: use a registered text extension (.txt, .html, .csv, etc.) so the file has the correct type and is readable via get_document_content_by_id; an unregistered extension like .md is stored as application/octet-stream and its text won't be extractable.", + "input_schema": { + "type": "object", + "properties": { + "parent_folder_id": { + "type": "string", + "description": "The unique identifier of the folder where the document is created." + }, + "name": { + "type": "string", + "description": "The document file name, including its extension (e.g. 'notes.md', 'report.txt', 'page.html'). The extension determines the file's content type; the name is used as-is." + }, + "content": { + "type": "string", + "description": "The text, markdown or HTML body of the document." + } + }, + "required": [ + "parent_folder_id", + "name", + "content" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "create_document_from_template", + "title": "Create an empty office document from a template", + "description": "Creates a NEW EMPTY office document (Word, Excel, or PowerPoint) from the platform's blank template, ready to edit in the online editor. Use this when the user wants a blank Word/Excel/PowerPoint document to fill in — create_document CANNOT produce office files (they are structured packages, not text) and upload_document needs the real file bytes. Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. The file extension is added automatically from document_type, so pass 'name' without an extension. Availability of a given type depends on the installed document editor add-on (onlyoffice).", + "input_schema": { + "type": "object", + "properties": { + "parent_folder_id": { + "type": "string", + "description": "The unique identifier of the folder where the document is created." + }, + "name": { + "type": "string", + "description": "The document name, without extension (e.g. 'Q3 report'); the extension is appended automatically from document_type." + }, + "document_type": { + "type": "string", + "description": "The office document type to create. The matching file extension is added automatically.", + "enum": [ + "docx", + "xlsx", + "pptx" + ] + } + }, + "required": [ + "parent_folder_id", + "name", + "document_type" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "upload_document", + "title": "Upload a file to the DMS", + "description": "Uploads a real or binary document (PDF, image, office file, etc.) into a folder. Use this when the user attaches a file or image in the conversation and asks to save it to the Documents app (the attachment is resolved automatically server-side), or from exactly one of a base64 payload or an http(s) URL (fetched server-side behind an SSRF guard). Call get_root_folder_for_user, get_root_folder_by_space or get_documents_by_folder_id first to get the parent_folder_id. For text/markdown/HTML authored in the chat, use create_document instead.", + "input_schema": { + "type": "object", + "properties": { + "parent_folder_id": { + "type": "string", + "description": "The unique identifier of the destination folder." + }, + "name": { + "type": "string", + "description": "The document file name including its extension (e.g. 'report.pdf'). Mandatory with base64; optional with url or a chat attachment (the name is then derived from the source file)." + }, + "base64": { + "type": "string", + "description": "The file bytes encoded in base64. Provide exactly one source: the chat attachment, base64 or url." + }, + "url": { + "type": "string", + "description": "An http(s) URL to download the file from. Provide exactly one source: the chat attachment, base64 or url." + }, + "attachment_object_type": { + "type": "string", + "description": "Internal: object type of an existing platform attachment to upload (set automatically by the client from the file/image the user attached in the conversation — do not fill this yourself). Read as the current user, so its ACL is enforced." + }, + "attachment_object_id": { + "type": "string", + "description": "Internal: object id of an existing platform attachment to upload (set automatically by the client — do not fill this yourself). Provide exactly one source: attachment_object_id, base64 or url." + } + }, + "required": [ + "parent_folder_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "require_approval": true + }, + { + "name": "rename_document", + "title": "Rename a DMS file or folder", + "description": "Renames a file or folder. Call get_document_by_id or get_documents_by_folder_id first to get a document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to rename." + }, + "new_name": { + "type": "string", + "description": "The new name." + } + }, + "required": [ + "document_id", + "new_name" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "move_document", + "title": "Move a DMS file or folder", + "description": "Moves a file or folder into a destination folder. Call get_document_by_id or get_documents_by_folder_id first to get the document_id and the destination_folder_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to move." + }, + "destination_folder_id": { + "type": "string", + "description": "The unique identifier of the destination folder." + }, + "conflict_action": { + "type": "string", + "enum": ["keepBoth", "createNewVersion"], + "description": "Behaviour when a document with the same name exists in the destination: 'keepBoth' moves it as a renamed copy, 'createNewVersion' replaces the existing file's content (keeping its version history). Defaults to 'keepBoth'." + } + }, + "required": [ + "document_id", + "destination_folder_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "copy_document", + "title": "Copy a DMS file or folder", + "description": "Copies a file or folder into a destination folder. Call get_document_by_id or get_documents_by_folder_id first to get the document_id and the destination_folder_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to copy." + }, + "destination_folder_id": { + "type": "string", + "description": "The unique identifier of the destination folder." + } + }, + "required": [ + "document_id", + "destination_folder_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "duplicate_document", + "title": "Duplicate a DMS file or folder", + "description": "Creates a copy of a file or folder in the same parent folder. Call get_document_by_id or get_documents_by_folder_id first to get a document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to duplicate." + }, + "prefix": { + "type": "string", + "description": "Optional prefix added to the duplicated name (e.g. 'Copy of')." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "delete_document", + "title": "Delete a DMS file or folder", + "description": "Moves a file or folder to the trash. It can be restored with undo_delete_document. Call get_document_by_id or get_documents_by_folder_id first to get a document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to delete." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "undo_delete_document", + "title": "Restore a deleted DMS document", + "description": "Cancels a previous delete_document and restores the document from the trash.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the previously deleted document." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "restore_document_version", + "title": "Restore a DMS file version", + "description": "Restores a previous version of a file as the current one. Call list_document_versions first to get a version_id.", + "input_schema": { + "type": "object", + "properties": { + "version_id": { + "type": "string", + "description": "The unique identifier of the version to restore." + } + }, + "required": [ + "version_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "update_version_summary", + "title": "Update a DMS file version summary", + "description": "Updates the summary (change note) of a specific file version. Call list_document_versions first to get the version_id; the document_id is the file the version belongs to.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file the version belongs to." + }, + "version_id": { + "type": "string", + "description": "The unique identifier of the version to update." + }, + "summary": { + "type": "string", + "description": "The new summary text." + } + }, + "required": [ + "document_id", + "version_id", + "summary" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "set_document_visibility", + "title": "Set DMS document visibility", + "description": "Hides or shows a file or folder. Call get_document_by_id or get_documents_by_folder_id first to get a document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder." + }, + "hidden": { + "type": "boolean", + "description": "true to hide the document, false to make it visible." + } + }, + "required": [ + "document_id", + "hidden" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "add_document_version", + "title": "Add a new version to a DMS file", + "description": "Uploads updated content onto an EXISTING file, creating a new version (the previous content stays retrievable via list_document_versions / restore_document_version). The file name and type are unchanged. Provide the new content from exactly one source: a text 'content' string (for text files), a 'base64' payload, an http(s) 'url', or a file the user attached in the conversation. Call get_document_by_id, get_documents_by_folder_id or search_documents first to get the document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the existing file to add a version to." + }, + "content": { + "type": "string", + "description": "Plain text / markdown / HTML body to write as the new version. Use for text files. Provide exactly one source: content, base64, url or the chat attachment." + }, + "base64": { + "type": "string", + "description": "The new file bytes encoded in base64. Provide exactly one source: content, base64, url or the chat attachment." + }, + "url": { + "type": "string", + "description": "An http(s) URL to download the new content from (fetched server-side behind an SSRF guard). Provide exactly one source: content, base64, url or the chat attachment." + }, + "attachment_object_type": { + "type": "string", + "description": "Internal: object type of an existing platform attachment to use as the new version (set automatically by the client from the file the user attached in the conversation - do not fill this yourself). Read as the current user, so its ACL is enforced." + }, + "attachment_object_id": { + "type": "string", + "description": "Internal: object id of an existing platform attachment to use as the new version (set automatically by the client - do not fill this yourself). Provide exactly one source: content, base64, url or attachment_object_id." + }, + "version_summary": { + "type": "string", + "description": "Optional change note stored on the new version." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "create_public_link", + "title": "Create a public download link for a DMS file", + "description": "Creates (or refreshes) an anonymous public share link for a document so it can be downloaded without signing in, optionally protected by a password and/or an expiration date. Requires edit permission on the document. Call get_document_by_id or get_documents_by_folder_id first to get the document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file to share publicly." + }, + "password": { + "type": "string", + "description": "Optional password required to open the link." + }, + "expiration_date": { + "type": "integer", + "description": "Optional expiration date as epoch milliseconds; after it the link stops working. Leave blank for a link that never expires." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "require_approval": true + }, + { + "name": "create_document_shortcut", + "title": "Create a shortcut to a DMS file or folder", + "description": "Creates a shortcut (symlink) to a document inside another folder, so the same file appears in a second location without being copied. Call get_document_by_id or get_documents_by_folder_id first to get the document_id and the destination_folder_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file or folder to create a shortcut to." + }, + "destination_folder_id": { + "type": "string", + "description": "The unique identifier of the folder where the shortcut is placed." + }, + "conflict_action": { + "type": "string", + "enum": ["keepBoth"], + "description": "Behaviour when a node with the same name already exists in the destination: only 'keepBoth' is supported (creates the shortcut under a renamed entry). Defaults to 'keepBoth'." + } + }, + "required": [ + "document_id", + "destination_folder_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "share_document", + "title": "Share a DMS file with a user or space", + "description": "Shares a document with another user or with a space: the recipient is granted read access and gets the file in their 'Shared' folder. Provide exactly one recipient: either username or space_id. Requires edit permission on the document. Call get_document_by_id first to get the document_id, search_users for a username, or get_my_spaces for a space_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file to share." + }, + "username": { + "type": "string", + "description": "The username to share the document with (mutually exclusive with space_id)." + }, + "space_id": { + "type": "integer", + "description": "The id of the space to share the document with (mutually exclusive with username)." + }, + "notify": { + "type": "boolean", + "description": "Whether to notify the recipient. Defaults to true." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "favorite_document", + "title": "Add a DMS file to favorites", + "description": "Adds a file to the current user's favorites. Only files can be favorited; folders have no favorites view. Call get_document_by_id or get_documents_by_folder_id first to get the document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file to mark as favorite." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "unfavorite_document", + "title": "Remove a DMS file from favorites", + "description": "Removes a file from the current user's favorites. Only files can be favorited; folders have no favorites view. Call get_document_by_id or get_documents_by_folder_id first to get the document_id.", + "input_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "The unique identifier of the file to remove from favorites." + } + }, + "required": [ + "document_id" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + } + ] +} diff --git a/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java new file mode 100644 index 0000000000..cbd25f8e8c --- /dev/null +++ b/documents-services/src/test/java/org/exoplatform/documents/mcp/DocumentMcpToolTest.java @@ -0,0 +1,1208 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.exoplatform.documents.mcp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import org.exoplatform.commons.ObjectAlreadyExistsException; +import org.exoplatform.commons.exception.ObjectNotFoundException; +import org.exoplatform.documents.mcp.model.BreadcrumbItemModel; +import org.exoplatform.documents.mcp.model.DocumentFileModel; +import org.exoplatform.documents.mcp.model.DocumentFolderModel; +import org.exoplatform.documents.mcp.model.DocumentModel; +import org.exoplatform.documents.mcp.model.DocumentPublicLinkModel; +import org.exoplatform.documents.mcp.model.DocumentTreeItemModel; +import org.exoplatform.documents.mcp.model.DocumentVersionModel; +import org.exoplatform.documents.mcp.model.DocumentsSizeModel; +import org.exoplatform.documents.model.AbstractNode; +import org.exoplatform.documents.model.BreadCrumbItem; +import org.exoplatform.documents.model.DocumentsSize; +import org.exoplatform.documents.model.FileNode; +import org.exoplatform.documents.model.FileVersion; +import org.exoplatform.documents.model.FolderNode; +import org.exoplatform.documents.model.FullTreeItem; +import org.exoplatform.documents.model.NodePermission; +import org.exoplatform.documents.model.PermissionEntry; +import org.exoplatform.documents.model.PermissionRole; +import org.exoplatform.documents.model.PublicDocumentAccess; +import org.exoplatform.documents.service.DocumentFileService; +import org.exoplatform.documents.service.PublicDocumentAccessService; +import org.exoplatform.portal.config.UserACL; +import org.exoplatform.portal.config.UserPortalConfigService; +import org.exoplatform.services.attachments.service.AttachmentService; +import org.exoplatform.services.security.Identity; +import org.exoplatform.social.core.manager.IdentityManager; +import org.exoplatform.social.core.profileproperty.ProfilePropertyService; +import org.exoplatform.social.core.space.model.Space; +import org.exoplatform.social.core.space.spi.SpaceService; +import org.exoplatform.social.metadata.favorite.FavoriteService; +import org.exoplatform.social.metadata.favorite.model.Favorite; +import org.exoplatform.upload.UploadService; + +import io.meeds.social.translation.service.TranslationService; + +public class DocumentMcpToolTest { + + private static final String USERNAME = "testuser1"; + + private static final long USER_IDENTITY_ID = 100L; + + private static final long SPACE_ID = 3L; + + private static final String DOCUMENT_ID = "be0688cd7f00010134384ab7e1b15a48"; + + private static final String FOLDER_ID = "folder-42"; + + private DocumentFileService documentFileService; + + private AttachmentService attachmentService; + + private org.exoplatform.social.attachment.AttachmentService socialAttachmentService; + + private org.exoplatform.commons.file.services.FileService fileService; + + private IdentityManager identityManager; + + private SpaceService spaceService; + + private PublicDocumentAccessService publicDocumentAccessService; + + private FavoriteService favoriteService; + + private Identity currentIdentity; + + private DocumentMcpTool documentMcpTool; + + @Before + public void setUp() { + documentFileService = Mockito.mock(DocumentFileService.class); + attachmentService = Mockito.mock(AttachmentService.class); + socialAttachmentService = Mockito.mock(org.exoplatform.social.attachment.AttachmentService.class); + fileService = Mockito.mock(org.exoplatform.commons.file.services.FileService.class); + identityManager = Mockito.mock(IdentityManager.class); + spaceService = Mockito.mock(SpaceService.class); + TranslationService translationService = Mockito.mock(TranslationService.class); + ProfilePropertyService profilePropertyService = Mockito.mock(ProfilePropertyService.class); + UserACL userAcl = Mockito.mock(UserACL.class); + UserPortalConfigService portalConfigService = Mockito.mock(UserPortalConfigService.class); + UploadService uploadService = Mockito.mock(UploadService.class); + publicDocumentAccessService = Mockito.mock(PublicDocumentAccessService.class); + favoriteService = Mockito.mock(FavoriteService.class); + currentIdentity = new Identity(USERNAME); + + org.exoplatform.social.core.identity.model.Identity socialIdentity = + new org.exoplatform.social.core.identity.model.Identity(String.valueOf(USER_IDENTITY_ID)); + lenient().when(identityManager.getOrCreateUserIdentity(USERNAME)).thenReturn(socialIdentity); + + documentMcpTool = new DocumentMcpTool(documentFileService, + attachmentService, + socialAttachmentService, + fileService, + identityManager, + spaceService, + translationService, + profilePropertyService, + userAcl, + portalConfigService, + uploadService, + publicDocumentAccessService, + favoriteService) { + @Override + public Identity getCurrentUserAclIdentity() { + return currentIdentity; + } + + @Override + public Locale getCurrentUserLocale() { + return Locale.ENGLISH; + } + }; + } + + private FileNode fileNode(String id, String mimeType) { + FileNode fileNode = new FileNode(); + fileNode.setId(id); + fileNode.setName("report.pdf"); + fileNode.setPath("/documents/report.pdf"); + fileNode.setMimeType(mimeType); + fileNode.setSize(2048L); + return fileNode; + } + + private FolderNode folderNode(String id) { + FolderNode folderNode = new FolderNode(); + folderNode.setId(id); + folderNode.setName("Projects"); + folderNode.setPath("/documents/Projects"); + return folderNode; + } + + @Test + public void getRootFolderBySpace() throws Exception { + when(documentFileService.getSpaceRootFolder(eq(SPACE_ID), eq(currentIdentity))).thenReturn(folderNode(FOLDER_ID)); + + DocumentFolderModel model = documentMcpTool.getRootFolderBySpace(SPACE_ID); + + assertNotNull(model); + assertEquals(FOLDER_ID, model.getId()); + assertEquals("Projects", model.getName()); + assertNotNull(model.getUrl()); + } + + @Test + public void getRootFolderForUser() { + when(documentFileService.getPersonalRootFolder(eq(currentIdentity))).thenReturn(folderNode(FOLDER_ID)); + + DocumentFolderModel model = documentMcpTool.getRootFolderForUser(); + + assertNotNull(model); + assertEquals(FOLDER_ID, model.getId()); + } + + @Test + public void getDocumentById() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + + DocumentModel model = documentMcpTool.getDocumentById(DOCUMENT_ID); + + assertNotNull(model); + assertEquals(DOCUMENT_ID, model.getId()); + } + + @Test + public void getDocumentByIdWithBlankIdFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.getDocumentById(" ")); + } + + @Test + public void getDocumentContentById() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "text/plain")); + when(documentFileService.getFileContentAsText(DOCUMENT_ID)).thenReturn("Hello world"); + + assertEquals("Hello world", documentMcpTool.getDocumentContentById(DOCUMENT_ID)); + } + + @Test + public void getDocumentTranscriptionById() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "audio/mp3")); + when(documentFileService.getAudioTranscription(eq(DOCUMENT_ID), anyLong())).thenReturn("the transcription"); + + assertEquals("the transcription", documentMcpTool.getDocumentTranscriptionById(DOCUMENT_ID)); + } + + @Test + public void getDocumentTranscriptionByIdWhenPendingFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "video/mp4")); + when(documentFileService.getAudioTranscription(eq(DOCUMENT_ID), anyLong())).thenReturn(null); + + assertThrows(IllegalStateException.class, () -> documentMcpTool.getDocumentTranscriptionById(DOCUMENT_ID)); + } + + @Test + public void getDocumentsByFolderId() throws Exception { + List items = List.of(fileNode(DOCUMENT_ID, "application/pdf"), folderNode(FOLDER_ID)); + Mockito.>when(documentFileService.getDocumentItems(any(), any(), anyInt(), anyInt(), anyLong(), anyBoolean())) + .thenReturn(items); + + List models = documentMcpTool.getDocumentsByFolderId(FOLDER_ID, null, null, null, null); + + assertNotNull(models); + assertEquals(2, models.size()); + } + + @Test + public void getDocumentsByFolderIdFilesOnlyExcludesFolders() throws Exception { + List items = List.of(fileNode(DOCUMENT_ID, "application/pdf"), folderNode(FOLDER_ID)); + Mockito.>when(documentFileService.getDocumentItems(any(), any(), anyInt(), anyInt(), anyLong(), anyBoolean())) + .thenReturn(items); + + List models = documentMcpTool.getDocumentsByFolderId(FOLDER_ID, Boolean.TRUE, null, null, null); + + // files_only=true must keep only the file, dropping the folder. + assertEquals(1, models.size()); + assertEquals(DOCUMENT_ID, models.get(0).getId()); + } + + @Test + public void getDocumentsByFolderIdFoldersOnlyExcludesFiles() throws Exception { + List items = List.of(fileNode(DOCUMENT_ID, "application/pdf"), folderNode(FOLDER_ID)); + Mockito.>when(documentFileService.getDocumentItems(any(), any(), anyInt(), anyInt(), anyLong(), anyBoolean())) + .thenReturn(items); + + List models = documentMcpTool.getDocumentsByFolderId(FOLDER_ID, null, Boolean.TRUE, null, null); + + // folders_only=true must keep only the folder, dropping the file. + assertEquals(1, models.size()); + assertEquals(FOLDER_ID, models.get(0).getId()); + } + + @Test + public void searchDocuments() throws Exception { + when(documentFileService.search(any(org.exoplatform.documents.model.DocumentTimelineFilter.class), + eq(currentIdentity), + anyInt(), + anyInt())).thenReturn(List.of(fileNode(DOCUMENT_ID, "application/pdf"))); + + List models = documentMcpTool.searchDocuments("report", null, null, null, null, null); + + assertNotNull(models); + assertEquals(1, models.size()); + assertEquals(DOCUMENT_ID, models.get(0).getId()); + } + + @Test + public void searchDocumentsWithUnknownSpaceFails() { + when(spaceService.getSpaceById(String.valueOf(SPACE_ID))).thenReturn(null); + + assertThrows(ObjectNotFoundException.class, + () -> documentMcpTool.searchDocuments("report", SPACE_ID, null, null, null, null)); + } + + @Test + public void searchDocumentsInSpaceNotViewableFails() { + Space space = new Space(); + space.setId(String.valueOf(SPACE_ID)); + space.setPrettyName("engineering"); + when(spaceService.getSpaceById(String.valueOf(SPACE_ID))).thenReturn(space); + when(spaceService.canViewSpace(eq(space), eq(USERNAME))).thenReturn(false); + + assertThrows(IllegalAccessException.class, + () -> documentMcpTool.searchDocuments("report", SPACE_ID, null, null, null, null)); + } + + @Test + public void updateDocumentDescription() throws Exception { + when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(55L); + + documentMcpTool.updateDocumentDescription(DOCUMENT_ID, "

new

"); + + verify(documentFileService).updateDocumentDescription(eq(55L), eq(DOCUMENT_ID), eq("

new

"), eq(USER_IDENTITY_ID)); + } + + @Test + public void attachDocumentToContent() throws Exception { + documentMcpTool.attachDocumentToContent(DOCUMENT_ID, "activity", 77L); + + verify(attachmentService).linkAttachmentToEntity(eq(USER_IDENTITY_ID), eq(77L), eq("activity"), eq(DOCUMENT_ID)); + } + + // --------------------------------------------------------------------------- + // New folder-navigation and content-management tools + // --------------------------------------------------------------------------- + + private static final long OWNER_ID = 55L; + + private FileVersion fileVersion() { + FileVersion version = new FileVersion(); + version.setId("version-1"); + version.setVersionNumber(2); + version.setTitle("report.pdf"); + version.setSummary("initial upload"); + version.setAuthor(USERNAME); + version.setAuthorFullName("Test User"); + version.setCreatedDate(new Date()); + version.setCurrent(true); + version.setSize(2048L); + return version; + } + + @Test + public void listFolderChildren() throws Exception { + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())).thenReturn(List.of(fileNode(DOCUMENT_ID, + "application/pdf"), + folderNode(FOLDER_ID))); + + List models = documentMcpTool.listFolderChildren(FOLDER_ID, null, null); + + assertNotNull(models); + assertEquals(2, models.size()); + } + + @Test + public void listFolderChildrenBlankFolderFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.listFolderChildren(" ", null, null)); + } + + @Test + public void getFolderBreadcrumb() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.getBreadcrumb(eq(OWNER_ID), eq(FOLDER_ID), isNull(), eq(USER_IDENTITY_ID))) + .thenReturn(List.of(new BreadCrumbItem(FOLDER_ID, "Projects", "Projects", "/documents/Projects", false, null))); + + List models = documentMcpTool.getFolderBreadcrumb(FOLDER_ID); + + assertEquals(1, models.size()); + assertEquals(FOLDER_ID, models.get(0).id()); + assertEquals("Projects", models.get(0).name()); + } + + @Test + public void getFolderBreadcrumbUnknownFolderFails() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenThrow(new ObjectNotFoundException("not found")); + + assertThrows(ObjectNotFoundException.class, () -> documentMcpTool.getFolderBreadcrumb(FOLDER_ID)); + } + + @Test + public void getFolderTree() throws Exception { + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + FullTreeItem child = new FullTreeItem("child-1", "Sub", "/documents/Projects/Sub", null, false, String.valueOf(OWNER_ID)); + FullTreeItem root = new FullTreeItem(FOLDER_ID, "Projects", "/documents/Projects", List.of(child), false, String.valueOf(OWNER_ID)); + when(documentFileService.getFullTreeData(eq(OWNER_ID), eq(FOLDER_ID), any(), eq(USER_IDENTITY_ID), anyBoolean(), anyBoolean())) + .thenReturn(List.of(root)); + + List tree = documentMcpTool.getFolderTree(FOLDER_ID, true); + + assertEquals(1, tree.size()); + assertEquals(FOLDER_ID, tree.get(0).id()); + assertEquals(1, tree.get(0).children().size()); + assertEquals("child-1", tree.get(0).children().get(0).id()); + } + + @Test + public void listDocumentVersions() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.getFileVersions(DOCUMENT_ID, USERNAME)).thenReturn(List.of(fileVersion())); + + List versions = documentMcpTool.listDocumentVersions(DOCUMENT_ID); + + assertEquals(1, versions.size()); + assertEquals("version-1", versions.get(0).id()); + assertTrue(versions.get(0).current()); + } + + @Test + public void getDocumentsSizeDefaultsToCurrentUser() throws Exception { + when(documentFileService.getDocumentsSizeStat(USER_IDENTITY_ID, USER_IDENTITY_ID)).thenReturn(new DocumentsSize(USER_IDENTITY_ID, + 4096L, + 0L, + 0L, + 0L, + true, + 0L)); + + DocumentsSizeModel model = documentMcpTool.getDocumentsSize(null); + + assertNotNull(model); + assertEquals(USER_IDENTITY_ID, model.ownerId()); + assertEquals(4096L, model.sizeInBytes()); + // Fresh, non-zero, today's stat -> no on-demand compute needed. + verify(documentFileService, never()).addDocumentsSizeStat(anyLong(), anyLong()); + } + + @Test + public void getDocumentsSizeComputesWhenStatMissing() throws Exception { + // No stat written yet -> getDocumentsSizeStat returns an empty (owner 0, size 0) value. + when(documentFileService.getDocumentsSizeStat(USER_IDENTITY_ID, USER_IDENTITY_ID)) + .thenReturn(new DocumentsSize(0L, 0L, 0L, 0L, 0L, false, 0L)); + when(documentFileService.addDocumentsSizeStat(USER_IDENTITY_ID, USER_IDENTITY_ID)) + .thenReturn(new DocumentsSize(USER_IDENTITY_ID, 8192L, 0L, 0L, 0L, true, 0L)); + + DocumentsSizeModel model = documentMcpTool.getDocumentsSize(null); + + // The tool must trigger the compute path and return the real size for the correct owner, + // never the misleading owner_id 0 / size 0. + verify(documentFileService).addDocumentsSizeStat(USER_IDENTITY_ID, USER_IDENTITY_ID); + assertEquals(USER_IDENTITY_ID, model.ownerId()); + assertEquals(8192L, model.sizeInBytes()); + } + + @Test + public void createFolder() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.createFolder(eq(OWNER_ID), eq(FOLDER_ID), isNull(), eq("New"), eq(USER_IDENTITY_ID))) + .thenReturn(folderNode("new-folder")); + + DocumentModel model = documentMcpTool.createFolder(FOLDER_ID, "New"); + + assertNotNull(model); + assertEquals("new-folder", model.getId()); + } + + @Test + public void createFolderUnderRootFolderPassesNullFolderPath() throws Exception { + // Reproduces the live bug: creating a folder under the user's ROOT folder + // (e.g. "Private", whose absolute JCR path is /Users/.../root/Private). The + // parent folder id already identifies the JCR node, so the storage-level + // 'folderPath' must be null; passing the absolute path made the JCR storage + // throw ObjectNotFoundException "Folder with path ... isn't found". + FolderNode rootFolder = new FolderNode(); + rootFolder.setId(FOLDER_ID); + rootFolder.setName("Private"); + rootFolder.setPath("/Users/t___/te___/tes___/testuser1/Private"); + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(rootFolder); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.createFolder(eq(OWNER_ID), eq(FOLDER_ID), isNull(), eq("MCPJam-Test"), eq(USER_IDENTITY_ID))) + .thenReturn(folderNode("new-folder")); + + DocumentModel model = documentMcpTool.createFolder(FOLDER_ID, "MCPJam-Test"); + + assertNotNull(model); + assertEquals("new-folder", model.getId()); + // folderPath must be null (not the absolute root path) so the storage resolves + // the parent purely from the folder id. + verify(documentFileService).createFolder(eq(OWNER_ID), eq(FOLDER_ID), isNull(), eq("MCPJam-Test"), eq(USER_IDENTITY_ID)); + } + + @Test + public void createFolderBlankNameFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createFolder(FOLDER_ID, " ")); + } + + private FileNode fileNodeNamed(String id, String name) { + FileNode fileNode = new FileNode(); + fileNode.setId(id); + fileNode.setName(name); + fileNode.setPath("/documents/Projects/" + name); + fileNode.setMimeType("text/plain"); + fileNode.setSize(16L); + return fileNode; + } + + @Test + public void createDocumentTextExtensionSucceeds() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + // First call = pre-import snapshot (empty), second call = after import (the new file). + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(Collections.emptyList()) + .thenReturn(List.of(fileNodeNamed("doc-txt", "notes.txt"))); + + // The name is used as-is (it already carries a .txt extension). + DocumentModel model = documentMcpTool.createDocument(FOLDER_ID, "notes.txt", "plain text body"); + + assertNotNull(model); + assertEquals("doc-txt", model.getId()); + // folderPath must be null (parent resolved from the folder id) and conflict + // "duplicate" (NOT "rename": the async import thread only understands + // "updateAll"/"duplicate", so "rename" silently creates nothing on a clash). + verify(documentFileService).importFiles(eq(String.valueOf(OWNER_ID)), + eq(FOLDER_ID), + isNull(), + any(), + eq("duplicate"), + eq(currentIdentity), + eq(USER_IDENTITY_ID)); + } + + @Test + public void createDocumentBlankNameFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocument(FOLDER_ID, " ", "body")); + } + + @Test + public void createDocumentMissingExtensionFails() { + // A name with no extension (and a trailing-dot name, which also has no + // extension) must fail: the extension drives the stored content type. + for (String name : new String[] { "notes", "my.notes.", "report " }) { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.createDocument(FOLDER_ID, name, "some content")); + assertTrue(ex.getMessage().contains("must include a file extension")); + } + } + + @Test + public void createDocumentOfficeExtensionFails() { + // .docx / .xlsx / .pptx / .pdf are structured/binary formats that cannot be + // built from a text string: fail up front with an LLM-directed message that + // names the extension and points to upload_document. + for (String name : new String[] { "report.docx", "budget.xlsx", "deck.pptx", "doc.pdf" }) { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.createDocument(FOLDER_ID, name, "some content")); + String ext = name.substring(name.lastIndexOf('.') + 1); + assertTrue(ex.getMessage().contains("'" + ext + "'")); + assertTrue(ex.getMessage().contains("upload_document")); + } + } + + @Test + public void createDocumentBlankContentFails() { + IllegalArgumentException nullContent = + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.createDocument(FOLDER_ID, "notes.md", null)); + assertTrue(nullContent.getMessage().contains("content is required")); + IllegalArgumentException blankContent = + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.createDocument(FOLDER_ID, "notes.md", " ")); + assertTrue(blankContent.getMessage().contains("content is required")); + } + + @Test + public void createDocumentFromTemplateSucceeds() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + // ecms names the file after the title (with the extension), so the tool builds + // "Q3 report.docx" and passes the normalized type "docx". + when(documentFileService.createDocumentFromTemplate(eq(OWNER_ID), + eq(FOLDER_ID), + isNull(), + eq("Q3 report.docx"), + eq("docx"), + eq(USER_IDENTITY_ID))) + .thenReturn(fileNodeNamed("doc-tpl", "q3 report.docx")); + + DocumentModel model = documentMcpTool.createDocumentFromTemplate(FOLDER_ID, "Q3 report", "docx"); + + assertNotNull(model); + assertEquals("doc-tpl", model.getId()); + verify(documentFileService).createDocumentFromTemplate(eq(OWNER_ID), + eq(FOLDER_ID), + isNull(), + eq("Q3 report.docx"), + eq("docx"), + eq(USER_IDENTITY_ID)); + } + + @Test + public void createDocumentFromTemplateStripsDuplicateExtensionAndNormalizesType() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.createDocumentFromTemplate(eq(OWNER_ID), + eq(FOLDER_ID), + isNull(), + eq("budget.xlsx"), + eq("xlsx"), + eq(USER_IDENTITY_ID))) + .thenReturn(fileNodeNamed("doc-xls", "budget.xlsx")); + + // name already carries the extension and document_type is upper-cased with a + // leading dot: the tool must not produce "budget.xlsx.xlsx" nor pass ".XLSX". + DocumentModel model = documentMcpTool.createDocumentFromTemplate(FOLDER_ID, "budget.xlsx", ".XLSX"); + + assertNotNull(model); + assertEquals("doc-xls", model.getId()); + verify(documentFileService).createDocumentFromTemplate(eq(OWNER_ID), + eq(FOLDER_ID), + isNull(), + eq("budget.xlsx"), + eq("xlsx"), + eq(USER_IDENTITY_ID)); + } + + @Test + public void createDocumentFromTemplateBlankNameFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocumentFromTemplate(FOLDER_ID, " ", "docx")); + } + + @Test + public void createDocumentFromTemplateBlankTypeFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocumentFromTemplate(FOLDER_ID, "report", " ")); + } + + @Test + public void createDocumentFromTemplateUnsupportedTypeFails() { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.createDocumentFromTemplate(FOLDER_ID, "report", "pdf")); + assertTrue(ex.getMessage().contains("Unsupported document_type")); + } + + @Test + public void createDocumentFromTemplateBlankFolderFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocumentFromTemplate(" ", "report", "docx")); + } + + @Test + public void createDocumentFromTemplateBadFolderFails() throws Exception { + when(documentFileService.getDocumentById("missing", USERNAME)).thenThrow(new ObjectNotFoundException("no node")); + assertThrows(ObjectNotFoundException.class, + () -> documentMcpTool.createDocumentFromTemplate("missing", "report", "docx")); + } + + @Test + public void createDocumentFromTemplateNoEditPermissionFails() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.createDocumentFromTemplate(anyLong(), anyString(), isNull(), anyString(), anyString(), anyLong())) + .thenThrow(new IllegalAccessException("Permission to add document is missing")); + assertThrows(IllegalAccessException.class, + () -> documentMcpTool.createDocumentFromTemplate(FOLDER_ID, "report", "docx")); + } + + @Test + public void createDocumentFromTemplateNameClashFails() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.createDocumentFromTemplate(anyLong(), anyString(), isNull(), anyString(), anyString(), anyLong())) + .thenThrow(new ObjectAlreadyExistsException("exists")); + IllegalStateException ex = + assertThrows(IllegalStateException.class, + () -> documentMcpTool.createDocumentFromTemplate(FOLDER_ID, "report", "docx")); + assertTrue(ex.getMessage().contains("already exists")); + } + + @Test + public void uploadDocumentFromBase64() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(Collections.emptyList()) + .thenReturn(List.of(fileNodeNamed("upl-1", "hello.txt"))); + String base64 = java.util.Base64.getEncoder().encodeToString("hello".getBytes()); + + DocumentModel model = documentMcpTool.uploadDocument(FOLDER_ID, "hello.txt", base64, null, null, null); + + assertNotNull(model); + assertEquals("upl-1", model.getId()); + verify(documentFileService).importFiles(eq(String.valueOf(OWNER_ID)), + eq(FOLDER_ID), + isNull(), + any(), + eq("duplicate"), + eq(currentIdentity), + eq(USER_IDENTITY_ID)); + } + + @Test + public void uploadDocumentFromChatAttachment() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(FOLDER_ID)).thenReturn(OWNER_ID); + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(Collections.emptyList()) + .thenReturn(List.of(fileNodeNamed("upl-att", "screenshot.png"))); + // The client injects a platform attachment reference; the tool resolves its + // bytes as the current user (ACL enforced) via the social AttachmentService. + when(socialAttachmentService.getAttachmentFileIds(eq("activity"), eq("77"), eq(currentIdentity))) + .thenReturn(List.of("999")); + org.exoplatform.commons.file.model.FileItem fileItem = Mockito.mock(org.exoplatform.commons.file.model.FileItem.class); + org.exoplatform.commons.file.model.FileInfo fileInfo = Mockito.mock(org.exoplatform.commons.file.model.FileInfo.class); + when(fileItem.getAsByte()).thenReturn("PNGBYTES".getBytes()); + when(fileItem.getFileInfo()).thenReturn(fileInfo); + when(fileInfo.getMimetype()).thenReturn("image/png"); + when(fileInfo.getName()).thenReturn("screenshot.png"); + when(fileService.getFile(999L)).thenReturn(fileItem); + + // name left blank -> reused from the resolved attachment file name. + DocumentModel model = documentMcpTool.uploadDocument(FOLDER_ID, null, null, null, "activity", "77"); + + assertNotNull(model); + assertEquals("upl-att", model.getId()); + verify(documentFileService).importFiles(eq(String.valueOf(OWNER_ID)), + eq(FOLDER_ID), + isNull(), + any(), + eq("duplicate"), + eq(currentIdentity), + eq(USER_IDENTITY_ID)); + } + + @Test + public void uploadDocumentUnknownAttachmentFails() throws Exception { + when(socialAttachmentService.getAttachmentFileIds(eq("activity"), eq("77"), eq(currentIdentity))) + .thenReturn(java.util.Collections.emptyList()); + + assertThrows(ObjectNotFoundException.class, + () -> documentMcpTool.uploadDocument(FOLDER_ID, null, null, null, "activity", "77")); + } + + @Test + public void uploadDocumentRequiresExactlyOneSource() { + String base64 = java.util.Base64.getEncoder().encodeToString("hello".getBytes()); + // no source at all + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.uploadDocument(FOLDER_ID, "x.txt", null, null, null, null)); + // both base64 and url + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.uploadDocument(FOLDER_ID, "x.txt", base64, "https://example.com/x.txt", null, null)); + // both an attachment and base64 + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.uploadDocument(FOLDER_ID, "x.txt", base64, null, "activity", "77")); + } + + @Test + public void uploadDocumentBase64RequiresName() { + String base64 = java.util.Base64.getEncoder().encodeToString("hello".getBytes()); + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.uploadDocument(FOLDER_ID, " ", base64, null, null, null)); + } + + @Test + public void renameDocument() throws Exception { + when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(OWNER_ID); + + documentMcpTool.renameDocument(DOCUMENT_ID, "renamed.pdf"); + + verify(documentFileService).renameDocument(eq(OWNER_ID), eq(DOCUMENT_ID), eq("renamed.pdf"), eq(USER_IDENTITY_ID)); + } + + @Test + public void moveDocumentDefaultsConflictActionToKeepBoth() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(OWNER_ID); + + documentMcpTool.moveDocument(DOCUMENT_ID, FOLDER_ID, null); + + // "keepBoth" is the only rename-style conflict value the storage honours; + // the old default "rename" was ignored (fell through to ObjectAlreadyExists). + verify(documentFileService).moveDocument(eq(OWNER_ID), + eq(DOCUMENT_ID), + eq("/documents/Projects"), + eq(USER_IDENTITY_ID), + eq("keepBoth")); + } + + @Test + public void moveDocumentBlankDestinationFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.moveDocument(DOCUMENT_ID, " ", null)); + } + + @Test + public void copyDocumentReturnsResolvedCopyNotDestinationFolder() throws Exception { + // The storage copyDocument returns the DESTINATION FOLDER node (not the copy); + // the tool must re-resolve the newly added file in the destination folder. + when(documentFileService.copyDocument(DOCUMENT_ID, FOLDER_ID, USER_IDENTITY_ID)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(Collections.emptyList()) + .thenReturn(List.of(fileNodeNamed("copy-1", "report.pdf"))); + + DocumentModel model = documentMcpTool.copyDocument(DOCUMENT_ID, FOLDER_ID); + + assertNotNull(model); + // Must be the resolved copied file, NOT the destination folder id the storage returned. + assertEquals("copy-1", model.getId()); + assertFalse(FOLDER_ID.equals(model.getId())); + } + + @Test + public void duplicateDocumentReturnsResolvedDuplicateNotParentFolder() throws Exception { + // The storage duplicateDocument returns the PARENT FOLDER node (not the + // duplicate); the tool must re-resolve the newly added file in the parent. + FileNode source = fileNode(DOCUMENT_ID, "application/pdf"); + source.setParentFolderId(FOLDER_ID); + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(source); + when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(OWNER_ID); + when(documentFileService.duplicateDocument(OWNER_ID, DOCUMENT_ID, "Copy of", USER_IDENTITY_ID)).thenReturn(folderNode(FOLDER_ID)); + when(documentFileService.getFolderChildNodes(any(), anyInt(), anyInt(), anyLong())) + .thenReturn(List.of(source)) + .thenReturn(List.of(source, fileNodeNamed("dup-1", "report(1).pdf"))); + + DocumentModel model = documentMcpTool.duplicateDocument(DOCUMENT_ID, "Copy of"); + + assertNotNull(model); + // Must be the resolved duplicate (the newly added file), NOT the parent folder. + assertEquals("dup-1", model.getId()); + assertFalse(FOLDER_ID.equals(model.getId())); + } + + @Test + public void deleteDocumentMovesToTrashImmediately() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + + documentMcpTool.deleteDocument(DOCUMENT_ID); + + // favorite=true so a favorited file is also dropped from favorites on delete + // (the storage cleanup uses object type "file"). + verify(documentFileService).deleteDocument(eq("/documents/report.pdf"), + eq(DOCUMENT_ID), + eq(true), + eq(0L), + eq(USER_IDENTITY_ID)); + } + + @Test + public void deleteDocumentUnknownFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenThrow(new ObjectNotFoundException("not found")); + + assertThrows(ObjectNotFoundException.class, () -> documentMcpTool.deleteDocument(DOCUMENT_ID)); + } + + @Test + public void undoDeleteDocumentRestoresFromTrash() throws Exception { + // After an immediate delete the node lives in trash but keeps its id; undo must + // actually restore it from trash (the delayed-delete cancel path is a no-op). + FileNode trashed = fileNode(DOCUMENT_ID, "application/pdf"); + trashed.setPath("/Trash/report.pdf"); + // The trashed node must be resolved through the SYSTEM-session overload + // (getDocumentById(id)), not the user-session one: trash lives under a system + // session and a regular user session cannot read the trashed node. + when(documentFileService.getDocumentById(DOCUMENT_ID)).thenReturn(trashed); + + documentMcpTool.undoDeleteDocument(DOCUMENT_ID); + + verify(documentFileService).restoreDocumentFromTrash(eq("/Trash/report.pdf")); + // It must use the system-session overload, NOT the user-session one (which would + // false-negative on a trashed node). + verify(documentFileService).getDocumentById(DOCUMENT_ID); + verify(documentFileService, never()).getDocumentById(DOCUMENT_ID, USERNAME); + // It must NOT rely on the no-op delayed-delete cancel. + verify(documentFileService, never()).undoDeleteDocument(anyString(), anyLong()); + } + + @Test + public void undoDeleteDocumentNotFoundFails() throws Exception { + // The system-session overload returns null when the node is genuinely gone. + when(documentFileService.getDocumentById(DOCUMENT_ID)).thenReturn(null); + + // Never silently succeed: a document no longer in trash must surface a clear error. + assertThrows(IllegalStateException.class, () -> documentMcpTool.undoDeleteDocument(DOCUMENT_ID)); + } + + @Test + public void restoreDocumentVersion() { + when(documentFileService.restoreVersion("version-1", USERNAME)).thenReturn(fileVersion()); + + DocumentVersionModel model = documentMcpTool.restoreDocumentVersion("version-1"); + + assertNotNull(model); + assertEquals("version-1", model.id()); + } + + @Test + public void restoreDocumentVersionBlankFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.restoreDocumentVersion(" ")); + } + + @Test + public void updateVersionSummaryRereadsFullVersion() { + // Storage updateVersionSummary returns a near-empty FileVersion (only id + summary); + // the tool must re-read the full version so number/author/dates/size are populated. + FileVersion stamped = new FileVersion(); + stamped.setId("version-1"); + stamped.setSummary("new summary"); + when(documentFileService.updateVersionSummary(DOCUMENT_ID, "version-1", "new summary", USERNAME)).thenReturn(stamped); + FileVersion full = fileVersion(); + full.setSummary("new summary"); + when(documentFileService.getFileVersions(DOCUMENT_ID, USERNAME)).thenReturn(List.of(full)); + + DocumentVersionModel model = documentMcpTool.updateVersionSummary(DOCUMENT_ID, "version-1", "new summary"); + + assertNotNull(model); + assertEquals("new summary", model.summary()); + // Fields that only the full re-read carries: + assertEquals(2, model.versionNumber()); + assertEquals("Test User", model.authorFullName()); + assertTrue(model.current()); + } + + @Test + public void setDocumentVisibility() throws Exception { + when(documentFileService.getRootFolderOwnerId(DOCUMENT_ID)).thenReturn(OWNER_ID); + + documentMcpTool.setDocumentVisibility(DOCUMENT_ID, Boolean.TRUE); + + verify(documentFileService).setDocumentVisibility(eq(OWNER_ID), eq(DOCUMENT_ID), eq(Boolean.TRUE), eq(USER_IDENTITY_ID)); + } + + @Test + public void setDocumentVisibilityNullHiddenFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.setDocumentVisibility(DOCUMENT_ID, null)); + } + + // --------------------------------------------------------------------------- + // Versioning, sharing, public links and favorites + // --------------------------------------------------------------------------- + + @Test + public void addDocumentVersionFromContent() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "text/plain")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + + DocumentModel model = documentMcpTool.addDocumentVersion(DOCUMENT_ID, "updated body", null, null, null, null, null); + + assertNotNull(model); + assertEquals(DOCUMENT_ID, model.getId()); + verify(documentFileService).createNewVersion(eq(DOCUMENT_ID), eq(USERNAME), any()); + // No summary provided -> the version summary is not touched. + verify(documentFileService, Mockito.never()).updateVersionSummary(any(), any(), any(), any()); + } + + @Test + public void addDocumentVersionWithSummaryStampsCurrentVersion() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "text/plain")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + when(documentFileService.getFileVersions(DOCUMENT_ID, USERNAME)).thenReturn(List.of(fileVersion())); + + documentMcpTool.addDocumentVersion(DOCUMENT_ID, "updated body", null, null, null, null, "reworded intro"); + + verify(documentFileService).createNewVersion(eq(DOCUMENT_ID), eq(USERNAME), any()); + verify(documentFileService).updateVersionSummary(eq(DOCUMENT_ID), eq("version-1"), eq("reworded intro"), eq(USERNAME)); + } + + @Test + public void addDocumentVersionNoSourceFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "text/plain")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.addDocumentVersion(DOCUMENT_ID, null, null, null, null, null, null)); + } + + @Test + public void addDocumentVersionOnFolderFails() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + + assertThrows(IllegalArgumentException.class, + () -> documentMcpTool.addDocumentVersion(FOLDER_ID, "body", null, null, null, null, null)); + } + + @Test + public void addDocumentVersionWithoutEditPermissionFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "text/plain")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(false); + + assertThrows(IllegalAccessException.class, + () -> documentMcpTool.addDocumentVersion(DOCUMENT_ID, "body", null, null, null, null, null)); + } + + @Test + public void createPublicLink() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + Date expiration = new Date(1893456000000L); + PublicDocumentAccess access = new PublicDocumentAccess(1L, DOCUMENT_ID, null, null, expiration); + access.setDecodedPassword("s3cretPass"); + when(publicDocumentAccessService.createPublicDocumentAccess(eq(USER_IDENTITY_ID), + eq(DOCUMENT_ID), + eq("s3cretPass"), + anyLong(), + anyBoolean())).thenReturn(access); + + DocumentPublicLinkModel model = documentMcpTool.createPublicLink(DOCUMENT_ID, "s3cretPass", expiration.getTime()); + + assertNotNull(model); + assertEquals("/portal/download-document/" + DOCUMENT_ID, model.url()); + assertEquals("s3cretPass", model.password()); + assertNotNull(model.expirationDate()); + } + + @Test + public void createPublicLinkRejectsInvalidPassword() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + + // Too short for the same password policy DocumentFileRest applies (min 9 chars). + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createPublicLink(DOCUMENT_ID, "short", null)); + verify(publicDocumentAccessService, never()).createPublicDocumentAccess(anyLong(), anyString(), anyString(), anyLong(), anyBoolean()); + } + + @Test + public void createPublicLinkWithoutEditPermissionFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(false); + + assertThrows(IllegalAccessException.class, () -> documentMcpTool.createPublicLink(DOCUMENT_ID, null, null)); + } + + @Test + public void createDocumentShortcutDefaultsConflictToKeepBoth() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + + documentMcpTool.createDocumentShortcut(DOCUMENT_ID, FOLDER_ID, null); + + // "keepBoth" is the only value the storage handleShortcutDocConflict honours; + // the old default "rename" fell through to ObjectAlreadyExists on a clash. + verify(documentFileService).createShortcut(eq(DOCUMENT_ID), eq("/documents/Projects"), eq(USERNAME), eq("keepBoth")); + } + + @Test + public void createDocumentShortcutBlankDestinationFails() { + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.createDocumentShortcut(DOCUMENT_ID, " ", null)); + } + + @Test + public void shareDocumentWithUser() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + org.exoplatform.social.core.identity.model.Identity bob = + new org.exoplatform.social.core.identity.model.Identity("200"); + when(identityManager.getOrCreateUserIdentity("bob")).thenReturn(bob); + + documentMcpTool.shareDocument(DOCUMENT_ID, "bob", null, null); + + // Must route through updatePermissions (grant + share), not the bare shareDocument + // that only copies the recipient's pre-existing permissions onto the symlink. + verify(documentFileService).updatePermissions(eq(DOCUMENT_ID), + argThat((NodePermission np) -> np.getToShare().containsKey(200L) + && np.getToNotify().containsKey(200L) + && np.getPermissions() + .stream() + .anyMatch(p -> p.getIdentity() + .getIdentityId() == 200L + && "read".equals(p.getPermission()))), + eq(USER_IDENTITY_ID)); + verify(documentFileService, never()).shareDocument(anyString(), anyLong(), anyLong(), anyBoolean()); + } + + @Test + public void shareDocumentWithSpace() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + Space space = new Space(); + space.setId(String.valueOf(SPACE_ID)); + space.setPrettyName("engineering"); + when(spaceService.getSpaceById(String.valueOf(SPACE_ID))).thenReturn(space); + org.exoplatform.social.core.identity.model.Identity spaceIdentity = + new org.exoplatform.social.core.identity.model.Identity("300"); + when(identityManager.getOrCreateSpaceIdentity("engineering")).thenReturn(spaceIdentity); + + documentMcpTool.shareDocument(DOCUMENT_ID, null, SPACE_ID, false); + + // notify=false -> the recipient is in toShare (granted access) but not in toNotify. + verify(documentFileService).updatePermissions(eq(DOCUMENT_ID), + argThat((NodePermission np) -> np.getToShare().containsKey(300L) + && np.getToNotify().isEmpty()), + eq(USER_IDENTITY_ID)); + } + + @Test + public void shareDocumentRequiresExactlyOneRecipient() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + + // neither recipient + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.shareDocument(DOCUMENT_ID, null, null, null)); + // both recipients + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.shareDocument(DOCUMENT_ID, "bob", SPACE_ID, null)); + } + + @Test + public void shareDocumentWithoutEditPermissionFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(false); + + assertThrows(IllegalAccessException.class, () -> documentMcpTool.shareDocument(DOCUMENT_ID, "bob", null, null)); + } + + @Test + public void shareDocumentDoesNotDowngradeExistingEditor() throws Exception { + // The recipient already collaborates on the document with EDIT (an "add_node" + // raw JCR permission). Sharing must not append a (recipient,"read") entry after + // the preserved (recipient,"edit"), which the storage's list-ordered write would + // otherwise use to silently DOWNGRADE the recipient to read. + org.exoplatform.social.core.identity.model.Identity bob = + new org.exoplatform.social.core.identity.model.Identity("200"); + bob.setProviderId("organization"); + bob.setRemoteId("bob"); + when(identityManager.getOrCreateUserIdentity("bob")).thenReturn(bob); + + org.exoplatform.social.core.identity.model.Identity bobInAcl = + new org.exoplatform.social.core.identity.model.Identity("200"); + bobInAcl.setProviderId("organization"); + bobInAcl.setRemoteId("bob"); + FileNode node = fileNode(DOCUMENT_ID, "application/pdf"); + node.setAcl(new NodePermission(true, + true, + false, + false, + List.of(new PermissionEntry(bobInAcl, "add_node", PermissionRole.ALL.name())), + new java.util.HashMap<>(), + new java.util.HashMap<>(), + null)); + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(node); + when(documentFileService.hasEditPermissionOnDocument(DOCUMENT_ID, USER_IDENTITY_ID)).thenReturn(true); + + documentMcpTool.shareDocument(DOCUMENT_ID, "bob", null, null); + + verify(documentFileService).updatePermissions(eq(DOCUMENT_ID), + argThat((NodePermission np) -> np.getPermissions() + .stream() + .anyMatch(p -> p.getIdentity() + .getIdentityId() == 200L + && "edit".equals(p.getPermission())) + && np.getPermissions() + .stream() + .noneMatch(p -> p.getIdentity() + .getIdentityId() == 200L + && "read".equals(p.getPermission()))), + eq(USER_IDENTITY_ID)); + } + + @Test + public void favoriteDocumentCreatesFavorite() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + + documentMcpTool.favoriteDocument(DOCUMENT_ID); + + // Ground truth: file favorites are stored under object type "file" (the JCR node + // id), which is what the app's favorite button / drawer read. Not "document". + verify(favoriteService).createFavorite(argThat(f -> "file".equals(f.getObjectType()) + && DOCUMENT_ID.equals(f.getObjectId()) + && f.getUserIdentityId() == USER_IDENTITY_ID)); + } + + @Test + public void favoriteFolderIsRejected() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + + // Folders have no favorite surface in the app; favoriting one must throw rather + // than writing a "folder" favorite that nothing ever shows. + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.favoriteDocument(FOLDER_ID)); + verify(favoriteService, never()).createFavorite(any()); + } + + @Test + public void unfavoriteFolderIsRejected() throws Exception { + when(documentFileService.getDocumentById(FOLDER_ID, USERNAME)).thenReturn(folderNode(FOLDER_ID)); + + assertThrows(IllegalArgumentException.class, () -> documentMcpTool.unfavoriteDocument(FOLDER_ID)); + verify(favoriteService, never()).deleteFavorite(any()); + } + + @Test + public void favoriteDocumentAlreadyFavoriteFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + doThrow(new ObjectAlreadyExistsException(DOCUMENT_ID)).when(favoriteService).createFavorite(any()); + + assertThrows(IllegalStateException.class, () -> documentMcpTool.favoriteDocument(DOCUMENT_ID)); + } + + @Test + public void unfavoriteDocumentDeletesFavorite() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + + documentMcpTool.unfavoriteDocument(DOCUMENT_ID); + + verify(favoriteService).deleteFavorite(any()); + } + + @Test + public void unfavoriteDocumentNotFavoriteFails() throws Exception { + when(documentFileService.getDocumentById(DOCUMENT_ID, USERNAME)).thenReturn(fileNode(DOCUMENT_ID, "application/pdf")); + doThrow(new ObjectNotFoundException("not a favorite")).when(favoriteService).deleteFavorite(any()); + + assertThrows(IllegalStateException.class, () -> documentMcpTool.unfavoriteDocument(DOCUMENT_ID)); + } + +} diff --git a/documents-storage-jcr/pom.xml b/documents-storage-jcr/pom.xml index 5e9ad7f982..589c2f13ec 100644 --- a/documents-storage-jcr/pom.xml +++ b/documents-storage-jcr/pom.xml @@ -3,7 +3,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT documents-storage-jcr eXo Documents - Storage JCR Implementation @@ -57,6 +57,13 @@ jar provided
+ + + org.exoplatform.ecms + ecms-core-services + ${addon.exo.ecms.version} + provided + diff --git a/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java b/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java index 484e15957b..a33ac7abfa 100644 --- a/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java +++ b/documents-storage-jcr/src/main/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorage.java @@ -54,6 +54,9 @@ import org.exoplatform.documents.storage.jcr.util.JCRDocumentsUtil; import org.exoplatform.documents.storage.jcr.util.NodeTypeConstants; import org.exoplatform.documents.storage.jcr.util.Utils; +import org.exoplatform.services.cms.documents.DocumentService; +import org.exoplatform.services.cms.documents.NewDocumentTemplate; +import org.exoplatform.services.cms.documents.NewDocumentTemplateProvider; import org.exoplatform.services.jcr.RepositoryService; import org.exoplatform.services.jcr.access.AccessControlEntry; import org.exoplatform.services.jcr.access.PermissionType; @@ -145,6 +148,8 @@ public class JCRDocumentFileStorage implements DocumentFileStorage { private final BulkStorageActionService bulkStorageActionService; + private final DocumentService documentService; + private final String EVENT_DOCUMENT_MOVED = "exo-document-moved"; private final String DEST_PATH = "destPath"; @@ -195,7 +200,8 @@ public JCRDocumentFileStorage(NodeHierarchyCreator nodeHierarchyCreator, UploadService uploadService, IdentityRegistry identityRegistry, ActivityManager activityManager, - BulkStorageActionService bulkStorageActionService) { + BulkStorageActionService bulkStorageActionService, + DocumentService documentService) { this.identityManager = identityManager; this.spaceService = spaceService; this.repositoryService = repositoryService; @@ -206,6 +212,7 @@ public JCRDocumentFileStorage(NodeHierarchyCreator nodeHierarchyCreator, this.identityRegistry = identityRegistry; this.activityManager = activityManager; this.bulkStorageActionService = bulkStorageActionService; + this.documentService = documentService; } @Override @@ -1045,6 +1052,97 @@ public AbstractNode createFolder(long ownerId, } } + @Override + public AbstractNode createDocumentFromTemplate(long ownerId, + String folderId, + String folderPath, + String title, + String documentType, + Identity aclIdentity) throws IllegalAccessException, + ObjectNotFoundException, + ObjectAlreadyExistsException { + if (!JCRDocumentsUtil.isValidDocumentTitle(title)) { + throw new IllegalArgumentException("document title is not valid"); + } + String extension = StringUtils.isBlank(documentType) ? "" : documentType.trim(); + if (extension.startsWith(".")) { + extension = extension.substring(1); + } + String username = aclIdentity.getUserId(); + SessionProvider sessionProvider = null; + try { + Node node = null; + ManageableRepository manageableRepository = repositoryService.getCurrentRepository(); + sessionProvider = getUserSessionProvider(repositoryService, aclIdentity); + Session session = sessionProvider.getSession(COLLABORATION, manageableRepository); + if (StringUtils.isBlank(folderId) && ownerId > 0) { + org.exoplatform.social.core.identity.model.Identity ownerIdentity = identityManager.getIdentity(String.valueOf(ownerId)); + node = getIdentityRootNode(spaceService, nodeHierarchyCreator, username, ownerIdentity, sessionProvider); + folderId = ((NodeImpl) node).getIdentifier(); + } else { + node = getNodeByIdentifier(session, folderId); + } + if (StringUtils.isNotBlank(folderPath)) { + try { + node = node.getNode(java.net.URLDecoder.decode(folderPath, StandardCharsets.UTF_8).replace("%", "%25")); + } catch (RepositoryException repositoryException) { + throw new ObjectNotFoundException("Folder with path : " + folderPath + " isn't found"); + } + } + Map nodeAccessList = countNodeAccessList(node, aclIdentity); + String canEdit = "canEdit"; + if (nodeAccessList.containsKey(canEdit) && !nodeAccessList.get(canEdit).booleanValue()) { + throw new IllegalAccessException("Permission to add document is missing"); + } + // no need to this object later make it eligible to the garbage collactor + nodeAccessList = null; + // Resolve the template from the installed document editor add-on providers + NewDocumentTemplate template = null; + List availableExtensions = new ArrayList<>(); + for (NewDocumentTemplateProvider provider : documentService.getNewDocumentTemplateProviders()) { + if (provider == null || provider.getTemplates() == null) { + continue; + } + for (NewDocumentTemplate candidate : provider.getTemplates()) { + if (candidate == null || candidate.getExtension() == null) { + continue; + } + availableExtensions.add(candidate.getExtension()); + // onlyoffice stores extensions with a leading dot (".docx"); the + // caller-provided extension is already dot-stripped above, so strip + // the candidate's dot too before comparing. + if (StringUtils.removeStart(candidate.getExtension(), ".").equalsIgnoreCase(extension)) { + template = candidate; + break; + } + } + if (template != null) { + break; + } + } + if (template == null) { + throw new ObjectNotFoundException("No document template found for extension '" + extension + + "'. Available template extensions: " + availableExtensions); + } + Node createdNode = documentService.createDocumentFromTemplate(node, title, template); + // defensive save (ecms saves internally, but the parent may still hold pending state) + node.save(); + return toFileNode(identityManager, aclIdentity, createdNode, "", spaceService); + } catch (IllegalAccessException exception) { + throw new IllegalAccessException(exception.getMessage()); + } catch (ObjectNotFoundException e) { + throw new ObjectNotFoundException(e.getMessage()); + } catch (javax.jcr.ItemExistsException e) { + throw new ObjectAlreadyExistsException("A document named '" + title + "' already exists in the target folder"); + } catch (Exception e) { + throw new IllegalStateException("Error creating document from template in folder '" + folderId + "'", e); + } finally { + if (sessionProvider != null) { + sessionProvider.close(); + } + } + } + @Override public String getNewName(long ownerId, String folderId, @@ -2643,18 +2741,30 @@ public FileVersion createNewVersion(String nodeId, String aclIdentity, InputStre ManageableRepository manageableRepository = repositoryService.getCurrentRepository(); Session session = getUserSessionProvider(repositoryService, identity).getSession(COLLABORATION, manageableRepository); Node node = session.getNodeByUUID(nodeId); - if (node.isNodeType(NodeTypeConstants.MIX_VERSIONABLE) && node.getNode(NodeTypeConstants.JCR_CONTENT) != null) { - Node contentNode = node.getNode(NodeTypeConstants.JCR_CONTENT); - if (contentNode.hasProperty(NodeTypeConstants.JCR_DATA)) { - contentNode.setProperty(NodeTypeConstants.JCR_DATA, newContent); - contentNode.setProperty(NodeTypeConstants.JCR_LAST_MODIFIED, Calendar.getInstance()); + if (node.hasNode(NodeTypeConstants.JCR_CONTENT)) { + // Make the file versionable if it isn't yet: otherwise the whole write was + // silently skipped and no version was ever created (mirror + // ActionThread.createFile which adds mix:versionable on file creation). + if (!node.isNodeType(NodeTypeConstants.MIX_VERSIONABLE) && node.canAddMixin(NodeTypeConstants.MIX_VERSIONABLE)) { + node.addMixin(NodeTypeConstants.MIX_VERSIONABLE); + node.save(); } - if (node.isNodeType(NodeTypeConstants.EXO_MODIFY)) { - node.setProperty(NodeTypeConstants.EXO_DATE_MODIFIED, Calendar.getInstance()); - node.setProperty(NodeTypeConstants.EXO_LAST_MODIFIED_DATE, Calendar.getInstance()); + if (node.isNodeType(NodeTypeConstants.MIX_VERSIONABLE)) { + if (!node.isCheckedOut()) { + node.checkout(); + } + Node contentNode = node.getNode(NodeTypeConstants.JCR_CONTENT); + if (contentNode.hasProperty(NodeTypeConstants.JCR_DATA)) { + contentNode.setProperty(NodeTypeConstants.JCR_DATA, newContent); + contentNode.setProperty(NodeTypeConstants.JCR_LAST_MODIFIED, Calendar.getInstance()); + } + if (node.isNodeType(NodeTypeConstants.EXO_MODIFY)) { + node.setProperty(NodeTypeConstants.EXO_DATE_MODIFIED, Calendar.getInstance()); + node.setProperty(NodeTypeConstants.EXO_LAST_MODIFIED_DATE, Calendar.getInstance()); + } + node.save(); + VersionHistoryUtils.createVersion(node); } - node.save(); - VersionHistoryUtils.createVersion(node); } } catch (Exception e) { throw new IllegalStateException("Error while creating new version", e); diff --git a/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java b/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java index bb796a07d9..453503a963 100644 --- a/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java +++ b/documents-storage-jcr/src/test/java/org/exoplatform/documents/storage/jcr/JCRDocumentFileStorageTest.java @@ -51,6 +51,8 @@ import org.exoplatform.services.jcr.impl.core.SessionImpl; import org.exoplatform.services.jcr.impl.core.WorkspaceImpl; import org.exoplatform.services.jcr.impl.core.query.QueryImpl; +import org.exoplatform.services.cms.documents.NewDocumentTemplate; +import org.exoplatform.services.cms.documents.NewDocumentTemplateProvider; import org.exoplatform.services.listener.ListenerService; import org.exoplatform.services.security.IdentityRegistry; import org.exoplatform.social.core.activity.model.ExoSocialActivity; @@ -112,6 +114,9 @@ public class JCRDocumentFileStorageTest { @Mock private BulkStorageActionService bulkStorageActionService; + @Mock + private org.exoplatform.services.cms.documents.DocumentService documentService; + private JCRDocumentFileStorage jcrDocumentFileStorage; @@ -135,7 +140,8 @@ public void setUp() { uploadService, identityRegistry, activityManager, - bulkStorageActionService); + bulkStorageActionService, + documentService); } @Test @@ -227,6 +233,51 @@ public void shareDocument() throws Exception { verify(sessionProvider, atLeast(1)).close(); } + @Test + public void createDocumentFromTemplateMatchesDottedExtension() throws Exception { + // Regression: onlyoffice stores the template extension WITH a leading dot + // (".docx"). The caller passes "docx" (dot-stripped). The match must normalize + // the dot on both sides, otherwise ".docx".equalsIgnoreCase("docx") is always + // false and every call wrongly throws ObjectNotFoundException. + org.exoplatform.services.security.Identity aclIdentity = new org.exoplatform.services.security.Identity("john"); + SessionProvider sessionProvider = mock(SessionProvider.class); + JCR_DOCUMENTS_UTIL.when(() -> JCRDocumentsUtil.getUserSessionProvider(repositoryService, aclIdentity)) + .thenReturn(sessionProvider); + ManageableRepository manageableRepository = mock(ManageableRepository.class); + when(repositoryService.getCurrentRepository()).thenReturn(manageableRepository); + Session session = mock(Session.class); + when(sessionProvider.getSession("collaboration", manageableRepository)).thenReturn(session); + + ExtendedNode folderNode = mock(ExtendedNode.class); + JCR_DOCUMENTS_UTIL.when(() -> JCRDocumentsUtil.getNodeByIdentifier(session, "1")).thenReturn(folderNode); + JCR_DOCUMENTS_UTIL.when(() -> JCRDocumentsUtil.isValidDocumentTitle("Q3 report.docx")).thenReturn(true); + // Grant the user edit access so countNodeAccessList reports canEdit=true. + AccessControlEntry editEntry = new AccessControlEntry("john", PermissionType.ADD_NODE); + when(folderNode.getACL()).thenReturn(new AccessControlList("john", Arrays.asList(editEntry))); + + // A template whose extension carries the leading dot, as onlyoffice stores it. + NewDocumentTemplate template = mock(NewDocumentTemplate.class); + when(template.getExtension()).thenReturn(".docx"); + NewDocumentTemplateProvider provider = mock(NewDocumentTemplateProvider.class); + when(provider.getTemplates()).thenReturn(Arrays.asList(template)); + when(documentService.getNewDocumentTemplateProviders()).thenReturn(Arrays.asList(provider)); + + Node createdNode = mock(Node.class); + when(documentService.createDocumentFromTemplate(folderNode, "Q3 report.docx", template)).thenReturn(createdNode); + FileNode expected = new FileNode(); + expected.setId("created-1"); + JCR_DOCUMENTS_UTIL.when(() -> JCRDocumentsUtil.toFileNode(identityManager, aclIdentity, createdNode, "", spaceService)) + .thenReturn(expected); + + AbstractNode result = jcrDocumentFileStorage.createDocumentFromTemplate(0L, "1", null, "Q3 report.docx", "docx", aclIdentity); + + // The dotted template must be matched (no ObjectNotFoundException) and used to + // create the document. + assertNotNull(result); + assertEquals("created-1", result.getId()); + verify(documentService).createDocumentFromTemplate(folderNode, "Q3 report.docx", template); + } + @Test public void duplicateDocument() throws Exception { Session systemSession = mock(Session.class); @@ -1267,6 +1318,7 @@ public void createNewVersion() throws Exception { Node contentNode = mock(Node.class); Version version = mock(Version.class); when(session.getNodeByUUID("123")).thenReturn(node); + when(node.hasNode(NodeTypeConstants.JCR_CONTENT)).thenReturn(true); when(node.isNodeType(NodeTypeConstants.MIX_VERSIONABLE)).thenReturn(true); when(node.getNode(NodeTypeConstants.JCR_CONTENT)).thenReturn(contentNode); when(contentNode.hasProperty(NodeTypeConstants.JCR_DATA)).thenReturn(true); @@ -1281,6 +1333,36 @@ public void createNewVersion() throws Exception { } + @Test + public void createNewVersionAddsVersionableMixinWhenMissing() throws Exception { + // A file that isn't yet mix:versionable must be made versionable (and checked + // out) before writing, otherwise the whole version write was silently skipped. + org.exoplatform.services.security.Identity identity = mock(org.exoplatform.services.security.Identity.class); + when(identityRegistry.getIdentity("user")).thenReturn(identity); + ManageableRepository manageableRepository = mock(ManageableRepository.class); + when(repositoryService.getCurrentRepository()).thenReturn(manageableRepository); + Session session = mock(Session.class); + SessionProvider sessionProvider = mock(SessionProvider.class); + JCR_DOCUMENTS_UTIL.when(() -> JCRDocumentsUtil.getUserSessionProvider(repositoryService, identity)).thenReturn(sessionProvider); + when(sessionProvider.getSession("collaboration", manageableRepository)).thenReturn(session); + Node node = mock(Node.class); + Node contentNode = mock(Node.class); + when(session.getNodeByUUID("123")).thenReturn(node); + when(node.hasNode(NodeTypeConstants.JCR_CONTENT)).thenReturn(true); + // Not versionable initially, then versionable after the mixin is added. + when(node.isNodeType(NodeTypeConstants.MIX_VERSIONABLE)).thenReturn(false, true); + when(node.canAddMixin(NodeTypeConstants.MIX_VERSIONABLE)).thenReturn(true); + when(node.getNode(NodeTypeConstants.JCR_CONTENT)).thenReturn(contentNode); + when(contentNode.hasProperty(NodeTypeConstants.JCR_DATA)).thenReturn(true); + when(node.isCheckedOut()).thenReturn(true); + when(node.getSession()).thenReturn(session); + + jcrDocumentFileStorage.createNewVersion("123", "user", new ByteArrayInputStream("test".getBytes())); + + verify(node, times(1)).addMixin(NodeTypeConstants.MIX_VERSIONABLE); + VERSION_HISTORY_UTILS.verify(() -> VersionHistoryUtils.createVersion(node), times(1)); + } + @Test public void moveDocument() throws Exception { org.exoplatform.services.security.Identity identity = mock(org.exoplatform.services.security.Identity.class); diff --git a/documents-webapp/pom.xml b/documents-webapp/pom.xml index adefbda501..be44e4d1c3 100644 --- a/documents-webapp/pom.xml +++ b/documents-webapp/pom.xml @@ -3,7 +3,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT documents-webapp war diff --git a/documents-webdav-webapp/pom.xml b/documents-webdav-webapp/pom.xml index 326384f218..566622e6dc 100644 --- a/documents-webdav-webapp/pom.xml +++ b/documents-webdav-webapp/pom.xml @@ -3,7 +3,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT documents-webdav-webapp war diff --git a/pom.xml b/pom.xml index 767e747258..647716532d 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ org.exoplatform.documents documents-parent - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT pom eXo Documents - Parent POM @@ -28,7 +28,9 @@ - 7.3.x-SNAPSHOT + 7.3.x-ai-contribution-SNAPSHOT + + 7.3.x-ai-contribution-SNAPSHOT 2.11.5 exoplatform