ilscipio/scipio-erp

View on GitHub
applications/cms/src/com/ilscipio/scipio/cms/data/importexport/CmsDataExportWorker.java

Summary

Maintainability
F
3 days
Test Coverage
/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package com.ilscipio.scipio.cms.data.importexport;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.Serializable;
import java.io.StringWriter;
import java.io.Writer;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

import javax.transaction.Transaction;

import org.ofbiz.base.util.Debug;
import org.ofbiz.base.util.UtilDateTime;
import org.ofbiz.base.util.UtilFormatOut;
import org.ofbiz.base.util.UtilGenerics;
import org.ofbiz.base.util.UtilMisc;
import org.ofbiz.base.util.UtilProperties;
import org.ofbiz.base.util.UtilValidate;
import org.ofbiz.content.data.SpecDataResEntityInfo;
import org.ofbiz.entity.Delegator;
import org.ofbiz.entity.DelegatorFactory;
import org.ofbiz.entity.GenericEntityException;
import org.ofbiz.entity.GenericValue;
import org.ofbiz.entity.condition.EntityComparisonOperator;
import org.ofbiz.entity.condition.EntityCondition;
import org.ofbiz.entity.condition.EntityJoinOperator;
import org.ofbiz.entity.condition.EntityOperator;
import org.ofbiz.entity.model.ModelEntity;
import org.ofbiz.entity.model.ModelViewEntity;
import org.ofbiz.entity.transaction.GenericTransactionException;
import org.ofbiz.entity.transaction.TransactionUtil;
import org.ofbiz.entity.util.EntityFindOptions;
import org.ofbiz.entity.util.EntityListIterator;
import org.ofbiz.security.Security;

import com.ilscipio.scipio.cms.CmsUtil;
import com.ilscipio.scipio.cms.data.CmsDataObject.DataObjectWorker;
import com.ilscipio.scipio.cms.data.CmsEntityInfo;
import com.ilscipio.scipio.cms.data.CmsEntityVisit;
import com.ilscipio.scipio.cms.data.CmsEntityVisit.CmsEntityVisitor;
import com.ilscipio.scipio.cms.data.CmsEntityVisit.VisitRelation;
import com.ilscipio.scipio.cms.data.CmsMajorObject;
import com.ilscipio.scipio.cms.data.CmsObjectRegistry;
import org.ofbiz.entity.util.EntityInfoUtil;
import com.ilscipio.scipio.cms.media.CmsMediaWorker;
import com.ilscipio.scipio.cms.template.CmsTemplate.TemplateBodySource;

/**
 * CMS data export worker.
 * <p>
 * Holds both parameters and state.
 * NOT thread-safe; use {@link #cloneWorkerNewState} before run when passed across session.
 * <p>
 * NOTE: Code here was migrated from CmsDataExport.groovy and CmsDataExportRaw.jsp progressively.
 * It contains several levels and the lower ones may not make that much sense to use anymore.
 */
@SuppressWarnings("serial")
public abstract class CmsDataExportWorker implements Serializable {

    private static final Debug.OfbizLogger module = Debug.getOfbizLogger(java.lang.invoke.MethodHandles.lookup().lookupClass());

    protected static final int DEFAULT_TRANSACTION_TIMEOUT = 3600;
    protected static final int LOG_RECORD_COUNT_INTERVAL = 500;

    private static final List<String> extEntityOrder = UtilMisc.unmodifiableArrayList("DataResource", "ElectronicText", "Content");

    public static final String LOG_PREFIX = "Cms: Data Export: ";

    /******************************************************/
    /* Essential/Configuration Variables */
    /******************************************************/
    // NOTE: See cmsExportData* service interfaces for descriptions of worker options

    protected final Delegator delegator;
    // DEV NOTE: final could be removed on some of these, using to prevent errors
    protected final CmsEntityInfo entityInfo;
    protected final boolean useTrans;
    protected final boolean newTrans;
    protected final int transTimeout;
    protected final Map<String, Set<String>> entityCdataFields;
    protected final boolean exportFilesAsTextData;
    protected final Set<String> targetEntityNames; // NOTE: this gets reordered
    protected final Set<String> targetSpecialEntityNames;
    protected final Set<String> targetCombinedEntityNames; // targetEntityNames + targetSpecialEntityNames
    protected final EntityCondition entityCond;
    protected final EntityCondition entityDateCond;
    protected final Map<String, EntityCondition> entityCondMap; // NOTE: this gets reordered
    protected final boolean includeContentRefs;
    protected EntityFindOptions mainEfo;
    protected final String attribTmplAssocType; // NOTE: this is pre-included in entityCondMap
    protected final String pmpsMappingTypeId; // NOTE: this is pre-included in entityCondMap
    protected final boolean doSpecialProcessViewMappingFilter;
    protected final boolean mediaExportVariants;

    /******************************************************/
    /* State Variables */
    /******************************************************/

    private Set<String> seenContentIds = new LinkedHashSet<>(); // SCIPIO: prevent duplicate outs and allows delayed output when recordGroupingByType
    private Set<String> seenCmsProcessMappingIds = new LinkedHashSet<>(); // using with doSpecialProcessViewMappingFilter
    private ModelEntity currentRecordModel = null;
    private String currentRecordName = null;
    private Set<String> currentContentIdFieldNames = null;
    private PrintWriter writer = null;


    /******************************************************/
    /* Constructors */
    /******************************************************/

    /**
     * Base common worker args.
     * NOTE: See cmsExportData* service interfaces for descriptions of worker options
     * <p>
     * Initialized from defaults and/or presets.
     * The last assignment to a field gets priority, unless null was passed in which case ignored;
     * this is how defaults are handled (slow but clear).
     * <p>
     * NOTE: 2017-06-02: currently all the options fit in here.
     * In case they don't is the reason for <T> which allows "return (T) this;" for one free level of subclassing later.
     */
    @SuppressWarnings("unchecked")
    public static class CommonWorkerArgs<T extends CommonWorkerArgs<T>> {
        // context
        final Delegator delegator;
        final CmsEntityInfo entityInfo;

        PresetConfig presetConfig = null; // currently this is only the last applied preset
        boolean useTrans = true;
        boolean newTrans = false;
        int transTimeout = DEFAULT_TRANSACTION_TIMEOUT;

        // general options
        Collection<String> targetEntityNames;
        Collection<String> enterEntityNames; // used in Object grouping mode only
        Collection<String> enterMajorEntityNames; // used in Object grouping mode only
        boolean includeContentRefs = true; // this is almost universally needed
        boolean exportFilesAsTextData = false; // makes extremely large files
        EntityCondition entityCond = null;
        EntityCondition entityDateCond = null;
        Map<String, EntityCondition> entityCondMap = new HashMap<>(); // NOTE: only affects the main queries, not the slave queries in Object grouping mode
        EntityFindOptions mainEfo;

        // mode-specific options
        RecordGrouping recordGrouping = null; // default would depend on file modes...
        boolean includeMajorDeps = false;
        int maxRecordsPerFile = 0;

        // extra convenience fields
        // NOTE: empty string is significant for overriding defaults
        String attribTmplAssocType = null; // NOTE: when the args are finalized, this is simply merged as conditions into entityCondMap (convenience option)
        String pmpsMappingTypeId = null; // NOTE: when the args are finalized, this is simply merged as conditions into entityCondMap (convenience option)

        Boolean mediaExportVariants = null;

        public CommonWorkerArgs(Delegator delegator) {
            this.delegator = delegator;
            this.entityInfo = CmsEntityInfo.getInst(this.delegator);
        }

        public T applyPreset(PresetConfig preset) {
            if (preset == null) return (T) this;
            this.presetConfig = preset;
            // apply the values (if non-null/non-empty)
            setTargetEntityNames(preset.getEntityNames());
            setAttribTmplAssocType(preset.getAttribTmplAssocType());
            setPmpsMappingTypeId(preset.getPmpsMappingTypeId());
            return (T) this;
        }
        public T applyPreset(String presetName) { return applyPreset(EntityPresetMap.getInst(delegator).get(presetName)); }

        /**
         * this is used to apply the entity names from the preset to the lateral entity traversing instead
         * of the bulk main queries.
         */
        protected void setEnterEntityNamesFromPresetInternal(PresetConfig preset) {
            if (preset != null) {
                this.enterEntityNames = preset.getEntityNames();
            }
        }

        /**
         * this is used to apply the entity names from the preset to the lateral entity traversing instead
         * of the bulk main queries.
         */
        protected void setEnterMajorEntityNamesFromPresetInternal(PresetConfig preset) {
            if (preset != null) {
                this.enterMajorEntityNames = preset.getEntityNames();
            }
        }

        public Delegator getDelegator() { return delegator; }
        public boolean isUseTrans() { return useTrans; }
        public boolean isNewTrans() { return newTrans; }
        public int getTransTimeout() { return transTimeout; }
        public Collection<String> getTargetEntityNames() { return targetEntityNames; }
        public Collection<String> getEnterEntityNames() { return enterEntityNames; }
        public Collection<String> getEnterMajorEntityNames() { return enterMajorEntityNames; }
        public boolean isIncludeContentRefs() { return includeContentRefs; }
        public boolean isExportFilesAsTextData() { return exportFilesAsTextData; }
        public EntityCondition getEntityCond() { return entityCond; }
        public EntityCondition getEntityDateCond() { return entityDateCond; }
        public Map<String, EntityCondition> getEntityCondMap() { return entityCondMap; }
        public EntityFindOptions getMainEfo() { return mainEfo; }
        public RecordGrouping getRecordGrouping() { return recordGrouping; }
        public boolean isIncludeMajorDeps() { return includeMajorDeps; }
        public int getMaxRecordsPerFile() { return maxRecordsPerFile; }
        public String getAttribTmplAssocType() { return attribTmplAssocType; }
        public String getPmpsMappingTypeId() { return pmpsMappingTypeId; }
        public Boolean getMediaExportVariants() { return mediaExportVariants; }

        // NOTE: set* methods ignore null values. for string and collections you can override with empty string and collection, however.
        //public T setDelegator(Delegator delegator) { this.delegator = delegator; return (T) this; } // don't change this
        public T setUseTrans(boolean useTrans) { this.useTrans = useTrans; return (T) this; }
        public T setNewTrans(boolean newTrans) { this.newTrans = newTrans; return (T) this; }
        public T setTransTimeout(int transTimeout) { this.transTimeout = transTimeout; return (T) this; }
        public T setTargetEntityNames(Collection<String> targetEntityNames) { this.targetEntityNames = targetEntityNames; return (T) this; }
        public T setEnterEntityNames(Collection<String> enterEntityNames) { this.enterEntityNames = enterEntityNames; return (T) this; }
        public T setEnterEntityNamesFromPreset(String presetConfigName) { setEnterEntityNamesFromPresetInternal(EntityPresetMap.getInst(delegator).get(presetConfigName)); return (T) this; }
        public T setEnterEntityNamesFromPreset(PresetConfig presetConfig) { setEnterEntityNamesFromPresetInternal(presetConfig); return (T) this; }
        public T setEnterMajorEntityNames(Collection<String> targetMajorEntityNames) { this.enterMajorEntityNames = targetMajorEntityNames; return (T) this; }
        public T setEnterMajorEntityNamesFromPreset(String presetConfigName) { setEnterMajorEntityNamesFromPresetInternal(EntityPresetMap.getInst(delegator).get(presetConfigName)); return (T) this; }
        public T setEnterMajorEntityNamesFromPreset(PresetConfig presetConfig) { setEnterMajorEntityNamesFromPresetInternal(presetConfig); return (T) this; }
        public T setIncludeContentRefs(boolean includeContentRefs) { this.includeContentRefs = includeContentRefs; return (T) this; }
        public T setExportFilesAsTextData(boolean exportFilesAsTextData) { this.exportFilesAsTextData = exportFilesAsTextData; return (T) this; }
        public T setEntityCond(EntityCondition entityCond) { this.entityCond = entityCond; return (T) this; }
        public T setEntityDateCond(EntityCondition entityDateCond) { this.entityDateCond = entityDateCond; return (T) this; }
        public T setEntityCondMap(Map<String, EntityCondition> entityCondMap) { if (entityCondMap != null) this.entityCondMap = entityCondMap; else this.entityCondMap = new HashMap<>(); return (T) this; }
        public T setMainEfo(EntityFindOptions mainEfo) { this.mainEfo = mainEfo; return (T) this; }
        public T setCommonEfo() { this.setMainEfo(CmsDataExportWorker.getCommonEfo()); return (T) this; }
        public T setRecordGrouping(RecordGrouping recordGrouping) { this.recordGrouping = recordGrouping; return (T) this; }
        public T setRecordGrouping(String recordGrouping) { this.recordGrouping = RecordGrouping.fromString(recordGrouping); return (T) this; }
        public T setIncludeMajorDeps(boolean includeMajorDeps) { this.includeMajorDeps = includeMajorDeps; return (T) this; }
        public T setMaxRecordsPerFile(int maxRecordsPerFile) { this.maxRecordsPerFile = maxRecordsPerFile; return (T) this; }
        public T setAttribTmplAssocType(String attribTmplAssocType) { this.attribTmplAssocType = attribTmplAssocType; return (T) this; }
        public T setPmpsMappingTypeId(String pmpsMappingTypeId) { this.pmpsMappingTypeId = pmpsMappingTypeId; return (T) this; }
        public T setMediaExportVariants(Boolean mediaExportVariants) { this.mediaExportVariants = mediaExportVariants; return (T) this; }

