package com.psddev.cms.tool.page; import com.google.common.collect.ImmutableMap; import com.psddev.cms.db.Content; import com.psddev.cms.db.Site; import com.psddev.cms.db.ToolUi; import com.psddev.cms.tool.PageServlet; import com.psddev.cms.tool.SearchResultSelection; import com.psddev.cms.tool.ToolPageContext; import com.psddev.cms.tool.Search; import com.psddev.dari.db.ObjectType; import com.psddev.dari.db.Query; import com.psddev.dari.db.State; import com.psddev.dari.util.ObjectUtils; import com.psddev.dari.util.RoutingFilter; import com.psddev.dari.util.StringUtils; import com.psddev.dari.util.TypeReference; import com.psddev.dari.util.UrlBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.jsp.PageContext; import java.io.Closeable; import java.io.IOException; import java.io.Writer; import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Stream; @RoutingFilter.Path(application = "cms", value = BulkArchive.PATH) public class BulkArchive extends PageServlet { public static final String PATH = "bulkArchive"; private static final String TARGET = "bulkArchive"; private static final String DEFAULT_ERROR_MESSAGE = "An error has occurred."; private static final Logger LOGGER = LoggerFactory.getLogger(BulkArchive.class); @Override protected String getPermissionId() { return null; } @Override protected void doService(ToolPageContext page) throws IOException, ServletException { execute(new Context(page)); } public void execute(ToolPageContext page, Search search, SearchResultSelection selection, WidgetState widgetState, Action action) throws IOException, ServletException { Context context = new Context(page, search, selection); context.setWidgetState(widgetState); context.setAction(action); execute(context); } public static enum Action { RESTORE, ARCHIVE } private void execute(Context page) throws IOException, ServletException { Action action = page.getAction(); if (action == null) { throw new IllegalArgumentException("action is required"); } long availableCount = Action.RESTORE.equals(action) ? page.getAvailableRestoreCount() : page.getAvailableArchiveCount(); if (availableCount == 0) { return; } String actionIconClass = "icon-action-" + (Action.RESTORE.equals(action) ? "restore" : "trash"); switch (page.getWidgetState()) { case CONFIRM: page.writeStart("form", "method", "post", "target", TARGET, "action", new UrlBuilder(page.getRequest()).absolutePath(page.cmsUrl(PATH)).currentParameters().parameter(Context.WIDGET_STATE_PARAMETER, WidgetState.BUTTON)); page.writeStart("p"); page.writeHtml(page.localize( BulkArchive.class, ImmutableMap.of( "action", action.name().toLowerCase(), "count", availableCount), "message.confirm")); page.writeEnd(); page.writeStart("button", "class", actionIconClass); page.writeHtml(page.localize( BulkArchive.class, ImmutableMap.of("name", action.name()), "action.confirm")); page.writeEnd(); page.writeEnd(); break; case BUTTON: default: if (page.isFormPost()) { Iterator queryIterator = page.itemsQuery().noCache().iterable(0).iterator(); Map<String, Integer> messageMap = new LinkedHashMap<>(); int successCount = 0; try { while (queryIterator.hasNext()) { Object item = queryIterator.next(); try { if (Action.RESTORE.equals(action)) { if (page.isItemActionable(item, false)) { page.restore(item); successCount ++; } } else if (Action.ARCHIVE.equals(action)) { if (page.isItemActionable(item, true)) { page.trash(item); successCount ++; } } } catch (Exception e) { page.getErrors().add(e); } } } finally { if (queryIterator instanceof Closeable) { ((Closeable) queryIterator).close(); } } // Build user notification String from errors' localized messages. // Stack repeat errors and track the counts of each type. if (page.getErrors().size() > 0) { for (Throwable throwable : page.getErrors()) { String message = throwable.getLocalizedMessage() != null ? throwable.getLocalizedMessage() : DEFAULT_ERROR_MESSAGE; int messageCount = ObjectUtils.to(int.class, messageMap.get(message)); messageMap.put(message, messageCount + 1); LOGGER.warn("BulkArchive Error: ", throwable); } } List<String> errorMessages = new ArrayList<>(); // Display error notifications. if (messageMap.size() > 0) { for (Map.Entry<String, Integer> entry : messageMap.entrySet()) { errorMessages.add(entry.getKey() + (entry.getValue() > 1 ? " (" + entry.getValue() + ")" : "")); } page.writeStart("div", "class", "message message-error"); page.writeHtml(StringUtils.join(errorMessages, "<br>")); page.writeEnd(); // end .message-error } // Display success notification. if (successCount > 0) { page.writeStart("div", "class", "message message-success"); page.writeHtml(page.localize( BulkArchive.class, ImmutableMap.of("count", successCount), Action.RESTORE.equals(action) ? "message.restored" : "message.archived")); String returnUrl = page.param(String.class, "returnUrl"); if (!ObjectUtils.isBlank(returnUrl)) { page.writeStart("a", "href", returnUrl); page.writeHtml(page.localize(null, "returnToSearch")); page.writeEnd(); } page.writeEnd(); // end .message-success } } else { page.writeStart("div", "class", "searchResult-action-simple"); page.writeStart("a", "class", "button " + actionIconClass, "target", TARGET, "href", new UrlBuilder(page.getRequest()) .absolutePath(page.cmsUrl(PATH)) .currentParameters() .parameter(Context.SELECTION_ID_PARAMETER, page.getSelection() != null ? page.getSelection().getId() : null) .parameter("action", action.name())); String resourceKey = null; if (Action.RESTORE.equals(action)) { resourceKey = page.getSelection() != null ? "action.restoreSelected" : "action.restoreAll"; } else if (Action.ARCHIVE.equals(action)) { resourceKey = page.getSelection() != null ? "action.archiveSelected" : "action.archiveAll"; } page.writeHtml(page.localize(BulkArchive.class, resourceKey)); page.writeEnd(); page.writeEnd(); } break; } } public static enum WidgetState { BUTTON, CONFIRM } /** * A private extension of ToolPageContext for use only with the BulkArchive servlet widget. */ private static class Context extends ToolPageContext { public static final String WIDGET_STATE_PARAMETER = "bulkArchiveState"; public static final String ACTION_PARAMETER = "action"; public static final String SELECTION_ID_PARAMETER = "selectionId"; public static final String SEARCH_PARAMETER = "search"; private static final Integer READ_PAGE_SIZE = 20; private Search search; private SearchResultSelection selection; private WidgetState widgetState; private Action action; public Context(PageContext pageContext) { this(pageContext.getServletContext(), (HttpServletRequest) pageContext.getRequest(), (HttpServletResponse) pageContext.getResponse(), null, null, null); } public Context(ToolPageContext page) { this(page.getServletContext(), page.getRequest(), page.getResponse(), page.getDelegate(), null, null); } public Context(ToolPageContext page, Search search, SearchResultSelection selection) { this(page.getServletContext(), page.getRequest(), page.getResponse(), page.getDelegate(), search, selection); } public Context(ServletContext servletContext, HttpServletRequest request, HttpServletResponse response, Writer delegate, Search search, SearchResultSelection selection) { super(servletContext, request, response); setDelegate(delegate); String selectionId = param(String.class, SELECTION_ID_PARAMETER); this.widgetState = ObjectUtils.firstNonNull(param(WidgetState.class, WIDGET_STATE_PARAMETER), WidgetState.CONFIRM); this.action = ObjectUtils.firstNonNull(param(Action.class, ACTION_PARAMETER), Action.ARCHIVE); if (selection != null) { setSelection(selection); } else if (!ObjectUtils.isBlank(selectionId)) { LOGGER.debug("Found " + SELECTION_ID_PARAMETER + " query parameter with value: " + selectionId); SearchResultSelection queriedSelection = (SearchResultSelection) Query.fromAll().where("_id = ?", selectionId).first(); if (queriedSelection == null) { throw new IllegalArgumentException("No Collection/SearchResultSelection exists for id " + selectionId); } setSelection(queriedSelection); } else if (search != null) { setSearch(search); } else { Search searchFromJson = searchFromJson(); if (searchFromJson == null) { LOGGER.debug("Could not obtain Search object from JSON query parameter"); searchFromJson = new Search(); } setSearch(searchFromJson); } } public Search getSearch() { return search; } public void setSearch(Search search) { this.search = search; } public SearchResultSelection getSelection() { return selection; } public void setSelection(SearchResultSelection selection) { this.selection = selection; } public WidgetState getWidgetState() { return widgetState; } public void setWidgetState(WidgetState widgetState) { this.widgetState = widgetState; } public Action getAction() { return action; } public void setAction(Action action) { this.action = action; } /** * Produces a Search object from JSON and prevents errors when the same query parameter name is used for non-JSON Search representation. * @return Search if a query parameter specifies valid Search JSON, null otherwise. */ public Search searchFromJson() { Search search = null; String searchParam = param(String.class, SEARCH_PARAMETER); if (searchParam != null) { try { Map<String, Object> searchJson = ObjectUtils.to(new TypeReference<Map<String, Object>>() { }, ObjectUtils.fromJson(searchParam)); search = new Search(); search.getState().setValues(searchJson); } catch (Exception ignore) { // Ignore. Search will be constructed below using ToolPageContext } } return search; } // Produces a Query for objects to be bulk workflow transitioned. public Query<?> itemsQuery() { if (getSearch() != null) { return getSearch().toQuery(getSite()); } else if (getSelection() != null) { return getSelection().createItemsQuery(); } throw new IllegalStateException("No Search or SearchResultsSelection populated. Cannot create items Query."); } public boolean isItemActionable(Object item, boolean archive) { State itemState = State.getInstance(item); String typePermissionId = "type/" + itemState.getTypeId(); Site site = getSite(); return !itemState.getType().as(ToolUi.class).isReadOnly() && archive ^ itemState.as(Content.ObjectModification.class).isTrash() && hasPermission(typePermissionId + "/write") && hasPermission(typePermissionId + "/bulkArchive") && (site == null || Site.Static.isObjectAccessible(site, item)); } public boolean isSearchActionable(Search search, boolean archive) { if (search == null) { return false; } ObjectType selectedType = search.getSelectedType(); return (archive ^ getSearch().getVisibilities().contains("b.cms.content.trashed")) && selectedType != null && !Stream.concat(selectedType.findConcreteTypes().stream(), Stream.of(selectedType)) .filter(t -> { String typePermissionId = "type/" + t.getId(); return t.as(ToolUi.class).isReadOnly() || !hasPermission(typePermissionId + "/write") || !hasPermission(typePermissionId + "/bulkArchive"); }) .findAny().isPresent(); } private long getAvailableActionCount(boolean archive) { if (getSelection() != null) { return itemsQuery().noCache().selectAll().stream().filter(i -> isItemActionable(i, archive)).count(); } else if (getSearch() != null) { return isSearchActionable(getSearch(), archive) ? getSearch().toQuery(getSite()).count() : 0; } return 0; } public long getAvailableArchiveCount() { return getAvailableActionCount(true); } public long getAvailableRestoreCount() { return getAvailableActionCount(false); } } }