/*! * @license * Alfresco Example Content Application * * Copyright (C) 2005 - 2018 Alfresco Software Limited * * This file is part of the Alfresco Example Content Application. * If the software was purchased under a paid Alfresco license, the terms of * the paid license agreement will prevail. Otherwise, the software is * provided under the following open source license terms: * * The Alfresco Example Content Application is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * The Alfresco Example Content Application 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 Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with Alfresco. If not, see . */ import { Injectable } from '@angular/core'; import { MatDialog } from '@angular/material'; import { Observable, Subject } from 'rxjs/Rx'; import { AlfrescoApiService, ContentService, NodesApiService, DataColumn, TranslationService } from '@alfresco/adf-core'; import { DocumentListService, ContentNodeSelectorComponent, ContentNodeSelectorComponentData } from '@alfresco/adf-content-services'; import { MinimalNodeEntity, MinimalNodeEntryEntity, SitePaging } from 'alfresco-js-api'; @Injectable() export class NodeActionsService { static SNACK_MESSAGE_DURATION_WITH_UNDO = 10000; static SNACK_MESSAGE_DURATION = 3000; contentCopied: Subject = new Subject(); contentMoved: Subject = new Subject(); moveDeletedEntries: any[] = []; isSitesDestinationAvailable = false; constructor(private contentService: ContentService, private dialog: MatDialog, private documentListService: DocumentListService, private apiService: AlfrescoApiService, private nodesApi: NodesApiService, private translation: TranslationService) {} /** * Copy node list * * @param contentEntities nodes to copy * @param permission permission which is needed to apply the action */ public copyNodes(contentEntities: any[], permission?: string): Subject { return this.doBatchOperation('copy', contentEntities, permission); } /** * Move node list * * @param contentEntities nodes to move * @param permission permission which is needed to apply the action */ public moveNodes(contentEntities: any[], permission?: string): Subject { return this.doBatchOperation('move', contentEntities, permission); } /** * General method for performing the given operation (copy|move) to multiple nodes * * @param action the action to perform (copy|move) * @param contentEntities the contentEntities which have to have the action performed on * @param permission permission which is needed to apply the action */ doBatchOperation(action: string, contentEntities: any[], permission?: string): Subject { const observable: Subject = new Subject(); if (!this.isEntryEntitiesArray(contentEntities)) { observable.error(new Error(JSON.stringify({error: {statusCode: 400}}))); } else if (this.checkPermission(action, contentEntities, permission)) { const destinationSelection = this.getContentNodeSelection(action, contentEntities); destinationSelection.subscribe((selections: MinimalNodeEntryEntity[]) => { const contentEntry = contentEntities[0].entry ; // Check if there's nodeId for Shared Files const contentEntryId = contentEntry.nodeId || contentEntry.id; const type = contentEntry.isFolder ? 'folder' : 'content'; const batch = []; // consider only first item in the selection const selection = selections[0]; let action$: Observable; if (action === 'move' && contentEntities.length === 1 && type === 'content') { action$ = this.documentListService[`${action}Node`].call(this.documentListService, contentEntryId, selection.id); action$ = action$.toArray(); } else { contentEntities.forEach((node) => { batch.push(this[`${action}NodeAction`](node.entry, selection.id)); }); action$ = Observable.zip(...batch); } action$ .subscribe( (newContent) => { observable.next(`OPERATION.SUCCES.${type.toUpperCase()}.${action.toUpperCase()}`); const processedData = this.processResponse(newContent); if (action === 'copy') { this.contentCopied.next(processedData.succeeded); } else if (action === 'move') { this.contentMoved.next(processedData); } }, observable.error.bind(observable) ); }); } else { observable.error(new Error(JSON.stringify({error: {statusCode: 403}}))); } return observable; } isEntryEntitiesArray(contentEntities: any[]): boolean { if (contentEntities && contentEntities.length) { const nonEntryNode = contentEntities.find(node => (!node || !node.entry || !(node.entry.nodeId || node.entry.id))); return !nonEntryNode; } return false; } checkPermission(action: string, contentEntities: any[], permission?: string) { const notAllowedNode = contentEntities.find(node => !this.isActionAllowed(action, node.entry, permission)); return !notAllowedNode; } getEntryParentId(nodeEntry: MinimalNodeEntryEntity) { let entryParentId = ''; if (nodeEntry.parentId) { entryParentId = nodeEntry.parentId; } else if (nodeEntry.path && nodeEntry.path.elements && nodeEntry.path.elements.length) { entryParentId = nodeEntry.path.elements[nodeEntry.path.elements.length - 1].id; } return entryParentId; } getContentNodeSelection(action: string, contentEntities: MinimalNodeEntity[]): Subject { const currentParentFolderId = this.getEntryParentId(contentEntities[0].entry); const customDropdown: SitePaging = { list: { entries: [ { entry: { guid: '-my-', title: 'APP.BROWSE.PERSONAL.SIDENAV_LINK.LABEL' } }, { entry: { guid: '-mysites-', title: 'APP.BROWSE.LIBRARIES.SIDENAV_LINK.LABEL' } } ] } }; const title = this.getTitleTranslation(action, contentEntities); this.isSitesDestinationAvailable = false; const data: ContentNodeSelectorComponentData = { title: title, currentFolderId: currentParentFolderId, actionName: action, dropdownHideMyFiles: true, dropdownSiteList: customDropdown, rowFilter: this.rowFilter.bind(this), imageResolver: this.imageResolver.bind(this), isSelectionValid: this.canCopyMoveInsideIt.bind(this), breadcrumbTransform: this.customizeBreadcrumb.bind(this), select: new Subject() }; /*const matDialogRef =*/ this.dialog.open(ContentNodeSelectorComponent, { data, panelClass: 'adf-content-node-selector-dialog', width: '630px' }); // todo: add back the fix for [ACA-1054]: /* const destinationPicker = matDialogRef.componentInstance; const initialSiteChanged = destinationPicker.siteChanged; destinationPicker.siteChanged = (chosenSite) => { initialSiteChanged.call(destinationPicker, chosenSite); if (chosenSite.guid === '-mysites-') { destinationPicker.documentList.data.setSorting(new DataSorting('title', 'asc')); } else { destinationPicker.documentList.data.setSorting(new DataSorting('name', 'asc')); } };*/ data.select.subscribe({ complete: this.close.bind(this) }); return data.select; } getTitleTranslation(action: string, nodes: MinimalNodeEntity[] = []): string { let keyPrefix = 'ITEMS'; let name = ''; if (nodes.length === 1 && nodes[0].entry.name) { name = nodes[0].entry.name; keyPrefix = 'ITEM'; } const number = nodes.length; return this.translation.instant(`NODE_SELECTOR.${action.toUpperCase()}_${keyPrefix}`, {name, number}); } private canCopyMoveInsideIt(entry: MinimalNodeEntryEntity): boolean { return this.hasEntityCreatePermission(entry) && !this.isSite(entry); } private hasEntityCreatePermission(entry: MinimalNodeEntryEntity): boolean { return this.contentService.hasPermission(entry, 'create'); } private isSite(entry) { return !!entry.guid || entry.nodeType === 'st:site' || entry.nodeType === 'st:sites'; } close() { this.dialog.closeAll(); } // todo: review this approach once 5.2.3 is out private customizeBreadcrumb(node: MinimalNodeEntryEntity) { if (node && node.path && node.path.elements) { const elements = node.path.elements; if (elements.length > 1) { if (elements[1].name === 'User Homes') { elements.splice(0, 2); // make sure first item is 'Personal Files' if (elements[0]) { elements[0].name = this.translation.instant('APP.BROWSE.PERSONAL.TITLE'); elements[0].id = '-my-'; } else { node.name = this.translation.instant('APP.BROWSE.PERSONAL.TITLE'); } } else if (elements[1].name === 'Sites') { this.normalizeSitePath(node); } } else if (elements.length === 1) { if (node.name === 'Sites') { node.name = this.translation.instant('APP.BROWSE.LIBRARIES.TITLE'); elements.splice(0, 1); } } } else if (node === null && this.isSitesDestinationAvailable) { node = { name: this.translation.instant('APP.BROWSE.LIBRARIES.TITLE'), path: { elements: [] } }; } return node; } // todo: review this approach once 5.2.3 is out private normalizeSitePath(node: MinimalNodeEntryEntity) { const elements = node.path.elements; // remove 'Company Home' elements.splice(0, 1); // replace first item with 'File Libraries' elements[0].name = this.translation.instant('APP.BROWSE.LIBRARIES.TITLE'); // elements[0].id = '-mysites-'; // commented this until navigation on custom sources is enabled on document-list if (this.isSiteContainer(node)) { // rename 'documentLibrary' entry to the target site display name // clicking on the breadcrumb entry loads the site content node.name = elements[1].name; // remove the site entry elements.splice(1, 1); } else { // remove 'documentLibrary' in the middle of the path const docLib = elements.findIndex(el => el.name === 'documentLibrary'); if (docLib > -1) { elements.splice(docLib, 1); } } } isSiteContainer(node: MinimalNodeEntryEntity): boolean { if (node && node.aspectNames && node.aspectNames.length > 0) { return node.aspectNames.indexOf('st:siteContainer') >= 0; } return false; } copyNodeAction(nodeEntry, selectionId): Observable { if (nodeEntry.isFolder) { return this.copyFolderAction(nodeEntry, selectionId); } else { // any other type is treated as 'content' return this.copyContentAction(nodeEntry, selectionId); } } copyContentAction(contentEntry, selectionId, oldName?): Observable { const _oldName = oldName || contentEntry.name; // Check if there's nodeId for Shared Files const contentEntryId = contentEntry.nodeId || contentEntry.id; // use local method until new name parameter is added on ADF copyNode return this.copyNode(contentEntryId, selectionId, _oldName) .catch((err) => { let errStatusCode; try { const {error: {statusCode}} = JSON.parse(err.message); errStatusCode = statusCode; } catch (e) { // } if (errStatusCode && errStatusCode === 409) { return this.copyContentAction(contentEntry, selectionId, this.getNewNameFrom(_oldName, contentEntry.name)); } else { // do not throw error, to be able to show message in case of partial copy of files return Observable.of(err || 'Server error'); } }); } copyFolderAction(contentEntry, selectionId): Observable { // Check if there's nodeId for Shared Files const contentEntryId = contentEntry.nodeId || contentEntry.id; let $destinationFolder: Observable; let $childrenToCopy: Observable; let newDestinationFolder; return this.copyNode(contentEntryId, selectionId, contentEntry.name) .catch((err) => { let errStatusCode; try { const {error: {statusCode}} = JSON.parse(err.message); errStatusCode = statusCode; } catch (e) { // } if (errStatusCode && errStatusCode === 409) { $destinationFolder = this.getChildByName(selectionId, contentEntry.name); $childrenToCopy = this.getNodeChildren(contentEntryId); return $destinationFolder .flatMap((destination) => { newDestinationFolder = destination; return $childrenToCopy; }) .flatMap((nodesToCopy) => { const batch = []; nodesToCopy.list.entries.forEach((node) => { if (node.entry.isFolder) { batch.push(this.copyFolderAction(node.entry, newDestinationFolder.entry.id)); } else { batch.push(this.copyContentAction(node.entry, newDestinationFolder.entry.id)); } }); if (!batch.length) { return Observable.of({}); } return Observable.zip(...batch); }); } else { // do not throw error, to be able to show message in case of partial copy of files return Observable.of(err || 'Server error'); } }); } moveNodeAction(nodeEntry, selectionId): Observable { this.moveDeletedEntries = []; if (nodeEntry.isFolder) { const initialParentId = nodeEntry.parentId; return this.moveFolderAction(nodeEntry, selectionId) .flatMap((newContent) => { // take no extra action, if folder is moved to the same location if (initialParentId === selectionId) { return Observable.of(newContent); } const flattenResponse = this.flatten(newContent); const processedData = this.processResponse(flattenResponse); // else, check if moving this nodeEntry succeeded for ALL of its nodes if (processedData.failed.length === 0) { // check if folder still exists on location return this.getChildByName(initialParentId, nodeEntry.name) .flatMap((folderOnInitialLocation) => { if (folderOnInitialLocation) { // Check if there's nodeId for Shared Files const nodeEntryId = nodeEntry.nodeId || nodeEntry.id; // delete it from location return this.nodesApi.deleteNode(nodeEntryId) .flatMap(() => { this.moveDeletedEntries.push(nodeEntry); return Observable.of(newContent); }); } return Observable.of(newContent); }); } return Observable.of(newContent); }); } else { // any other type is treated as 'content' return this.moveContentAction(nodeEntry, selectionId); } } moveFolderAction(contentEntry, selectionId): Observable { // Check if there's nodeId for Shared Files const contentEntryId = contentEntry.nodeId || contentEntry.id; const initialParentId = this.getEntryParentId(contentEntry); let $destinationFolder: Observable; let $childrenToMove: Observable; let newDestinationFolder; return this.documentListService.moveNode(contentEntryId, selectionId) .map((itemMoved) => { return { itemMoved, initialParentId }; }) .catch((err) => { let errStatusCode; try { const {error: {statusCode}} = JSON.parse(err.message); errStatusCode = statusCode; } catch (e) { // } if (errStatusCode && errStatusCode === 409) { $destinationFolder = this.getChildByName(selectionId, contentEntry.name); $childrenToMove = this.getNodeChildren(contentEntryId); return $destinationFolder .flatMap((destination) => { newDestinationFolder = destination; return $childrenToMove; }) .flatMap((childrenToMove) => { const batch = []; childrenToMove.list.entries.forEach((node) => { if (node.entry.isFolder) { batch.push(this.moveFolderAction(node.entry, newDestinationFolder.entry.id)); } else { batch.push(this.moveContentAction(node.entry, newDestinationFolder.entry.id)); } }); if (!batch.length) { return Observable.of(batch); } return Observable.zip(...batch); }); } else { // do not throw error, to be able to show message in case of partial move of files return Observable.of(err); } }); } moveContentAction(contentEntry, selectionId) { // Check if there's nodeId for Shared Files const contentEntryId = contentEntry.nodeId || contentEntry.id; const initialParentId = this.getEntryParentId(contentEntry); return this.documentListService.moveNode(contentEntryId, selectionId) .map((itemMoved) => { return { itemMoved, initialParentId }; }) .catch((err) => { // do not throw error, to be able to show message in case of partial move of files return Observable.of(err); }); } getChildByName(parentId, name) { const matchedNodes: Subject = new Subject(); this.getNodeChildren(parentId).subscribe( (childrenNodes: any) => { const result = childrenNodes.list.entries.find(node => (node.entry.name === name)); if (result) { matchedNodes.next(result); } else { matchedNodes.next(null); } }, (err) => { return Observable.of(err || 'Server error'); }); return matchedNodes; } private isActionAllowed(action: string, node: MinimalNodeEntryEntity, permission?: string): boolean { if (action === 'copy') { return true; } return this.contentService.hasPermission(node, permission); } // todo: review once 1.10-beta6 is out private rowFilter(row: /*ShareDataRow*/ any): boolean { const node: MinimalNodeEntryEntity = row.node.entry; this.isSitesDestinationAvailable = !!node['guid']; return (!node.isFile && (node.nodeType !== 'app:folderlink')); } // todo: review once 1.10-beta6 is out private imageResolver(row: /*ShareDataRow*/ any, col: DataColumn): string | null { const entry: MinimalNodeEntryEntity = row.node.entry; if (!this.contentService.hasPermission(entry, 'update')) { return this.documentListService.getMimeTypeIcon('disable/folder'); } return null; } public getNewNameFrom(name: string, baseName?: string) { const extensionMatch = name.match(/\.[^/.]+$/); // remove extension in case there is one const fileExtension = extensionMatch ? extensionMatch[0] : ''; let extensionFree = extensionMatch ? name.slice(0, extensionMatch.index) : name; let prefixNumber = 1; let baseExtensionFree; if (baseName) { const baseExtensionMatch = baseName.match(/\.[^/.]+$/); // remove extension in case there is one baseExtensionFree = baseExtensionMatch ? baseName.slice(0, baseExtensionMatch.index) : baseName; } if (!baseExtensionFree || baseExtensionFree !== extensionFree) { // check if name already has integer appended on end: const oldPrefix = extensionFree.match('-[0-9]+$'); if (oldPrefix) { // if so, try to get the number at the end const oldPrefixNumber = parseInt(oldPrefix[0].slice(1), 10); if (oldPrefixNumber.toString() === oldPrefix[0].slice(1)) { extensionFree = extensionFree.slice(0, oldPrefix.index); prefixNumber = oldPrefixNumber + 1; } } } return extensionFree + '-' + prefixNumber + fileExtension; } /** * Get children nodes of given parent node * * @param nodeId The id of the parent node * @param params optional parameters */ getNodeChildren(nodeId: string, params?) { return Observable.fromPromise(this.apiService.getInstance().nodes.getNodeChildren(nodeId, params)); } // Copied from ADF document-list.service, and added the name parameter /** * Copy a node to destination node * * @param nodeId The id of the node to be copied * @param targetParentId The id of the folder-node where the node have to be copied to * @param name The new name for the copy that would be added on the destination folder */ copyNode(nodeId: string, targetParentId: string, name?: string) { return Observable.fromPromise(this.apiService.getInstance().nodes.copyNode(nodeId, {targetParentId, name})); } public flatten(nDimArray) { if (!Array.isArray(nDimArray)) { return nDimArray; } const nodeQueue = nDimArray.slice(0); const resultingArray = []; do { nodeQueue.forEach( (node) => { if (Array.isArray(node)) { nodeQueue.push(...node); } else { resultingArray.push(node); } const nodeIndex = nodeQueue.indexOf(node); nodeQueue.splice(nodeIndex, 1); } ); } while (nodeQueue.length); return resultingArray; } processResponse(data: any): any { const moveStatus = { succeeded: [], failed: [], partiallySucceeded: [] }; if (Array.isArray(data)) { return data.reduce( (acc, next) => { if (next instanceof Error) { acc.failed.push(next); } else if (Array.isArray(next)) { // if content of a folder was moved const folderMoveResponseData = this.flatten(next); const foundError = folderMoveResponseData.find(node => node instanceof Error); // data might contain also items of form: { itemMoved, initialParentId } const foundEntry = folderMoveResponseData.find( node => (node.itemMoved && node.itemMoved.entry) || (node && node.entry) ); if (!foundError) { // consider success if NONE of the items from the folder move response is an error acc.succeeded.push(next); } else if (!foundEntry) { // consider failed if NONE of the items has an entry acc.failed.push(next); } else { // partially move folder acc.partiallySucceeded.push(next); } } else { acc.succeeded.push(next); } return acc; }, moveStatus ); } else { if ((data.itemMoved && data.itemMoved.entry) || (data && data.entry)) { moveStatus.succeeded.push(data); } else { moveStatus.failed.push(data); } return moveStatus; } } }