        public T setAllFromMap(Map<String, ?> ctx) {
            applyPresetFromMap(ctx);
            setFieldsFromMap(ctx);
            return (T) this;
        }
        public T applyPresetFromMap(Map<String, ?> ctx) {
            if (ctx.containsKey("presetConfigName")) applyPreset((String) ctx.get("presetConfigName"));
            if (ctx.containsKey("enterPresetConfigName")) setEnterEntityNamesFromPreset((String) ctx.get("enterPresetConfigName"));
            if (ctx.containsKey("enterMajorPresetConfigName")) setEnterMajorEntityNamesFromPreset((String) ctx.get("enterMajorPresetConfigName"));
            return (T) this;
        }
        public T setFieldsFromMap(Map<String, ?> ctx) {
            if (ctx.containsKey("targetEntityNames")) setTargetEntityNames(UtilGenerics.<String>checkCollection(ctx.get("targetEntityNames")));
            if (ctx.containsKey("enterEntityNames")) setEnterEntityNames(UtilGenerics.<String>checkCollection(ctx.get("enterEntityNames")));
            if (ctx.containsKey("enterMajorEntityNames")) setEnterMajorEntityNames(UtilGenerics.<String>checkCollection(ctx.get("enterMajorEntityNames")));
            if (ctx.containsKey("attribTmplAssocType")) setAttribTmplAssocType(getString(ctx, "attribTmplAssocType"));
            if (ctx.containsKey("pmpsMappingTypeId")) setPmpsMappingTypeId(getString(ctx, "pmpsMappingTypeId"));

            if (ctx.containsKey("recordGrouping")) setRecordGrouping(getString(ctx, "recordGrouping"));
            if (ctx.containsKey("exportFilesAsTextData")) setExportFilesAsTextData((Boolean) ctx.get("exportFilesAsTextData"));
            if (ctx.containsKey("includeMajorDeps")) setIncludeMajorDeps((Boolean) ctx.get("includeMajorDeps"));
            if (ctx.containsKey("includeContentRefs")) setIncludeContentRefs((Boolean) ctx.get("includeContentRefs"));
            if (ctx.containsKey("maxRecordsPerFile")) setMaxRecordsPerFile((Integer) ctx.get("maxRecordsPerFile"));

            if (ctx.containsKey("entityCond")) setEntityCond((EntityCondition) ctx.get("entityCond"));
            if (ctx.containsKey("entityDateCond")) setEntityDateCond((EntityCondition) ctx.get("entityDateCond"));
            if (ctx.containsKey("entityCondMap")) setEntityCondMap(UtilGenerics.<String, EntityCondition>checkMap(ctx.get("entityCondMap")));

            if (ctx.containsKey("mainEfo")) setMainEfo((EntityFindOptions) ctx.get("mainEfo"));
            if (Boolean.TRUE.equals(ctx.get("useCommonEfo"))) {
                setCommonEfo();
            }
            if (ctx.containsKey("transTimeout")) setTransTimeout((Integer) ctx.get("transTimeout"));

            return (T) this;
        }

        private String getString(Map<String, ?> ctx, String key) { return (String) ctx.get(key); }

        // Finalization methods
        /**
         * Returns copy of entityCondMap plus attribTmplAssocType and pmpsMappingTypeId factored in.
         */
        protected Map<String, EntityCondition> getEffectiveEntityCondMap() {
            Map<String, EntityCondition> effCondMap = new HashMap<>(getEntityCondMap());
            if (UtilValidate.isNotEmpty(getPmpsMappingTypeId())) {
                // NOTE: for ObjGrp worker, this gets post-processed in the ObjGrp constructor
                CmsDataExportWorker.addPageSpecialMappingConds(delegator, getPmpsMappingTypeId(), effCondMap);
            }
            if (UtilValidate.isNotEmpty(getAttribTmplAssocType())) {
                CmsDataExportWorker.addAttribTmplAssocTypeConds(delegator, getAttribTmplAssocType(), effCondMap);
            }
            return effCondMap;
        }

        protected Set<String> getEffectiveTargetEntityNames() {
            return entityInfo.filterCmsEntityNames((targetEntityNames != null) ? getTargetEntityNames() : Collections.<String>emptySet());
        }
        protected Set<String> getEffectiveSpecialTargetEntityNames() {
            return entityInfo.filterSpecialCmsEntityNames((targetEntityNames != null) ? getTargetEntityNames() : Collections.<String>emptySet());
        }
        protected Set<String> getEffectiveEnterEntityNames() {
            if (enterEntityNames == null || enterEntityNames.isEmpty()) return null;
            return entityInfo.filterCmsEntityNames(getEnterEntityNames());
        }
        protected Set<String> getEffectiveEnterMajorEntityNames() {
            if (enterMajorEntityNames == null || enterMajorEntityNames.isEmpty()) return null;
            return entityInfo.filterMajorCmsEntityNames(getEnterMajorEntityNames());
        }
    }

    // NOTE: may have more args subclass later, but simplified to 1.5 for now, as most args could be reused
    public static class GenericWorkerArgs extends CommonWorkerArgs<GenericWorkerArgs> {
        public GenericWorkerArgs(Delegator delegator) { super(delegator); }
    }

    protected CmsDataExportWorker(CommonWorkerArgs<?> args) throws IllegalArgumentException {
        this.delegator = args.getDelegator();
        this.useTrans = args.isUseTrans();
        this.newTrans = args.isNewTrans();
        this.transTimeout = args.getTransTimeout();
        this.entityInfo = CmsEntityInfo.getInst(this.delegator);
        this.entityCdataFields = entityInfo.getEntityCdataFields();
        this.exportFilesAsTextData = args.isExportFilesAsTextData();
        this.targetEntityNames = args.getEffectiveTargetEntityNames();
        this.targetSpecialEntityNames = args.getEffectiveSpecialTargetEntityNames();
        Set<String> targetCombinedEntityNames = new LinkedHashSet<>();
        targetCombinedEntityNames.addAll(this.targetEntityNames);
        targetCombinedEntityNames.addAll(this.targetSpecialEntityNames);
        this.targetCombinedEntityNames = Collections.unmodifiableSet(targetCombinedEntityNames);
        this.entityCond = args.getEntityCond();
        this.entityDateCond = args.getEntityDateCond();
        this.entityCondMap = Collections.unmodifiableMap(args.getEffectiveEntityCondMap());
        this.includeContentRefs = args.isIncludeContentRefs();
        this.mainEfo = args.getMainEfo();
        this.attribTmplAssocType = args.getAttribTmplAssocType();
        this.pmpsMappingTypeId = args.getPmpsMappingTypeId();
        this.doSpecialProcessViewMappingFilter = (args.getRecordGrouping() != RecordGrouping.MAJOR_OBJECT) && entityCondMap.get("CmsProcessMapping") != null;
        if (CmsUtil.verboseOn()) {
            Debug.logInfo(LOG_PREFIX+"Using special CmsProcessViewMapping filter? " + this.doSpecialProcessViewMappingFilter, module);
        }
        Boolean mediaExportVariants = args.getMediaExportVariants();
        if (mediaExportVariants == null) mediaExportVariants = getTargetSpecialEntityNames().contains("CmsMediaVariants");
        this.mediaExportVariants = mediaExportVariants;
    }

    protected CmsDataExportWorker(CmsDataExportWorker other, Delegator delegator) {
        this.delegator = (delegator != null) ? delegator : other.delegator;
        this.useTrans = other.useTrans;
        this.newTrans = other.newTrans;
        this.transTimeout = other.transTimeout;
        this.entityInfo = CmsEntityInfo.getInst(this.delegator);
        this.entityCdataFields = this.entityInfo.getEntityCdataFields();
        this.exportFilesAsTextData = other.exportFilesAsTextData;
        this.targetEntityNames = other.targetEntityNames;
        this.targetSpecialEntityNames = other.targetSpecialEntityNames;
        this.targetCombinedEntityNames = other.targetCombinedEntityNames;
        this.entityCond = other.entityCond;
        this.entityDateCond = other.entityDateCond;
        this.entityCondMap = other.entityCondMap;
        this.includeContentRefs = other.includeContentRefs;
        this.mainEfo = other.mainEfo;
        this.attribTmplAssocType = other.attribTmplAssocType;
        this.pmpsMappingTypeId = other.pmpsMappingTypeId;
        this.doSpecialProcessViewMappingFilter = other.doSpecialProcessViewMappingFilter;
        this.mediaExportVariants = other.mediaExportVariants;
    }

    /**
     * Clones the worker but with a fresh new state, with optional replacement delegator (pass null to keep).
     */
    public abstract CmsDataExportWorker cloneWorkerNewState(Delegator delegator);

    public static CmsDataExportWorker makeGenericWorker(GenericWorkerArgs args) throws IllegalArgumentException {
        return new GenericWorker(args);
    }

    public static SingleFileWorker makeSingleFileWorker(GenericWorkerArgs args) throws IllegalArgumentException {
        if (args.getRecordGrouping() == RecordGrouping.MAJOR_OBJECT) return new ObjGrpSingleFileWorker(args);
        else return new SingleFileWorker(args);
    }

    public static MultiFileWorker makeMultiFileWorker(GenericWorkerArgs args) throws IllegalArgumentException {
        return new MultiFileWorker(args);
    }

    /******************************************************/
    /* Getters/Setters/Info */
    /******************************************************/

    public Delegator getDelegator() {
        return delegator;
    }

    /**
     * Gets the target CMS entity names (i.e. gotten via passedEntityNames request parameter).
     * NOTE: These may be reordered compared to the ones passed to the controller.
     */
    public Set<String> getTargetEntityNames() {
        return targetEntityNames;
    }

    /**
     * Gets the SPECIAL target CMS entity names (i.e. gotten via passedEntityNames request parameter), can include fake entities,
     * e.g. "CmsMedia".
     * NOTE: These may be reordered compared to the ones passed to the controller.
     */
    public Set<String> getTargetSpecialEntityNames() {
        return targetSpecialEntityNames;
    }

    /**
     * Returns {@link #getTargetEntityNames} + {@link #getTargetSpecialEntityNames}.
     */
    public Set<String> getTargetCombinedEntityNames() {
        return targetCombinedEntityNames;
    }

    /**
     * Sanity check, don't use in implementation.
     */
    public boolean hasEntityNames() {
        return targetCombinedEntityNames.size() > 0;
    }

    public EntityCondition getEntityCond() {
        return entityCond;
    }

    public EntityCondition getEntityDateCond() {
        return entityDateCond;
    }

    public Map<String, EntityCondition> getEntityCondMap() {
        return Collections.unmodifiableMap(entityCondMap);
    }

    protected Map<String, EntityCondition> getEntityCondMapInternal() {
        return entityCondMap;
    }

    public boolean isExportFilesAsTextData() {
        return exportFilesAsTextData;
    }

    public Set<String> getSeenContentIds() {
        return seenContentIds;
    }

    public abstract boolean isMultiFile();

    public boolean isSingleFile() {
        return !isMultiFile();
    }

    public PrintWriter getWriter() {
        return writer;
    }

    public void setWriter(PrintWriter writer) {
        this.writer = writer;
    }

    public EntityFindOptions getMainEfo() {
        return mainEfo;
    }

    public void setMainEfo(EntityFindOptions mainEfo) {
        this.mainEfo = mainEfo;
    }

    public ModelEntity getCurrentRecordModel() {
        return currentRecordModel;
    }

    /**
     * WARN: this may be different from getCurrentRecordModel().getEntityName()!
     */
    public String getCurrentRecordName() {
        return currentRecordName;
    }

    public void setCurrentRecordModel(ModelEntity currentRecordModel) {
        this.currentRecordModel = currentRecordModel;
        this.setCurrentContentIdFieldNames(entityInfo.getCmsContentIdFieldNames(currentRecordModel));
    }

    public boolean isIncludeContentRefs() {
        return includeContentRefs;
    }

    public Set<String> getCurrentContentIdFieldNames() {
        return Collections.unmodifiableSet(currentContentIdFieldNames);
    }

    protected void setCurrentContentIdFieldNames(Set<String> currentContentIdFieldNames) {
        this.currentContentIdFieldNames = currentContentIdFieldNames;
    }

    public boolean hasSeenContentId(String contentId) {
        return seenContentIds.contains(contentId);
    }

    protected boolean registerContentId(String contentId) {
        return seenContentIds.add(contentId);
    }

    protected abstract RecordGrouping getRecordGrouping();


    public EntityCondition getEntitySpecificCond(String entityName) {
        return entityCondMap.get(entityName);
    }

    public EntityCondition getEffectiveEntityCond(String entityName, boolean excludeDateCond) {
        List<EntityCondition> condList = new ArrayList<>(4);
        EntityCondition commonCond = getEntityCond();
        if (commonCond != null) {
            condList.add(commonCond);
        }
        if (!excludeDateCond) {
            EntityCondition dateCond = getEntityDateCond();
            if (dateCond != null) {
                condList.add(dateCond);
            }
        }
        EntityCondition specCond = getEntitySpecificCond(entityName);
        if (specCond != null) {
            condList.add(specCond);
        }
        // SPECIAL: CmsProcessViewMapping workaround
        // FIXME: this is a memory hog solution generating a huge query but it's the only one on the table for now
        if (doSpecialProcessViewMappingFilter && "CmsProcessViewMapping".equals(entityName) && this.seenCmsProcessMappingIds.size() > 0) {
            List<EntityCondition> viewCondList = new ArrayList<>(this.seenCmsProcessMappingIds.size());
            for(String id : this.seenCmsProcessMappingIds) {
                viewCondList.add(EntityCondition.makeCondition("processMappingId", id));
            }
            condList.add(EntityCondition.makeCondition(viewCondList, EntityOperator.OR));
        }
        // not for now
        if ("CmsMedia".equals(this.getCurrentRecordName())) {
            condList.add(EntityCondition.makeCondition("contentTypeId", "SCP_MEDIA"));
        }
        return condList.isEmpty() ? null : EntityCondition.makeCondition(condList, EntityOperator.AND);
    }

    public EntityCondition getEffectiveEntityCond(String entityName) {
        return getEffectiveEntityCond(entityName, false);
    }

    public boolean isMediaExportVariants() {
        return mediaExportVariants;
    }


    /******************************************************/
    /* Essential/Configuration Variables */
    /******************************************************/

    /**
     * How to group records.
     * NOTE: this is currently only used for single-file exports.
     */
    public enum RecordGrouping {
        /**
         * No grouping, fastest export.
         */
        NONE("CmsRecordGroupingNoGrouping", "CmsRecordGroupingNoGroupingHint"),
        /**
         * Grouping by entity type (name), fastest import, but has export performance issues.
         * NOTE: the export performance is worst for single-file (memory issues), whereas multi-file may
         * manage better.
         */
        ENTITY_TYPE("CmsRecordGroupingGroupByEntityType", "CmsRecordGroupingGroupByEntityTypeHint"),
        /**
         * Grouping by object, slowest export and import, but most readable.
         */
        MAJOR_OBJECT("CmsRecordGroupingGroupByMajorObject", "CmsRecordGroupingGroupByMajorObjectHint");

        public static final RecordGrouping DEFAULT = getDisplayValues().get(0); // default = always the first
        public static final String LABEL_RESOURCE = "CMSUiLabels";
        //private static final List<RecordGrouping> displayValues = UtilMisc.unmodifiableArrayList(NONE, ENTITY_TYPE);

        private final String labelName;
        private final String hintLabelName;

        private RecordGrouping(String labelName, String hintLabelName) {
            this.labelName = labelName;
            this.hintLabelName = hintLabelName;
        }

        public String getLabelResource() { return LABEL_RESOURCE; }
        public String getLabelName() { return labelName; }
        public String getLabel(Locale locale) { return UtilProperties.getMessage(getLabelResource(), getLabelName(), locale); }
        public String getHintLabelName() { return hintLabelName; }
        public String getHintLabel(Locale locale) { return UtilProperties.getMessage(getLabelResource(), getHintLabelName(), locale); }
        public String getFullLabel(Locale locale) { return getLabel(locale) + " (" + getHintLabel(locale) + ")"; }
        public static RecordGrouping fromString(String str) { return UtilValidate.isNotEmpty(str) ? RecordGrouping.valueOf(str) : null; }
        public static RecordGrouping fromStringSafe(String str) {
            try {
                return UtilValidate.isNotEmpty(str) ? RecordGrouping.valueOf(str) : null;
            } catch(Exception e) { return null; }
        }
        public static RecordGrouping fromStringOrDefault(String str) { return UtilValidate.isNotEmpty(str) ? RecordGrouping.valueOf(str) : DEFAULT; }
        public static RecordGrouping fromStringOrDefaultSafe(String str) {
            try {
                return UtilValidate.isNotEmpty(str) ? RecordGrouping.valueOf(str) : DEFAULT;
            } catch(Exception e) { return DEFAULT; }
        }
        public static RecordGrouping getDefault() { return DEFAULT; }
        public static List<RecordGrouping> getDisplayValues() { return Arrays.asList(RecordGrouping.values()); } // return displayValues; }
    }

    public enum OutputMode {
        SF_DL("CmsSingleFileDownload", null),
        SF_IL("CmsSingleFileInline", "CmsSingleFileInlineDesc"),
        SF_FS("CmsSingleFileServerFile", null),
        MF_FS("CmsMultiFileServerDirectory", "CmsExportFuncMultiFileInfo");

        public static final String LABEL_RESOURCE = "CMSUiLabels";
        private static final List<OutputMode> restrictedDisplayModes = Collections.unmodifiableList(new ArrayList<>(Arrays.asList(OutputMode.values())));
        private static final List<OutputMode> allowedAllDisplayModes = UtilMisc.unmodifiableArrayList(SF_DL, SF_IL);
        public static final OutputMode DEFAULT = getDisplayValues().get(0); // default = always the first

        private final String labelName;
        private final String descLabelName;

        private OutputMode(String labelName, String descLabelName) { this.labelName = labelName; this.descLabelName = descLabelName; }

        public boolean isSingle() { return this == SF_IL || this == SF_DL || this == SF_FS; }
        public boolean isMulti() { return this == MF_FS; }
        public String getLabelResource() { return LABEL_RESOURCE; }
        public String getLabelName() { return labelName; }
        public String getLabel(Locale locale) { return UtilProperties.getMessage(getLabelResource(), getLabelName(), locale); }
        public String getDescLabelName() { return descLabelName; }
        public String getDescLabel(Locale locale) { return descLabelName != null ? UtilProperties.getMessage(getLabelResource(), getDescLabelName(), locale) : null; }
        public static OutputMode fromString(String str) { return UtilValidate.isNotEmpty(str) ? OutputMode.valueOf(str) : null; }
        public static OutputMode fromStringSafe(String str) {
            try {
                return UtilValidate.isNotEmpty(str) ? OutputMode.valueOf(str) : null;
            } catch(Exception e) { return null; }
        }
        public static OutputMode fromStringOrDefault(String str) { return UtilValidate.isNotEmpty(str) ? OutputMode.valueOf(str) : DEFAULT; }
        public static OutputMode fromStringOrDefaultSafe(String str) {
            try {
                return UtilValidate.isNotEmpty(str) ? OutputMode.valueOf(str) : DEFAULT;
            } catch(Exception e) { return DEFAULT; }
        }
        public static OutputMode getDefault() { return DEFAULT; }
        public static List<OutputMode> getDisplayValues() { return allowedAllDisplayModes; }
        public static List<OutputMode> getDisplayValues(Security security, GenericValue userLogin) {
            if (security != null && userLogin != null && security.hasPermission("ENTITY_MAINT", userLogin)) {
                return restrictedDisplayModes;
            } else {
                return allowedAllDisplayModes;
            }
        }
        public void checkAllowed(Security security, GenericValue userLogin) throws IllegalStateException {
            if (allowedAllDisplayModes.contains(this)) return;
            else {
                if (!security.hasPermission("ENTITY_MAINT", userLogin)) {
                    throw new IllegalStateException("User missing ENTITY_MAINT permission for output mode " + this); // FIXME: more appropriate exception
                }
            }
        }
    }

    /**
     * Presets for entity selections and queries.
     * See {@link #buildDefaultEntityPresets} body for available presets, or simply visit Cms Data Export page.
     */
    public static class EntityPresetMap implements Serializable, Map<String, PresetConfig> {
        //private static final Debug.OfbizLogger module = Debug.getOfbizLogger(java.lang.invoke.MethodHandles.lookup().lookupClass());
        private static final EntityPresetMap INSTANCE = buildDefaultEntityPresets(DelegatorFactory.getDelegator("default")); // optimization: only need one inst
        protected final Map<String, PresetConfig> map;
        protected final Set<String> simpleOptionKeys;

        /**
         * Main constructor, private - use {@link #buildDefaultEntityPresets} or {@link PresetConfig.Builder}.
         */
        EntityPresetMap(Map<String, PresetConfig> map, Set<String> simpleOptionKeys) {
            this.map = map;
            this.simpleOptionKeys = (simpleOptionKeys != null) ? Collections.unmodifiableSet(simpleOptionKeys) : null;
        }

        /**
         * Returns a map of export presets to the names of the entities they should export.
         */
        public static EntityPresetMap getInst(Delegator delegator) {
            return INSTANCE;
        }

        /**
         * Builds map of export preset names to the names of the entities they should export and other settings;
         * this overload adds to given builder.
         */
        public static PresetConfig.Builder buildDefaultEntityPresets(PresetConfig.Builder builder) {
            PresetConfig.Builder b = builder;
            String pfx = CmsEntityInfo.CMS_ENTITY_BASE_PKG_PREFIX;
            CmsEntityInfo ei = b.getCmsEntityInfo();

            // NOTE: the variants take up huge space, so we exclude them by default
            b.newConfig("CmsAllEntities").setEntityNames(ei.copyCmsEntityNames(null, ei.getCombinedCmsEntityNames(), b.list("CmsMediaVariants"))).complete();
            b.newConfig("CmsAllEntitiesNoMedia").setEntityNames(UtilMisc.excludedSet(ei.getCmsEntityNames(), "ImageSizePreset")).complete();

            // NOTE: CmsPages includes the CmsProcessMapping and CmsPageSpecialMapping in order to save the primary path
            b.newConfig("CmsPages").setPmpsMappingTypeId("CMS_PGSPCMAP_PRIMARY").setEntityNames(ei.copyCmsEntityNames(b.list(pfx+"content"), b.list("CmsProcessMapping", "CmsProcessViewMapping", "CmsPageSpecialMapping"))).complete();
            b.newConfig("CmsPagesStrict").setEntityNames(ei.copyCmsEntityNames(b.list(pfx+"content"))).complete();

            // NOTE: CmsMappings tries to exclude primary process mappings
            b.newConfig("CmsMappings").setPmpsMappingTypeId("CMS_PGSPCMAP_STD").setEntityNames(ei.copyCmsEntityNames(pfx+"control")).complete();
            b.newConfig("CmsMappingsStrict").setEntityNames(ei.copyCmsEntityNames(pfx+"control")).complete();
            b.newConfig("CmsPagesMappings").setEntityNames(ei.copyCmsEntityNames(b.list(pfx+"content", pfx+"control"))).complete();

            b.newConfig("CmsTemplates").setEntityNames(ei.copyCmsEntityNames(pfx+"template")).complete();
            b.newConfig("CmsPageTemplates").setAttribTmplAssocType("PAGE_TEMPLATE").setEntityNames(ei.copyCmsEntityNames(null, b.list("CmsPageTemplate", "CmsPageTemplateAssetAssoc", "CmsPageTemplateScriptAssoc", "CmsPageTemplateVersion", "CmsPageTemplateVersionState", "CmsAttributeTemplate"))).complete();
            b.newConfig("CmsAssetTemplates").setAttribTmplAssocType("ASSET_TEMPLATE").setEntityNames(ei.copyCmsEntityNames(null, b.list("CmsAssetTemplate", "CmsAssetTemplateScriptAssoc", "CmsAssetTemplateVersion", "CmsAssetTemplateVersionState", "CmsAttributeTemplate"))).complete();
            b.newConfig("CmsScriptTemplates").setEntityNames(ei.copyCmsEntityNames(null, b.list("CmsScriptTemplate"))).complete();
            b.newConfig("CmsMenus").setEntityNames(ei.copyCmsEntityNames(null, b.list("CmsMenu"))).complete();

            // SPECIAL CASES
            b.newConfig("CmsMedia").setEntityNames(UtilMisc.toHashSet("ImageSizePreset", "CmsMedia")).complete();
            b.newConfig("CmsMediaWithVariants").setEntityNames(UtilMisc.toHashSet("ImageSizePreset", "CmsMedia", "CmsMediaVariants")).complete();

            // Simplified (non-advanced) list of names to show in the UI...
            // this must be separate because internally we need access to all the presets
            b.setSimplePresetNames(new LinkedHashSet<>(Arrays.asList(new String[] {
                    "CmsAllEntities", "CmsAllEntitiesNoMedia", "CmsPages", "CmsPagesMappings", "CmsTemplates", "CmsPageTemplates", "CmsAssetTemplates", "CmsScriptTemplates", "CmsMenus",
                    // SPECIAL CASES
                    "CmsMedia", "CmsMediaWithVariants"
            })));

            return b;
        }

        /**
         * Builds map of export preset names to the names of the entities they should export and other settings;
         * this overload creates a builder with a LinkedHashMap and unmodifiable EntityPresetMap.
         */
        public static EntityPresetMap buildDefaultEntityPresets(Delegator delegator) {
            return buildDefaultEntityPresets(new PresetConfig.Builder(delegator, new LinkedHashMap<String, PresetConfig>())).completePresetMap();
        }

        @Override public int size() { return map.size(); }
        @Override public boolean isEmpty() { return map.isEmpty(); }
        @Override public boolean containsKey(Object key) { return map.containsKey(key); }
        @Override public boolean containsValue(Object value) { return map.containsValue(value); }
        @Override public PresetConfig get(Object key) { return map.get(key); }
        @Override public PresetConfig put(String key, PresetConfig value) { return map.put(key, value); }
        @Override public PresetConfig remove(Object key) { return map.remove(key); }
        @Override public void putAll(Map<? extends String, ? extends PresetConfig> m) { map.putAll(m); }
        @Override public void clear() { map.clear(); }
        @Override public Set<String> keySet() { return map.keySet(); }
        @Override public Collection<PresetConfig> values() { return map.values(); }
        @Override public Set<Map.Entry<String, PresetConfig>> entrySet() { return map.entrySet(); }
        @Override public boolean equals(Object o) { return map.equals(o); }

        public PresetConfig getOrNone(Object key) { PresetConfig res = get(key); return res != null ? res : PresetConfig.NONE; }
        public PresetConfig getOrDefaults(Object key) { PresetConfig res = get(key); return res != null ? res : PresetConfig.DEFAULTS; }

        public Collection<String> getAllPresetNames() { return keySet(); }
        public Collection<String> getSimplePresetNames() { return (simpleOptionKeys != null) ? simpleOptionKeys : keySet(); }
    }

    /**
     * Preset export config. Immutable and automatically doubles as a map itself.
     */
    public static class PresetConfig implements Serializable, Map<String, Object> {
        public static final PresetConfig NONE;
        public static final PresetConfig DEFAULTS;
        static {
            PresetConfig.Builder b = new PresetConfig.Builder(DelegatorFactory.getDelegator("default"), null);
            NONE = b.newConfig("None").complete();
            DEFAULTS = b.newConfig("Defaults").complete();
        }

        protected final String presetName;
        protected final String pmpsMappingTypeId;
        protected final String attribTmplAssocType;
        protected final String labelName;
        protected final Set<String> entityNames;

        protected final Map<String, Object> map;

        private PresetConfig(Builder other) {
            this.presetName = other.presetName;
            this.pmpsMappingTypeId = other.pmpsMappingTypeId;
            this.attribTmplAssocType = other.attribTmplAssocType;
            this.labelName = (other.labelName != null) ? other.labelName : ("CmsEntityPresetLabel_" + this.presetName);
            this.entityNames = (other.entityNames != null) ? Collections.unmodifiableSet(other.entityNames) : null;
            this.map = Collections.unmodifiableMap(toMap(new HashMap<>()));
        }

        public String getPresetName() { return presetName; }
        public String getPmpsMappingTypeId() { return pmpsMappingTypeId; }
        public String getAttribTmplAssocType() { return attribTmplAssocType; }
        public String getLabelName() { return labelName; }
        public Set<String> getEntityNames() { return entityNames; }

        private Map<String, Object> toMap(Map<String, Object> other) {
            other.put("presetName", presetName);
            other.put("pmpsMappingTypeId", pmpsMappingTypeId);
            other.put("attribTmplAssocType", attribTmplAssocType);
            other.put("labelName", labelName);
            other.put("entityNames", entityNames);
            return other;
        }

        /**
         * Dual PresetConfig and EntityPresetMap builder.
         */
        public static class Builder {
            private Delegator delegator;
            private CmsEntityInfo ei;
            private Map<String, PresetConfig> rawEntityPresetMap;

            protected String presetName;
            protected String pmpsMappingTypeId;
            protected String attribTmplAssocType;
            protected String labelName;
            protected Set<String> entityNames;
            protected Set<String> simpleOptionKeys = null;

            /**
             * Builder with a LinkedHashMap for rawEntityPresetMap.
             */
            public Builder(Delegator delegator) { this(delegator, new LinkedHashMap<String, PresetConfig>()); }
            /**
             * Builder with a custom map or no map (to create only configs).
             */
            public Builder(Delegator delegator, Map<String, PresetConfig> rawEntityPresetMap) {
                this.delegator = delegator;
                this.rawEntityPresetMap = rawEntityPresetMap;
                this.ei = CmsEntityInfo.getInst(delegator);
            }
            public Builder newConfig(String presetName) { resetPreset(); setPresetName(presetName); return this; }
            public Builder resetPreset() {
                this.presetName = null;
                this.pmpsMappingTypeId = null;
                this.attribTmplAssocType = null;
                this.labelName = null;
                this.entityNames = null;
                return this;
            }
            public Builder setAll(PresetConfig other) { // setAll
                this.presetName = other.presetName;
                this.pmpsMappingTypeId = other.pmpsMappingTypeId;
                this.attribTmplAssocType = other.attribTmplAssocType;
                this.labelName = other.labelName;
                this.entityNames = other.entityNames;
                return this;
            }

            public Delegator getDelegator() { return delegator; }
            public CmsEntityInfo getCmsEntityInfo() { return ei; }

            public String getPresetName() { return presetName; }
            public Builder setPresetName(String presetName) { this.presetName = presetName; return this; }
            public Builder setPmpsMappingTypeId(String pmpsMappingTypeId) { this.pmpsMappingTypeId = pmpsMappingTypeId; return this; }
            public Builder setAttribTmplAssocType(String attribTmplAssocType) { this.attribTmplAssocType = attribTmplAssocType; return this; }
            public Builder setLabelName(String labelName) { this.labelName = labelName; return this; }
            public Builder setEntityNames(Set<String> entityNames) { this.entityNames = entityNames; return this; }
            public void setSimplePresetNames(Set<String> simpleOptionKeys) { this.simpleOptionKeys = simpleOptionKeys; }

            @SafeVarargs public final <T> List<T> list(T... args) { return new ArrayList<>(Arrays.asList(args)); }
            @SafeVarargs public final <T> Set<T> set(T... args) { return new LinkedHashSet<>(Arrays.asList(args)); }

            /**
             * Builds the PresetConfig and adds it to the internal map.
             */
            public PresetConfig complete() {
                PresetConfig pc = new PresetConfig(this);
                if (rawEntityPresetMap != null) rawEntityPresetMap.put(pc.getPresetName(), pc);
                return pc;
            }
            /**
             * Returns a finalized, immutable {@link EntityPresetMap}.
             */
            public EntityPresetMap completePresetMap() { return new EntityPresetMap(Collections.unmodifiableMap(rawEntityPresetMap), simpleOptionKeys); }
        }

        @Override public int size() { return map.size(); }
        @Override public boolean isEmpty() { return map.isEmpty(); }
        @Override public boolean containsKey(Object key) { return map.containsKey(key); }
        @Override public boolean containsValue(Object value) { return map.containsValue(value); }
        @Override public Object get(Object key) { return map.get(key); }
        @Override public Object put(String key, Object value) { return map.put(key, value); }
        @Override public Object remove(Object key) { return map.remove(key); }
        @Override public void putAll(Map<? extends String, ? extends Object> m) { map.putAll(m); }
        @Override public void clear() { map.clear(); }
        @Override public Set<String> keySet() { return map.keySet(); }
        @Override public Collection<Object> values() { return map.values(); }
        @Override public Set<Map.Entry<String, Object>> entrySet() { return map.entrySet(); }
        @Override public boolean equals(Object o) { return map.equals(o); }
    }


    /******************************************************/
    /* Entity Query helpers */
    /******************************************************/

    // FIXME: this is duplicated in the CmsEntityVisit class; both are in use
    /**
     * Gets content and related values.
     * <p>
     * FIXME: this is duplicated in {@link com.ilscipio.scipio.cms.data.CmsEntityVisit#acceptContentRelationEntityDepsVisitor}; both are in use.
     */
    public void getContentAndRelatedValues(String contentId, List<GenericValue> values) throws GenericEntityException {
        GenericValue content = delegator.findOne("Content", UtilMisc.toMap("contentId", contentId), false);
        if (content != null) {
            GenericValue dataRes = content.getRelatedOne("DataResource", false);
            if (dataRes != null) {
                if ("ELECTRONIC_TEXT".equals(dataRes.getString("dataResourceTypeId"))) {
                    values.add(dataRes);
                    GenericValue elecText = dataRes.getRelatedOne("ElectronicText", false);
                    if (elecText != null) {
                        values.add(elecText);
                    } else {
                        Debug.logError(LOG_PREFIX+"Missing ElectronicText for contentId '" + contentId + "'", module);
                    }
                } else if (exportFilesAsTextData) {
                    try {
                        TemplateBodySource tmplBodySrc = com.ilscipio.scipio.cms.template.CmsTemplate.getTemplateBodySourceFromContent(delegator, contentId, false);

                        dataRes.put("dataResourceTypeId", "ELECTRONIC_TEXT"); // WARN: DO NOT COMMIT THIS VALUE!
                        values.add(dataRes);

                        GenericValue elecText = delegator.makeValue("ElectronicText",
                            UtilMisc.toMap("dataResourceId",dataRes.getString("dataResourceId"), "textData",tmplBodySrc.getEffectiveBody()));
                        values.add(elecText);
                    } catch(Exception e) {
                        Debug.logError(e, LOG_PREFIX+"Unable to read content body for contentId '" + contentId + "'", module);
                    }
                } else {
                    values.add(dataRes);
                }
            } else {
                Debug.logError(LOG_PREFIX+"Missing DataResource for contentId '" + contentId + "'", module);
            }
            // TODO?: in the future there may be more related records (ALTERNATE LOCALES); not going too deep yet
            values.add(content);
        }
    }

    public List<GenericValue> getContentAndRelatedValues(String contentId) throws GenericEntityException {
        List<GenericValue> values = new ArrayList<>();
        getContentAndRelatedValues(contentId, values);
        return values;
    }

    public Map<String, List<GenericValue>> getContentAndRelatedValuesByEntity(Set<String> contentIds) throws GenericEntityException {
        Map<String, List<GenericValue>> map = new LinkedHashMap<>();
        // establish order
        for(String name : extEntityOrder) {
            map.put(name, new ArrayList<>());
        }
        for(String contentId : contentIds) {
            List<GenericValue> values = getContentAndRelatedValues(contentId);
            for(GenericValue value : values) {
                String entityName = value.getEntityName();
                List<GenericValue> entityValues = map.get(entityName);
                if (entityValues == null) {
                    entityValues = new ArrayList<>();
                    map.put(entityName, entityValues);
                }
                entityValues.add(value);
            }
        }
        return map;
    }

    public static void addToEntityCondMap(Delegator delegator, Map<String, EntityCondition> entityCondMap, String entityName, EntityCondition cond) {
        EntityCondition prevCond = entityCondMap.get(entityName);
        if (prevCond == null) {
            entityCondMap.put(entityName, cond);
        } else {
            entityCondMap.put(entityName, EntityCondition.makeCondition(prevCond, EntityOperator.AND, cond));
        }
    }

    public static EntityCondition makeEntityDateCond(Delegator delegator, Timestamp entityFrom, Timestamp entityThru) {
        EntityCondition entityFromCond = null;
        EntityCondition entityThruCond = null;
        EntityCondition entityDateCond = null;
        if (entityFrom != null) {
            entityFromCond = EntityCondition.makeCondition("lastUpdatedTxStamp", EntityComparisonOperator.GREATER_THAN, entityFrom);
        }
        if (entityThru != null) {
            entityThruCond = EntityCondition.makeCondition("lastUpdatedTxStamp", EntityComparisonOperator.LESS_THAN, entityThru);
        }
        if (entityFromCond != null && entityThruCond != null) {
            entityDateCond = EntityCondition.makeCondition(entityFromCond, EntityJoinOperator.AND, entityThruCond);
        } else if (entityFromCond != null) {
            entityDateCond = entityFromCond;
        } else if (entityThruCond != null) {
            entityDateCond = entityThruCond;
        }
        return entityDateCond;
    }

    public static EntityCondition makeEntityDateCond(Delegator delegator, String entityFromStr, String entityThruStr) {
        Timestamp entityFrom = UtilValidate.isNotEmpty(entityFromStr) ? UtilDateTime.toTimestamp(entityFromStr) : null;
        Timestamp entityThru = UtilValidate.isNotEmpty(entityThruStr) ? UtilDateTime.toTimestamp(entityThruStr) : null;
        return makeEntityDateCond(delegator, entityFrom, entityThru);
    }

    public static GenericValue getStdPageSpecialMappingEnumValue(Delegator delegator) throws GenericEntityException {
        // TODO: review
        // this record shouldn't exist in the system, doesn't make sense, so we make a virtual one for UI use only
        GenericValue value = delegator.findOne("Enumeration", UtilMisc.toMap("enumId", "CMS_PGSPCMAP_STD"), true);
        if (value == null) {
            value = delegator.makeValue("Enumeration", UtilMisc.toMap(
                    "enumId", "CMS_PGSPCMAP_STD",
                    "enumCode", "STD",
                    "sequenceId", "01",
                    "enumTypeId", "CMS_PAGE_SPCMAP_TYPE",
                    "description", "Standard"));
            value.setImmutable(); // don't persist it...
        }
        return value;
    }

    public static List<GenericValue> getPageSpecialMappingCondEnumerations(Delegator delegator) throws GenericEntityException {
        List<GenericValue> realValues = delegator.findByAnd("Enumeration",
                UtilMisc.toMap("enumTypeId", "CMS_PAGE_SPCMAP_TYPE"), UtilMisc.toList("sequenceId"), true);

        List<GenericValue> results = new ArrayList<>();
        // TODO/FIXME: we can only do STD & PRIMARY for now, remove this filter later
        boolean hasStd = false;
        for(GenericValue value : realValues) {
            if ("CMS_PGSPCMAP_STD".equals(value.getString("enumId"))) {
                hasStd = true;
                results.add(value);
            } else if ("CMS_PGSPCMAP_PRIMARY".equals(value.getString("enumId"))) {
                results.add(value);
            }
        }
        if (!hasStd) {
            results.add(0, getStdPageSpecialMappingEnumValue(delegator));
        }
        return results;
    }

    public static List<GenericValue> getPageSpecialMappingCondEnumerationsSafe(Delegator delegator) {
        try {
            return getPageSpecialMappingCondEnumerations(delegator);
        } catch (GenericEntityException e) {
            Debug.logError(e, module);
            return Collections.emptyList();
        }
    }

    public static void addPageSpecialMappingConds(Delegator delegator, String pmpsMappingTypeId, Map<String, EntityCondition> entityCondMap) {
        if (UtilValidate.isEmpty(pmpsMappingTypeId)) return;

        // FIXME: these conditions are bad, because we can't do complex queries, but close enough for now
        if ("CMS_PGSPCMAP_STD".equals(pmpsMappingTypeId)) { // this is sort of like "NOT primary", but not exact
            // special case
            addToEntityCondMap(delegator, entityCondMap, "CmsProcessMapping", EntityCondition.makeCondition("primaryForPageId", EntityOperator.EQUALS, null));
            addToEntityCondMap(delegator, entityCondMap, "CmsPageSpecialMapping", EntityCondition.makeCondition("mappingTypeId", EntityOperator.NOT_EQUAL, "CMS_PGSPCMAP_PRIMARY"));
            // FIXME: MISSING CmsProcessViewMapping condition to link to CmsProcessMapping!
        } else if ("CMS_PGSPCMAP_PRIMARY".equals(pmpsMappingTypeId)) {
            addToEntityCondMap(delegator, entityCondMap, "CmsProcessMapping", EntityCondition.makeCondition("primaryForPageId", EntityOperator.NOT_EQUAL, null));
            addToEntityCondMap(delegator, entityCondMap, "CmsPageSpecialMapping", EntityCondition.makeCondition("mappingTypeId", EntityOperator.EQUALS, "CMS_PGSPCMAP_PRIMARY"));
            // FIXME: MISSING CmsProcessViewMapping condition to link to CmsProcessMapping!
        } else {
            // TODO: others would be harder, require an sql "IN"
            throw new UnsupportedOperationException("Process mapping page special mapping filter doesn't yet support: " + pmpsMappingTypeId);
        }
    }

    public static void addAttribTmplAssocTypeConds(Delegator delegator, String attribTmplAssocType, Map<String, EntityCondition> entityCondMap) {
        if (UtilValidate.isEmpty(attribTmplAssocType)) return;
        if ("PAGE_TEMPLATE".equals(attribTmplAssocType)) {
            addToEntityCondMap(delegator, entityCondMap, "CmsAttributeTemplate",
                    EntityCondition.makeCondition("pageTemplateId", EntityOperator.NOT_EQUAL, null));
        } else if ("ASSET_TEMPLATE".equals(attribTmplAssocType)) {
            addToEntityCondMap(delegator, entityCondMap, "CmsAttributeTemplate",
                    EntityCondition.makeCondition("assetTemplateId", EntityOperator.NOT_EQUAL, null));
        } else {
            throw new UnsupportedOperationException("Unrecognized attribTmplAssocType value: " + attribTmplAssocType);
        }
    }

    public static void addEntityPkFilterConds(Delegator delegator, Map<String, ?> entityPks, Map<String, EntityCondition> entityCondMap) {
        for(Map.Entry<String, ?> entry : entityPks.entrySet()) {
            String entityName = entry.getKey();
            Object pkVals = entry.getValue();
            if (pkVals instanceof String) {
                addEntityPkFilterConds(delegator, entityName, (String) pkVals, entityCondMap);
            } else if (pkVals instanceof Collection) {
                addEntityPkFilterConds(delegator, entityName, UtilGenerics.<String>checkCollection(pkVals), entityCondMap);
            }
        }

    }

    public static void addEntityPkFilterConds(Delegator delegator, String entityName, String pkValue, Map<String, EntityCondition> entityCondMap) throws IllegalArgumentException {
        EntityCondition cond = EntityCondition.makeCondition(EntityInfoUtil.getSinglePkFieldNameStrict(delegator, entityName), pkValue);
        addToEntityCondMap(delegator, entityCondMap, entityName, cond);
    }

    public static void addEntityPkFilterConds(Delegator delegator, String entityName, Collection<String> pkValues, Map<String, EntityCondition> entityCondMap) throws IllegalArgumentException {
        String pkFieldName = EntityInfoUtil.getSinglePkFieldNameStrict(delegator, entityName);
        List<EntityCondition> condList = new ArrayList<>(pkValues.size());
        for(String pkValue : pkValues) {
            condList.add(EntityCondition.makeCondition(pkFieldName, pkValue));
        }
        EntityCondition cond = EntityCondition.makeCondition(condList, EntityOperator.OR);
        addToEntityCondMap(delegator, entityCondMap, entityName, cond);
    }


    public static EntityFindOptions getCommonEfo() {
        // TODO: REVIEW: these were from the stock ofbiz groovy
        //return new EntityFindOptions(true, EntityFindOptions.TYPE_SCROLL_INSENSITIVE, EntityFindOptions.CONCUR_READ_ONLY, true);
        return new EntityFindOptions(true, EntityFindOptions.TYPE_SCROLL_INSENSITIVE, EntityFindOptions.CONCUR_READ_ONLY, true);
    }

    /******************************************************/
    /* Mid-Level handler callbacks */
    /******************************************************/
    // DEV NOTE: these handlers were added before I migrated the high-level export methods, since which they've become less useful...

    public void handleBegin() throws GenericEntityException, IOException {
    }

    public void handleFileBegin(PrintWriter writer) throws GenericEntityException, IOException {
        this.setWriter(writer);
    }

    public void handleNewRecordName(String entityName) {
        this.currentRecordName = entityName;
    }

    public void handleEntityQueryBegin(ModelEntity modelEntity, ListIterator<GenericValue> values) throws GenericEntityException, IOException {
        this.setCurrentRecordModel(modelEntity);
    }

    /**
     * Subclass should override if need to handle visitor; default impl ignores visitor.
     */
    protected int handleRecord(CmsEntityVisitor visitor, GenericValue value) throws Exception {
        assert(visitor == null);
        return handleRecordCommon(value);
    }

    public int handleRecordCommon(GenericValue value) throws Exception {
        // SPECIAL: CmsMedia
        if (isMediaContentRecord(value)) {
            return handleMediaContentRecord(value);
        }

        // SPECIAL: workaround for CmsProcessViewMapping filter failure
        if (doSpecialProcessViewMappingFilter && "CmsProcessMapping".equals(value.getEntityName())) {
            seenCmsProcessMappingIds.add(value.getString("processMappingId"));
        }
        int numberWritten = 0;
        if (this.isIncludeContentRefs()) {
            if (currentContentIdFieldNames == null) {
                throw new IllegalStateException("handleRecord was called without setCurrentRecordModel");
            } else if (!currentContentIdFieldNames.isEmpty()) { // NOTE: automatically empty if this is Content itself
                // SCIPIO: export related Content, DataResource, and other linked records
                for(String fieldName : currentContentIdFieldNames) {
                    String contentId = value.getString(fieldName);
                    if (UtilValidate.isNotEmpty(contentId) && !hasSeenContentId(contentId)) {
                        registerContentId(contentId);
                        if (getRecordGrouping() == RecordGrouping.ENTITY_TYPE) {
                            ; // do nothing, we must handle this separately
                        } else {
                            for(GenericValue relatedValue : getContentAndRelatedValues(contentId)) {
                                writeOut(relatedValue);
                                numberWritten++;
                            }
                        }
                    }
                }
            }
        }
        writeOut(value);
        numberWritten++;
        return numberWritten;
    }

    public void writeOut(GenericValue value) throws GenericEntityException, IOException {
        value.writeXmlText(writer, "", entityCdataFields.get(value.getEntityName())); // NOTE: 3rd parameter is a SCIPIO patch
    }

    public void handleEntityQueryEnd(ListIterator<GenericValue> values) throws GenericEntityException, IOException {
        this.setCurrentRecordModel(null);
    }

    public void handleFileEnd() throws GenericEntityException, IOException {
        if (isMultiFile()) {
            this.setWriter(null);
        }
    }

    public void handleEnd() throws GenericEntityException, IOException {
        this.setWriter(null);
    }

    /******************************************************/
    /* High-Level Execution and Implementations */
    /******************************************************/

    // Public execution methods

    public static class ExecResult {
        protected List<String> errorMsgs = new ArrayList<>();
        protected List<String> resultMsgs = new ArrayList<>();
        protected int numberWritten;

        protected void addMsg(String msg) { resultMsgs.add(msg); }
        protected void addErrorMsg(String msg) { errorMsgs.add(msg); }

        public boolean isSuccess() { return errorMsgs.isEmpty(); }
        public List<String> getErrorMessages() { return errorMsgs; }
        public List<String> getResultMessages() { return resultMsgs; }
        public int getNumberWritten() { return numberWritten; }
    }

    /**
     * Executes export to the writer, IF single-writer output is supported.
     */
    public ExecResult executeExport(PrintWriter writer) throws Exception {
         // SCIPIO: suspend existing trans to prevent screen render aborts
         Transaction suspendedTrans = null;
         try {
             suspendedTrans = suspendTrans();
             return executeExportInternal(writer);
         } finally {
             resumeTrans(suspendedTrans);
         }
    }

    public ExecResult executeExport(Writer writer) throws Exception {
        Transaction suspendedTrans = null;
        try {
            suspendedTrans = suspendTrans();
            return executeExportInternal(new PrintWriter(writer));
        } finally {
            resumeTrans(suspendedTrans);
        }
    }

    public ExecResult executeExport(File outFile) throws Exception {
        Transaction suspendedTrans = null;
        try {
            suspendedTrans = suspendTrans();
            return executeExportInternal(outFile);
        } finally {
            resumeTrans(suspendedTrans);
        }
    }


    // Real/Internal Implementations

    protected abstract ExecResult executeExportInternal(PrintWriter writer) throws Exception;
    protected abstract ExecResult executeExportInternal(File outFile) throws Exception;

    protected EntityListIterator doMainEntityQuery(ModelEntity me) throws GenericEntityException {
        return delegator.find(me.getEntityName(), getEffectiveEntityCond(me.getEntityName(), me.getNoAutoStamp()), null, null, me.getPkFieldNames(), getMainEfo());
    }

    protected <T extends ListIterator<GenericValue>> T closeIteratorSafe(T values) {
        if (values instanceof EntityListIterator) {
            try {
                ((EntityListIterator) values).close();
            } catch(Exception e) {
                Debug.logError(e, LOG_PREFIX+"Error closing EntityListIterator: " + e.getMessage(), module);
            }
        }
        return null;
    }

    protected Transaction suspendTrans() throws GenericTransactionException {
        try {
            return (useTrans && this.newTrans) ? TransactionUtil.suspend() : null;
        } catch (GenericTransactionException e) {
            Debug.logError(e, LOG_PREFIX+"Error suspending transaction: " + e.getMessage(), module);
            throw e;
        }
    }

    protected boolean beginTrans() throws GenericTransactionException {
        return useTrans ? TransactionUtil.begin(this.transTimeout) : false;
    }

    protected void resumeTrans(Transaction trans) throws GenericTransactionException {
        if (trans != null) {
            try {
                TransactionUtil.resume(trans);
            } catch (GenericTransactionException e) {
                Debug.logError(e, LOG_PREFIX+"Error resuming suspended transaction: " + e.getMessage(), module);
            }
        }
    }

    protected ModelEntity resolveModelEntity(String entityName) throws GenericEntityException {
        if ("CmsMedia".equals(entityName)) entityName = "Content";
        ModelEntity me = delegator.getModelReader().getModelEntity(entityName);
        if (me instanceof ModelViewEntity) throw new IllegalArgumentException("passed entity name was a View entity - not supported by CMS exporter: " + entityName);
        return me;
    }

    protected boolean isMediaContentRecord(GenericValue value) {
        // TODO: REVIEW: check
        return "Content".equals(value.getEntityName()) && "SCP_MEDIA".equals(value.getString("contentTypeId"));
    }

    protected int handleMediaContentRecord(GenericValue content) throws GenericEntityException, IOException {
        int numberWritten = 0;
        String contentId = content.getString("contentId");

        if (UtilValidate.isNotEmpty(content.getString("dataResourceId"))) {
            numberWritten += handleMediaDataResourceRecord(content.getRelatedOne("DataResource", false));
        }

        writeOut(content);
        numberWritten++;

        for(GenericValue attr : content.getRelated("ContentAttribute", null, null, false)) {
            writeOut(attr);
            numberWritten++;
        }

        List<GenericValue> assocList = delegator.findList("ContentAssoc",
                EntityCondition.makeCondition("contentId", contentId), null, null, null, false);
        for(GenericValue assoc : assocList) {
            GenericValue contentTo = assoc.getRelatedOne("ToContent", false);
            if (!isMediaExportVariants() && "SCP_MEDIA_VARIANT".equals(contentTo.getString("contentTypeId"))) {
                continue;
            }
            numberWritten += handleMediaContentRecord(contentTo);
            writeOut(assoc);
            numberWritten++;
        }

        return numberWritten;
    }

    protected int handleMediaDataResourceRecord(GenericValue dataRes) throws GenericEntityException, IOException {
        int numberWritten = 0;

        writeOut(dataRes);
        numberWritten++;

        for(GenericValue attr : dataRes.getRelated("DataResourceAttribute", null, null, false)) {
            writeOut(attr);
            numberWritten++;
        }

        GenericValue elecText = dataRes.getRelatedOne("ElectronicText", false);
        if (elecText != null) {
            writeOut(elecText);
            numberWritten++;
        }

        SpecDataResEntityInfo specDataResInfo = SpecDataResEntityInfo.fromDataResource(dataRes);
        if (specDataResInfo != null) {
            GenericValue specDataRes = specDataResInfo.getMediaDataResourceFromDataResource(dataRes, false);
            if (specDataRes != null) {
                writeOut(specDataRes);
                numberWritten++;
            }
        }

        return numberWritten;
    }

    protected static boolean crossedInterval(int curCount, int lastCount, int interval) {
        // treat first as crossed
        if (lastCount == 0) return true;

        // round up lastCount to next interval
        int boundary = ((lastCount / interval) * interval) + interval; // (integer div)

        // did we reach it?
        return (curCount >= boundary);
    }


    // TODO: REVIEW: this class design is poor, I'm trying to figure it out as we go along

    /**
     * Generic worker, doesn't implement any executeExport method, all implementation in CmsDataExportWorker.
     */
    public static class GenericWorker extends CmsDataExportWorker {
        protected GenericWorker(CommonWorkerArgs<?> args) throws IllegalArgumentException { super(args); }
        protected GenericWorker(CmsDataExportWorker other, Delegator delegator) { super(other, delegator); }
        @Override public CmsDataExportWorker cloneWorkerNewState(Delegator delegator) { return new GenericWorker(this, delegator);}

        @Override public boolean isMultiFile() { return false; }
        @Override protected RecordGrouping getRecordGrouping() { return RecordGrouping.NONE; }
        @Override public ExecResult executeExportInternal(PrintWriter writer) throws Exception { throw new UnsupportedOperationException(); }
        @Override public ExecResult executeExportInternal(File outFile) throws Exception { throw new UnsupportedOperationException(); }
    }

    /**
     * Single file exporter.
     */
    public static class SingleFileWorker extends CmsDataExportWorker {
        protected final RecordGrouping recordGrouping;

        protected SingleFileWorker(CommonWorkerArgs<?> args)
                throws IllegalArgumentException {
            super(args);
            this.recordGrouping = args.getRecordGrouping() != null ? args.getRecordGrouping() : RecordGrouping.getDefault();
        }

        protected SingleFileWorker(SingleFileWorker other, Delegator delegator) {
            super(other, delegator);
            this.recordGrouping = other.recordGrouping;
        }
        @Override
        public CmsDataExportWorker cloneWorkerNewState(Delegator delegator) {
            return new SingleFileWorker(this, delegator);
        }

        @Override
        public RecordGrouping getRecordGrouping() {
            return recordGrouping;
        }

        @Override
        public boolean isMultiFile() {
            return false;
        }

        @Override
        protected ExecResult executeExportInternal(File outFile) throws Exception {
            PrintWriter writer = new PrintWriter(new BufferedWriter(new OutputStreamWriter(new FileOutputStream(outFile), "UTF-8")));
            try {
                return executeExportInternal(writer);
            } finally {
                try {
                    writer.close();
                } catch(Exception e) {
                    ;
                }
            }
        }

        @Override
        protected ExecResult executeExportInternal(PrintWriter writer) throws Exception {
            ExecResult result = new ExecResult();

            writer.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
            writer.println("<entity-engine-xml>");

            int numberWritten = 0;
            boolean beganTransaction;

            PrintWriter origWriter = writer;
            StringWriter tempWriter = null;
            boolean specialTypeGrouping = (getRecordGrouping() == RecordGrouping.ENTITY_TYPE) && this.isIncludeContentRefs();
            if (specialTypeGrouping) {
                // FIXME: if we need to output by entity type in a single file, we have to output Content records
                // BEFORE everything else.
                // this causes a performance problem because we have to buffer everything before in memory.
                // there is no other real solution available currently.
                tempWriter = new StringWriter();
                writer = new PrintWriter(tempWriter);
            }

            this.handleBegin();
            this.handleFileBegin(writer);

            for(String curEntityName : getTargetEntityNames()) {
                Collection<String> slaveRecords = entityInfo.getMajorEntityNamesExpandMap().get(curEntityName);
                if (slaveRecords != null) {
                    for(String slaveEntityName : slaveRecords) {
                        numberWritten += exportEntityRecords(null, slaveEntityName, numberWritten);
                    }
                } else {
                    numberWritten += exportEntityRecords(null, curEntityName, numberWritten);
                }
            }

            if (specialTypeGrouping) {
                // we write the Content records to the origWriter so they come before
                // everything else; then add our string buffer
                writer.flush();
                writer = origWriter;
                this.setWriter(writer);

                Map<String, List<GenericValue>> valuesByEntity = Collections.emptyMap();
                beganTransaction = false;
                try {
                    beganTransaction = beginTrans();
                    valuesByEntity = getContentAndRelatedValuesByEntity(getSeenContentIds());
                    TransactionUtil.commit(beganTransaction);
                } catch(Exception e) {
                    handleError(e, beganTransaction);
                }

                for(Map.Entry<String, List<GenericValue>> entry : valuesByEntity.entrySet()) {
                    String curEntityName = entry.getKey();
                    List<GenericValue> entityValues = entry.getValue();
                    if (entityValues == null || entityValues.isEmpty()) continue;
                    numberWritten += exportEntityRecords(null, curEntityName, entityValues.listIterator(), numberWritten);
                }
                // append the temp buffer
                writer.append(tempWriter.getBuffer());
            }

            if (getTargetSpecialEntityNames().contains("CmsMedia")) {
                // FIXME: non-integrated solution for now
                numberWritten += exportEntityRecords(null, "CmsMedia", numberWritten);
            }

            this.handleFileEnd();
            this.handleEnd();

            writer.println("</entity-engine-xml>");
            Debug.logInfo(LOG_PREFIX+"Total records written from all entities: " + numberWritten, module);
            result.numberWritten = numberWritten;
            return result;
        }

        protected int exportEntityRecords(CmsEntityVisitor visitor, String curEntityName, int numberWritten) throws Exception {
            return exportEntityRecords(visitor, curEntityName, null, numberWritten);
        }

        protected int exportEntityRecords(CmsEntityVisitor visitor, String curEntityName, ListIterator<GenericValue> values, int numberWritten) throws Exception {
            boolean beganTransaction = false;
            this.handleNewRecordName(curEntityName);
            int curNumberWritten = 0;
            try {
                beganTransaction = beginTrans();
                ModelEntity me = resolveModelEntity(curEntityName);

                // FIXME?: hardcoded special case, doesn't really belong here
                if ("CmsMedia".equals(curEntityName) && isMediaExportVariants()) {
                    ListIterator<GenericValue> catValues = null;
                    try {
                        catValues = CmsMediaWorker.findVariantContentAssocTypes(delegator);
                        ModelEntity catme = resolveModelEntity("ContentAssocType");
                        curNumberWritten += handleEntityRecordsCore(null, "ContentAssocType", catme, catValues, numberWritten);
                        numberWritten += curNumberWritten;
                    } finally {
                        catValues = closeIteratorSafe(catValues);
                    }
                }

                if (values == null) {
                    values = doMainEntityQuery(me);
                }

                curNumberWritten += handleEntityRecordsCore(visitor, curEntityName, me, values, numberWritten);
                numberWritten += curNumberWritten;

                values = closeIteratorSafe(values);
                TransactionUtil.commit(beganTransaction);
                Debug.logInfo(LOG_PREFIX+"Committed records [" + curEntityName + "]: Main query: " + curNumberWritten + "; Total All: " + numberWritten, module);
            } catch (Exception e) {
                values = closeIteratorSafe(values);
                handleError(e, beganTransaction);
            }
            return curNumberWritten;
        }

        protected int handleEntityRecordsCore(CmsEntityVisitor visitor, String curEntityName, ModelEntity me, ListIterator<GenericValue> values, int numberWritten) throws Exception {
            this.handleEntityQueryBegin(me, values);
            GenericValue value = null;
            int curNumberWritten = 0;
            int lastNumberWritten = 0;
            boolean isEntityListIterator = (values instanceof EntityListIterator);
            while ((isEntityListIterator || values.hasNext()) && ((value = values.next()) != null)) {
                int numAdded = this.handleRecord(visitor, value);
                numberWritten += numAdded;
                curNumberWritten += numAdded;
                if (crossedInterval(curNumberWritten, lastNumberWritten, LOG_RECORD_COUNT_INTERVAL)) {
                    Debug.logInfo(LOG_PREFIX+"Records written [" + curEntityName + "]: Main query: " + curNumberWritten + "; Total All: " + numberWritten, module);
                }
                lastNumberWritten = curNumberWritten;
            }
            this.handleEntityQueryEnd(values);
            return curNumberWritten;
        }

        protected void handleError(Exception e, boolean beganTransaction) throws Exception {
            String errMsg = "Failure in operation, rolling back transaction";
            Debug.logError(e, LOG_PREFIX+errMsg, module);
            try {
                // only rollback the transaction if we started one...
                TransactionUtil.rollback(beganTransaction, errMsg, e);
            } catch (GenericEntityException e2) {
                Debug.logError(e2, LOG_PREFIX+"Could not rollback transaction: " + e2.toString(), module);
            }
            // after rolling back, rethrow the exception
            throw e;
        }
    }

    /**
     * Special dedicated instance to try to implement the object grouping code.
     * NOTE: this will be several times slower than the others, which makes it bad for large exports.
     * DEV NOTE: may need re-refactoring later, trying to figure this out as we go along.
     */
    public static class ObjGrpSingleFileWorker extends SingleFileWorker {
        protected final boolean includeMajorDeps;
        protected final Set<String> enterEntityNames;
        protected final Set<String> enterMajorEntityNames;
        protected ObjGrpSingleFileWorker(CommonWorkerArgs<?> args) throws IllegalArgumentException {
            super(overrideArgs(args));
            this.includeMajorDeps = args.isIncludeMajorDeps();
            this.enterEntityNames = args.getEffectiveEnterEntityNames();
            this.enterMajorEntityNames = args.getEffectiveEnterMajorEntityNames();
        }
        private static CommonWorkerArgs<?> overrideArgs(CommonWorkerArgs<?> args) { // java kludge
            args.setRecordGrouping(RecordGrouping.MAJOR_OBJECT);
            return args;
        }
        protected ObjGrpSingleFileWorker(ObjGrpSingleFileWorker other, Delegator delegator) {
            super(other, delegator);
            this.includeMajorDeps = other.includeMajorDeps;
            this.enterEntityNames = other.enterEntityNames;
            this.enterMajorEntityNames = other.enterMajorEntityNames;
        }
        @Override
        public ObjGrpSingleFileWorker cloneWorkerNewState(Delegator delegator) {
            return new ObjGrpSingleFileWorker(this, delegator);
        }

        @Override
        public RecordGrouping getRecordGrouping() { return RecordGrouping.MAJOR_OBJECT; }

        @Override
        protected ExecResult executeExportInternal(PrintWriter writer) throws Exception {
            ExecResult result = new ExecResult();

            writer.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
            writer.println("<entity-engine-xml>");

            int numberWritten = 0;

            this.handleBegin();
            this.handleFileBegin(writer);

            Set<String> targetMajorEntityNames = entityInfo.filterMajorCmsEntityNames(this.getTargetEntityNames());
            boolean imageSizePreset = targetMajorEntityNames.remove("ImageSizePreset");

            // SPECIAL: in record-grouping mode, if CMS_PGSPCMAP_PRIMARY was targeted and CmsPage
            // was included, we'll remove CmsProcessMapping from the entities to target because already included in the CmsPage visiting
            // and this simplifies to PK filter
            // NOTE: order of entities (CmsPage first) may also affect results of this
            // FIXME: this check is flawed but just working enough for now
            if (targetMajorEntityNames.contains("CmsPage") && targetMajorEntityNames.contains("CmsProcessMapping")) {
                if (UtilValidate.isNotEmpty(this.pmpsMappingTypeId) && !"CMS_PGSPCMAP_STD".equals(this.pmpsMappingTypeId)) {
                    targetMajorEntityNames.remove("CmsProcessMapping");
                }
            }

            ObjGrpEntityVisitor visitor = new ObjGrpEntityVisitor(delegator);
            visitor.setEnterContent(this.isIncludeContentRefs()); // usually do enter content records, unless disabled
            visitor.setEnterMajor(includeMajorDeps); // usually don't enter deep deps, unless enabled

            visitor.setEnterEntityNames(enterEntityNames);
            visitor.setEnterMajorEntityNames(enterMajorEntityNames);

            for(String curEntityName : targetMajorEntityNames) {
                Collection<String> slaveRecords = entityInfo.getMajorEntityNamesExpandMap().get(curEntityName);
                if (slaveRecords != null) {
                    for(String slaveEntityName : slaveRecords) {
                        numberWritten += exportEntityRecords(visitor, slaveEntityName, numberWritten);
                    }
                } else {
                    numberWritten += exportEntityRecords(visitor, curEntityName, numberWritten);
                }
            }

            if (imageSizePreset || getTargetSpecialEntityNames().contains("ImageSizePreset")) {
                for(String entityName : entityInfo.getMajorEntityNamesExpandMap().get("ImageSizePreset")) {
                    // FIXME: non-integrated solution for now (NOTE: do not pass visitor!)
                    numberWritten += exportEntityRecords(null, entityName, numberWritten);
                }
            }
            // FIXME: non-integrated solution for now
            if (getTargetSpecialEntityNames().contains("CmsMedia")) {
                // FIXME: non-integrated solution for now (NOTE: do not pass visitor!)
                numberWritten += exportEntityRecords(null, "CmsMedia", numberWritten);
            }

            this.handleFileEnd();
            this.handleEnd();

            writer.println("</entity-engine-xml>");
            Debug.logInfo(LOG_PREFIX+"Total records written from all entities: " + numberWritten, module);
            result.numberWritten = numberWritten;
            return result;
        }

        @Override
        protected int handleRecord(CmsEntityVisitor visitorObj, GenericValue value) throws Exception {
            if (visitorObj == null) return handleRecordCommon(value);

            ObjGrpEntityVisitor visitor = (ObjGrpEntityVisitor) visitorObj;
            visitor.setNumberWritten(0);

            DataObjectWorker<?> dataObjWorker = CmsObjectRegistry.getEntityDataObjectWorkerAlways(value.getEntityName());
            CmsMajorObject majorDataObj = (CmsMajorObject) dataObjWorker.makeFromValue(value);

            // begin visiting
            majorDataObj.acceptEntityDepsVisitor(visitor, null, null, majorDataObj);

            return visitor.getNumberWritten();
        }
    }

    /**
     * Object grouping entity visitor implementation.
     * TODO: REVIEW
     */
    protected class ObjGrpEntityVisitor extends CmsEntityVisit.AbstractCmsEntityVisitor {
        protected ObjGrpVisitContext visitContext;
        protected int numberWritten = 0;
        protected Set<String> seenMajorEntities = new HashSet<>(); // entityName + "@" + PK
        // NOTE: seenAllEntities could get very big, so we only use seenMajorEntities in production
        protected Set<String> seenAllEntities = CmsUtil.debugOn() ? new HashSet<String>() : null;

        public ObjGrpEntityVisitor(Delegator delegator) {
            super(delegator);
            this.visitContext = new ObjGrpVisitContext();
        }

        @Override
        public ObjGrpVisitContext getVisitContext() { return visitContext; }

        public int getNumberWritten() { return numberWritten; }
        public void setNumberWritten(int numberWritten) { this.numberWritten = numberWritten; }

        protected boolean hasSeenMajorEntity(String entityName, String pk) {
            final String key = entityName + "@" + pk;
            if (seenMajorEntities.contains(key)) {
                if (CmsUtil.verboseOn()) {
                    Debug.logInfo(LOG_PREFIX+"DUP: Prevented duplicate major entity: " + key, module);
                }
                return true;
            } else {
                return false;
            }
        }

        protected void registerMajorEntity(String entityName, String pk) {
            seenMajorEntities.add(entityName + "@" + pk);
        }

        // PRE-LOOKUP FILTERS

        @Override
        protected boolean shouldEnterMajor(VisitRelation relation, GenericValue relValue, CmsMajorObject majorDataObj) {
            // Here we check if the PKs of given major entities were already visited (one relations)
            // WARN: we can only check seen entities in pre-filter for ONE relations
            if (relation.getDataRelType().isOneAny()) {
                String pk = relation.extractRelOneEntitySingleFieldPk(relValue);
                if (pk == null) return false; // won't be a value, can stop early
                if (hasSeenMajorEntity(relation.getRelEntityName(), pk)) return false;
            }
            return true;
        }

        @Override
        protected boolean shouldEnterContent(VisitRelation relation, GenericValue relValue,
                CmsMajorObject majorDataObj) {
            boolean seen = hasSeenContentId(relation.extractRelOneEntitySingleFieldPk(relValue));
            if (CmsUtil.verboseOn()) {
                if (seen) {
                    Debug.logInfo(LOG_PREFIX+"DUP: Prevented duplicate Content record: " + relation.extractRelOneEntitySingleFieldPk(relValue), module);
                }
            }
            return !seen;
        }

        // POST-LOOKUP FILTERS

        @Override
        public boolean shouldEnter(GenericValue value, VisitRelation relation, GenericValue relValue, CmsMajorObject majorDataObj) {
            // Here we check if the PKs of given major entities were already visited (many & self relations)
            // NOTE: we only need to check MANY relations here because one covered in pre-filter, and
            // as a special case must also check SELF relation (which will be major at same time)
            if (relation.isMajor() && (relation.getDataRelType().isMany() || relation.getDataRelType().isSelf())) {
                if (hasSeenMajorEntity(value.getEntityName(), value.getPkShortValueString())) return false;
            }
            return true;
        }

        @Override
        public void visit(GenericValue value, VisitRelation relation, GenericValue relValue, CmsMajorObject majorDataObj) throws Exception {
            visitRecordOnly(value, relation, relValue, majorDataObj);
            visitWriteOnly(value, relation, relValue, majorDataObj);
        }

        @Override
        public void visitRecordOnly(GenericValue value, VisitRelation relation, GenericValue relValue,
                CmsMajorObject majorDataObj) throws Exception {
            if (relation != null) { // WARN: some case like DataResource passing null for this for now...
                if (relation.isContentPrimary()) { // WARN: check will be insufficient later, for now assume this is main Content entity only
                    if (CmsUtil.verboseOn()) {
                        if (hasSeenContentId(value.getString("contentId"))) {
                            Debug.logError(LOG_PREFIX+"Unexpected duplicate contentId in visit method: " + value.getString("contentId"), module);
                        }
                    }
                    registerContentId(value.getString("contentId"));
                } else if (relation.isMajor()) { // NOTE: includes isSelf() at major boundaries
                    if (CmsUtil.verboseOn()) {
                        if (hasSeenMajorEntity(value.getEntityName(), value.getPkShortValueString())) {
                            Debug.logError(LOG_PREFIX+"Unexpected duplicate major entity in visit method: " + value.getEntityName() + "@" + value.getPkShortValueString(), module);
                        }
                    }
                    registerMajorEntity(value.getEntityName(), value.getPkShortValueString());
                }
            }
            if (seenAllEntities != null) {
                // this is for debugging purposes only, we don't want this on large exports
                final String key = value.getEntityName() + "@" + value.getPkShortValueString();
                if (seenAllEntities.contains(key)) {
                    Debug.logError(LOG_PREFIX+"Unexpected duplicate entity in visit method: " + key, module);
                } else {
                    seenAllEntities.add(key);
                }
            }
        }

        @Override
        public void visitWriteOnly(GenericValue value, VisitRelation relation, GenericValue relValue,
                CmsMajorObject majorDataObj) throws Exception {
            // NOTE: the numberWritten is never used excess as statistic so don't consider part of state.
            writeOut(value);
            numberWritten++;
        }

        public class ObjGrpVisitContext extends CmsEntityVisit.AbstractCmsEntityVisitor.AbstractVisitContext {
            @Override
            public boolean isExportFilesAsTextData() {
                return exportFilesAsTextData;
            }
        }
    }

    /**
     * Multi-file directory exporter.
     */
    public static class MultiFileWorker extends CmsDataExportWorker {
        protected int maxRecordsPerFile;

        protected MultiFileWorker(CommonWorkerArgs<?> args) throws IllegalArgumentException {
            super(args);
            this.maxRecordsPerFile = args.getMaxRecordsPerFile();
        }
        protected MultiFileWorker(MultiFileWorker other, Delegator delegator) {
            super(other, delegator);
        }
        @Override
        public CmsDataExportWorker cloneWorkerNewState(Delegator delegator) {
            return new MultiFileWorker(this, delegator);
        }

        @Override
        public RecordGrouping getRecordGrouping() { return RecordGrouping.ENTITY_TYPE; }
        @Override
        public boolean isMultiFile() { return true; }

        @Override
        protected ExecResult executeExportInternal(PrintWriter writer) {
            throw new UnsupportedOperationException("MultiFileWorker can't export to a single Writer");
        }

        @Override
        protected ExecResult executeExportInternal(File outdir) throws Exception {
            ExecResult result = new ExecResult();
            boolean beganTransaction;

            if (outdir.isDirectory() && outdir.canWrite()) {
                // SCIPIO: we start at 4 so that Content, DataResource & ElectronicText will get loaded first,
                // and leaving space for others...
                int fileNumber = 10;
                this.handleBegin();

                for(String curEntityName : getTargetEntityNames()) {
                    Collection<String> slaveRecords = entityInfo.getMajorEntityNamesExpandMap().get(curEntityName);
                    if (slaveRecords != null) {
                        for(String slaveEntityName : slaveRecords) {
                            fileNumber += exportEntityRecords(null, slaveEntityName, result, fileNumber, outdir, true);
                        }
                    } else {
                        fileNumber += exportEntityRecords(null, curEntityName, result, fileNumber, outdir, true);
                    }
                }

                Map<String, List<GenericValue>> valuesByEntity = Collections.emptyMap();
                beganTransaction = false;
                try {
                    beganTransaction = beginTrans();
                    valuesByEntity = getContentAndRelatedValuesByEntity(getSeenContentIds());
                    TransactionUtil.commit(beganTransaction);
                } catch(Exception e) {
                    handleError(e, "Error when querying additional Content(/DataResource/ElectronicText) records: " + e.getMessage(), result, beganTransaction);
                }

                // SCIPIO: now do the Content values
                fileNumber = 1;
                for(Map.Entry<String, List<GenericValue>> entry : valuesByEntity.entrySet()) {
                    if (entry.getValue().isEmpty()) continue;
                    fileNumber += exportEntityRecords(null, entry.getKey(), entry.getValue().listIterator(), result, fileNumber, outdir, false);
                }

                if (getTargetSpecialEntityNames().contains("CmsMedia")) {
                    // FIXME: non-flexible solution, but it's not that bad for now
                    fileNumber += exportEntityRecords(null, "CmsMedia", result, fileNumber, outdir, true);
                }

                this.handleEnd();
            }

            return result;
        }

        protected int exportEntityRecords(CmsEntityVisitor visitor, String curEntityName, ExecResult result, int fileNumber, File outdir, boolean doHooks) throws Exception {
            return exportEntityRecords(visitor, curEntityName, null, result, fileNumber, outdir, doHooks);
        }

        protected int exportEntityRecords(CmsEntityVisitor visitor, String curEntityName, ListIterator<GenericValue> values,
                ExecResult result, int fileNumber, File outdir, boolean doHooks) throws Exception {
            this.handleNewRecordName(curEntityName);
            boolean beganTransaction = false;
            try {
                beganTransaction = beginTrans();

                ModelEntity me = resolveModelEntity(curEntityName);

                // FIXME?: hardcoded special case, doesn't really belong here
                if ("CmsMedia".equals(curEntityName) && isMediaExportVariants()) {
                    ListIterator<GenericValue> catValues = null;
                    try {
                        catValues = CmsMediaWorker.findVariantContentAssocTypes(delegator);
                        ModelEntity catme = resolveModelEntity("ContentAssocType");
                        handleEntityRecordsCore(null, "ContentAssocType", catme, catValues, result, fileNumber, outdir, doHooks);
                    } finally {
                        catValues = closeIteratorSafe(catValues);
                    }
                }

                if (values == null) {
                    values = doMainEntityQuery(me);
                }

                handleEntityRecordsCore(visitor, curEntityName, me, values, result, fileNumber, outdir, doHooks);

                values = closeIteratorSafe(values);
                // only commit the transaction if we started one... this will throw an exception if it fails
                TransactionUtil.commit(beganTransaction);
            } catch (Exception e) {
                values = closeIteratorSafe(values);
                handleEntityWriteError(e, result, beganTransaction, curEntityName, fileNumber);
            }
            return 1;
        }

        // TODO: REVIEW: can't remember why doHooks was here...
        protected void handleEntityRecordsCore(CmsEntityVisitor visitor, String curEntityName, ModelEntity me, ListIterator<GenericValue> values,
                ExecResult result, int fileNumber, File outdir, boolean doHooks) throws Exception {
            int numberWritten = 0;
            String fileName = (maxRecordsPerFile > 0) ? UtilFormatOut.formatPaddedNumber((long) fileNumber, 3) + "_" : "";
            fileName = fileName + curEntityName;

            this.handleEntityQueryBegin(me, values);

            boolean isFirst = true;
            PrintWriter writer = null;
            int fileSplitNumber = 1;
            GenericValue value;
            boolean isEntityListIterator = (values instanceof EntityListIterator);
            while ((isEntityListIterator || values.hasNext()) && ((value = values.next()) != null)) {
                //Don't bother writing the file if there's nothing
                //to put into it
                if (isFirst) {
                    writer = new PrintWriter(new BufferedWriter(new OutputStreamWriter(new FileOutputStream(new File(outdir, fileName +".xml")), "UTF-8")));
                    writer.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
                    writer.println("<entity-engine-xml>");
                    isFirst = false;
                    this.handleFileBegin(writer);
                }

                numberWritten += this.handleRecord(visitor, value);

                // split into small files
                if (maxRecordsPerFile > 0 && (numberWritten % maxRecordsPerFile == 0)) {
                    fileSplitNumber++;
                    this.handleFileEnd();
                    // close the file
                    writer.println("</entity-engine-xml>");
                    writer.close();

                    // create a new file
                    String splitNumStr = UtilFormatOut.formatPaddedNumber((long) fileSplitNumber, 3);
                    writer = new PrintWriter(new BufferedWriter(new OutputStreamWriter(new FileOutputStream(new File(outdir, fileName + "_" + splitNumStr +".xml")), "UTF-8")));
                    writer.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
                    writer.println("<entity-engine-xml>");
                    this.handleFileBegin(writer);
                }

                // FIXME: MODULUS DOES NOT WORK
                if (numberWritten % 500 == 0 || numberWritten == 1) {
                    Debug.logInfo(LOG_PREFIX+"Records written [" + curEntityName + "]: " + numberWritten, module);
                }
            }

            this.handleEntityQueryEnd(values);

            if (writer != null) {
                this.handleFileEnd();
                writer.println("</entity-engine-xml>");
                writer.close();
                String thisResult = "[" + fileNumber + "] [" + numberWritten + "] " + curEntityName + " wrote " + numberWritten + " records";
                Debug.logInfo(LOG_PREFIX+thisResult, module);
                result.addMsg(thisResult);
            } else {
                String thisResult = "[" + fileNumber + "] [---] " + curEntityName + " has no records, not writing file";
                Debug.logInfo(LOG_PREFIX+thisResult, module);
                result.addMsg(thisResult);
            }
        }

        private void handleError(Exception e, String errorMsg, ExecResult result, boolean beganTransaction) throws Exception {
            Debug.logError(e, LOG_PREFIX+errorMsg, module);
            result.addMsg(errorMsg);
            try {
                TransactionUtil.rollback(beganTransaction, errorMsg, e);
            } catch (GenericEntityException e2) {
                Debug.logError(e2, LOG_PREFIX+"Could not rollback transaction: " + e2.toString(), module);
            }
            throw e;
        }

        private void handleEntityWriteError(Exception e, ExecResult result, boolean beganTransaction, String curEntityName, int fileNumber) throws Exception {
            handleError(e, "[" + fileNumber + "] Error while writing " + curEntityName + ": " + e.getMessage(), result, beganTransaction);
        }
    }

}