From c65132f754e632f279facd1346e6fee7348a5581 Mon Sep 17 00:00:00 2001 From: canonical Date: Thu, 2 Jan 2025 20:20:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E8=BF=9BImportModelToExportModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/io/nop/api/core/beans/DictBean.java | 5 + .../io/nop/api/core/beans/DictOptionBean.java | 9 + .../model/table/tree/TreeTableLayout.java | 6 +- .../nop/excel/imp/model/ImportFieldModel.java | 7 + .../nop/excel/model/ExcelDataValidation.java | 7 + .../xlsx/imp/ImportModelToExportModel.java | 96 ++- .../nop/ooxml/xlsx/imp/TreeObjectLayout.java | 123 +++- .../imp/TestImportModelToExportModel.java | 36 ++ .../_vfs/test/test-imp-to-excel.imp.xml | 587 ++++++++++++++++++ 9 files changed, 832 insertions(+), 44 deletions(-) create mode 100644 nop-ooxml/nop-ooxml-xlsx/src/test/java/io/nop/ooxml/xlsx/imp/TestImportModelToExportModel.java create mode 100644 nop-ooxml/nop-ooxml-xlsx/src/test/resources/_vfs/test/test-imp-to-excel.imp.xml diff --git a/nop-api-core/src/main/java/io/nop/api/core/beans/DictBean.java b/nop-api-core/src/main/java/io/nop/api/core/beans/DictBean.java index 575da0a19..618574e47 100644 --- a/nop-api-core/src/main/java/io/nop/api/core/beans/DictBean.java +++ b/nop-api-core/src/main/java/io/nop/api/core/beans/DictBean.java @@ -226,6 +226,11 @@ public List getValues() { return options.stream().map(DictOptionBean::getValue).collect(Collectors.toList()); } + @JsonIgnore + public List getStringValues(){ + return options.stream().map(DictOptionBean::getStringValue).collect(Collectors.toList()); + } + /** * 将下拉选项的label转换为value-label形式 */ diff --git a/nop-api-core/src/main/java/io/nop/api/core/beans/DictOptionBean.java b/nop-api-core/src/main/java/io/nop/api/core/beans/DictOptionBean.java index 60da38815..b9a954325 100644 --- a/nop-api-core/src/main/java/io/nop/api/core/beans/DictOptionBean.java +++ b/nop-api-core/src/main/java/io/nop/api/core/beans/DictOptionBean.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import io.nop.api.core.annotations.data.DataBean; @@ -22,6 +23,7 @@ import java.io.Serializable; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import static io.nop.api.core.util.FreezeHelper.checkNotFrozen; @@ -85,6 +87,13 @@ public void setValue(Object value) { this.value = value; } + @JsonIgnore + public String getStringValue() { + if (value == null) + return ""; + return Objects.toString(value, ""); + } + @PropMeta(propId = 3) @JsonInclude(Include.NON_EMPTY) public String getDescription() { diff --git a/nop-core/src/main/java/io/nop/core/model/table/tree/TreeTableLayout.java b/nop-core/src/main/java/io/nop/core/model/table/tree/TreeTableLayout.java index 21c02b9e5..fffb305ff 100644 --- a/nop-core/src/main/java/io/nop/core/model/table/tree/TreeTableLayout.java +++ b/nop-core/src/main/java/io/nop/core/model/table/tree/TreeTableLayout.java @@ -78,7 +78,11 @@ void calcBbox(TreeCell cell) { calcBbox(child); } - switch (cell.getChildPos()) { + TreeCellChildPosition pos = cell.getChildPos(); + if (pos == null) + pos = TreeCellChildPosition.ver; + + switch (pos) { case right_hor: case left_hor: { int w = sumBboxWidth(children); diff --git a/nop-excel/src/main/java/io/nop/excel/imp/model/ImportFieldModel.java b/nop-excel/src/main/java/io/nop/excel/imp/model/ImportFieldModel.java index fb5a4b705..0965161b3 100644 --- a/nop-excel/src/main/java/io/nop/excel/imp/model/ImportFieldModel.java +++ b/nop-excel/src/main/java/io/nop/excel/imp/model/ImportFieldModel.java @@ -104,6 +104,13 @@ public static Map toNameMap(List fie return fieldNameMap; } + public String getDisplayNameOrName() { + String displayName = getDisplayName(); + if (displayName == null) + return getName(); + return displayName; + } + @Override public String getPropOrName() { String prop = getProp(); diff --git a/nop-excel/src/main/java/io/nop/excel/model/ExcelDataValidation.java b/nop-excel/src/main/java/io/nop/excel/model/ExcelDataValidation.java index e216f3f82..bb12b73f1 100644 --- a/nop-excel/src/main/java/io/nop/excel/model/ExcelDataValidation.java +++ b/nop-excel/src/main/java/io/nop/excel/model/ExcelDataValidation.java @@ -1,5 +1,6 @@ package io.nop.excel.model; +import io.nop.api.core.beans.DictBean; import io.nop.commons.util.StringHelper; import io.nop.core.model.table.CellRange; import io.nop.excel.model._gen._ExcelDataValidation; @@ -17,6 +18,12 @@ public ExcelDataValidation() { } + public static ExcelDataValidation buildFromDict(DictBean dict) { + ExcelDataValidation obj = new ExcelDataValidation(); + obj.setListOptions(dict.getLabels()); + return obj; + } + public List getRanges() { if (ranges == null) { ranges = CellRange.parseRangeList(getSqref()); diff --git a/nop-ooxml/nop-ooxml-xlsx/src/main/java/io/nop/ooxml/xlsx/imp/ImportModelToExportModel.java b/nop-ooxml/nop-ooxml-xlsx/src/main/java/io/nop/ooxml/xlsx/imp/ImportModelToExportModel.java index 8eb8d63c1..a20bd065a 100644 --- a/nop-ooxml/nop-ooxml-xlsx/src/main/java/io/nop/ooxml/xlsx/imp/ImportModelToExportModel.java +++ b/nop-ooxml/nop-ooxml-xlsx/src/main/java/io/nop/ooxml/xlsx/imp/ImportModelToExportModel.java @@ -1,24 +1,57 @@ package io.nop.ooxml.xlsx.imp; +import io.nop.api.core.beans.DictBean; +import io.nop.commons.cache.ICache; +import io.nop.commons.cache.MapCache; +import io.nop.core.dict.DictProvider; +import io.nop.core.lang.eval.IEvalScope; +import io.nop.core.model.table.CellPosition; +import io.nop.core.model.table.ICellView; import io.nop.core.model.table.tree.TreeCell; import io.nop.excel.imp.model.ImportFieldModel; import io.nop.excel.imp.model.ImportModel; import io.nop.excel.imp.model.ImportSheetModel; +import io.nop.excel.model.ExcelCell; +import io.nop.excel.model.ExcelDataValidation; import io.nop.excel.model.ExcelSheet; +import io.nop.excel.model.ExcelTable; import io.nop.excel.model.ExcelWorkbook; import io.nop.ooxml.xlsx.XlsxConstants; import io.nop.ooxml.xlsx.parse.ExcelWorkbookParser; +import io.nop.xlang.api.XLang; import java.util.List; +import static io.nop.ooxml.xlsx.imp.TreeObjectLayout.STYLE_ID_AUTO_SEQ; +import static io.nop.ooxml.xlsx.imp.TreeObjectLayout.STYLE_ID_COL; +import static io.nop.ooxml.xlsx.imp.TreeObjectLayout.STYLE_ID_HEADER; +import static io.nop.ooxml.xlsx.imp.TreeObjectLayout.STYLE_ID_LABEL; +import static io.nop.ooxml.xlsx.imp.TreeObjectLayout.STYLE_ID_SEQ; +import static io.nop.ooxml.xlsx.imp.TreeObjectLayout.STYLE_ID_TITLE; +import static io.nop.ooxml.xlsx.imp.TreeObjectLayout.STYLE_ID_VALUE; + public class ImportModelToExportModel { private ExcelWorkbook wk; private ExcelSheet dataSheet; + private String labelStyle; + private String valueStyle; + private String titleStyle; + private ICache cache = new MapCache<>("import", false); + private IEvalScope scope = XLang.newEvalScope(); public ImportModelToExportModel() { wk = new ExcelWorkbookParser().parseFromVirtualPath(XlsxConstants.SIMPLE_DATA_TEMPLATE_PATH); dataSheet = wk.getSheet(XlsxConstants.SHEET_DATA); wk.clearSheets(); + + labelStyle = this.getCellStyleId(0, 0); + valueStyle = this.getCellStyleId(1, 0); + titleStyle = this.getCellStyleId(0, 1); + } + + String getCellStyleId(int row, int col) { + ICellView cell = dataSheet.getTable().getCell(row, col); + return cell != null ? cell.getStyleId() : null; } public ExcelWorkbook build(ImportModel model) { @@ -33,23 +66,66 @@ ExcelSheet buildSheet(ImportSheetModel sheetModel) { ExcelSheet sheet = new ExcelSheet(); sheet.setName(sheetModel.getName()); sheet.setLocation(sheetModel.getLocation()); + + TreeObjectLayout layout = new TreeObjectLayout(); + TreeCell rootCell = layout.init(sheetModel); + + assignToTable(sheet.getTable(), rootCell.getChildren()); + + addValidation(rootCell, sheetModel); return sheet; } - private void layout(List cells, List fields, boolean list) { - if (list) { - boolean complex = hasSubFields(fields); - - } else { + private void assignToTable(ExcelTable table, List cells) { + for (TreeCell cell : cells) { + if (cell.isVirtual()) { + if (cell.getChildren() != null) + assignToTable(table, cell.getChildren()); + continue; + } + ExcelCell ec = (ExcelCell) table.makeCell(cell.getRowIndex(), cell.getColIndex()); + if (STYLE_ID_HEADER.equals(cell.getStyleId())) { + ec.setStyleId(labelStyle); + ImportFieldModel field = (ImportFieldModel) cell.getValue(); + ec.setValue(field.getDisplayNameOrName()); + } else if (STYLE_ID_LABEL.equals(cell.getStyleId())) { + ImportFieldModel field = (ImportFieldModel) cell.getValue(); + ec.setStyleId(labelStyle); + ec.setValue(field.getDisplayNameOrName()); + } else if (STYLE_ID_VALUE.equals(cell.getStyleId())) { + ec.setStyleId(valueStyle); + } else if (STYLE_ID_TITLE.equals(cell.getStyleId())) { + ec.setStyleId(titleStyle); + ImportFieldModel field = (ImportFieldModel) cell.getValue(); + ec.setValue(field.getDisplayNameOrName()); + }else if(STYLE_ID_AUTO_SEQ.equals(cell.getStyleId())){ + ec.setStyleId(labelStyle); + ec.setValue("序号"); + }else if(STYLE_ID_SEQ.equals(cell.getStyleId())){ + ec.setStyleId(valueStyle); + ec.setValue("1"); + } } } - private boolean hasSubFields(List fields) { - for (ImportFieldModel field : fields) { - if (field.hasFields()) - return true; + void addValidation(TreeCell cell, ImportSheetModel sheetModel) { + for (TreeCell child : cell.getChildren()) { + if (!STYLE_ID_COL.equals(child.getStyleId())) + continue; + + TreeCell fieldCell = child.getChildren().get(1); + ImportFieldModel field = (ImportFieldModel) fieldCell.getValue(); + if (field != null && field.getSchema() != null && field.getSchema().getDict() != null) { + String dictName = field.getSchema().getDict(); + DictBean dict = DictProvider.instance().getDict(null, dictName, cache, scope); + + ExcelDataValidation validation = ExcelDataValidation.buildFromDict(dict); + String start = CellPosition.toABString(fieldCell.getRowIndex(), fieldCell.getColIndex()); + String end = CellPosition.toABString(CellPosition.MAX_ROWS, fieldCell.getColIndex()); + validation.setSqref(start + ":" + end); + } } - return false; } + } \ No newline at end of file diff --git a/nop-ooxml/nop-ooxml-xlsx/src/main/java/io/nop/ooxml/xlsx/imp/TreeObjectLayout.java b/nop-ooxml/nop-ooxml-xlsx/src/main/java/io/nop/ooxml/xlsx/imp/TreeObjectLayout.java index 58fa486f5..7c67e40a0 100644 --- a/nop-ooxml/nop-ooxml-xlsx/src/main/java/io/nop/ooxml/xlsx/imp/TreeObjectLayout.java +++ b/nop-ooxml/nop-ooxml-xlsx/src/main/java/io/nop/ooxml/xlsx/imp/TreeObjectLayout.java @@ -1,57 +1,74 @@ package io.nop.ooxml.xlsx.imp; import io.nop.api.core.convert.ConvertHelper; +import io.nop.commons.type.StdDataType; import io.nop.core.model.table.tree.TreeCell; import io.nop.core.model.table.tree.TreeCellChildPosition; import io.nop.core.model.table.tree.TreeTableLayout; import io.nop.excel.imp.model.ImportFieldModel; import io.nop.excel.imp.model.ImportSheetModel; import io.nop.ooxml.xlsx.XlsxConstants; +import io.nop.xlang.xmeta.ISchema; import java.util.ArrayList; import java.util.List; public class TreeObjectLayout { - static final String STYLE_ID_ROW = "row"; - static final String STYLE_ID_LIST = "list"; - static final String STYLE_ID_SEQ = "seq"; + public static final String STYLE_ID_ROW = "row"; + public static final String STYLE_ID_LIST = "list"; + public static final String STYLE_ID_SEQ = "seq"; + public static final String STYLE_ID_LABEL = "label"; + public static final String STYLE_ID_VALUE = "value"; + public static final String STYLE_ID_TITLE = "title"; + public static final String STYLE_ID_HEADER = "header"; + public static final String STYLE_ID_COL = "col"; + public static final String STYLE_ID_FIELD = "field"; + public static final String STYLE_ID_AUTO_SEQ = "auto-seq"; private final List cells = new ArrayList<>(); public TreeCell init(ImportSheetModel sheetModel) { if (sheetModel.isList()) { - TreeObjectLayout layout = addListCell(sheetModel, hasSubFields(sheetModel.getFields())); - for (ImportFieldModel field : sheetModel.getFields()) { - layout.addField(field); - } + TreeCell cell = buildListCell(sheetModel.getFields(), sheetModel.isNoSeqCol()); + cells.add(cell); } else { - for (ImportFieldModel field : sheetModel.getFields()) { - addField(field); - } + addFields(sheetModel.getFields()); } return TreeTableLayout.instance().calcLayout(cells, true); } + private void addFields(List fields) { + for (ImportFieldModel field : fields) { + addField(field); + } + } + public List getCells() { return cells; } private void addField(ImportFieldModel mainField) { if (mainField.isList()) { - TreeObjectLayout layout = addListCell(mainField, hasSubFields(mainField.getFields())); - for (ImportFieldModel field : mainField.getFields()) { - layout.addField(field); - } + TreeCell cell = new TreeCell(mainField, TreeCellChildPosition.ver); + cell.setStyleId(STYLE_ID_FIELD); + + TreeCell title = new TreeCell(mainField); + title.setStyleId(STYLE_ID_TITLE); + cell.addChild(title); + + cell.addChild(buildListCell(mainField.getFields(), mainField.isNoSeqCol())); + cells.add(cell); } else if (mainField.hasFields()) { for (ImportFieldModel field : mainField.getFields()) { addField(field); } } else { - addSimpleCell(mainField.getName(), isSingleRow(mainField)); + addSimpleCell(mainField, isSingleRow(mainField)); } } + private boolean isSingleRow(ImportFieldModel field) { return ConvertHelper.toPrimitiveBoolean(field.prop_get(XlsxConstants.EXT_PROP_XPT_SINGLE_ROW)); } @@ -65,41 +82,81 @@ private boolean hasSubFields(List fields) { } public void addSimpleCell(Object value, boolean singleRow) { - TreeCell cell = new TreeCell(value); - if (cells.isEmpty() || singleRow) { - cells.add(cell); + TreeCell labelCell = new TreeCell(value); + labelCell.setStyleId(STYLE_ID_LABEL); + + TreeCell valueCell = new TreeCell(value); + valueCell.setStyleId(STYLE_ID_VALUE); + + if (cells.isEmpty() || singleRow || (cells.get(cells.size()-1).getChildren().size()/2) % 2 == 1) { + TreeCell row = new TreeCell(null, TreeCellChildPosition.hor); + row.setStyleId(STYLE_ID_ROW); + row.addChild(labelCell); + row.addChild(valueCell); + cells.add(row); } else { TreeCell last = cells.get(cells.size() - 1); - if (!last.hasChild()) { - last.setChildPos(TreeCellChildPosition.right_hor); - last.addChild(cell); - } else { - cells.add(cell); - } + last.addChild(labelCell); + last.addChild(valueCell); } } - public TreeObjectLayout addListCell(Object value, boolean complex) { - TreeCell cell = new TreeCell(value, TreeCellChildPosition.bottom_hor); + public TreeCell buildListCell(List fields, boolean noSeqCol) { + TreeCell cell = new TreeCell(null); cell.setStyleId(STYLE_ID_LIST); - cells.add(cell); - - TreeObjectLayout layout = new TreeObjectLayout(); + cell.setChildPos(TreeCellChildPosition.hor); + boolean complex = hasSubFields(fields); if (complex) { TreeCell child = new TreeCell("1"); child.setStyleId(STYLE_ID_SEQ); - child.setChildPos(TreeCellChildPosition.right_hor); cell.addChild(child); TreeCell child2 = new TreeCell(null); child2.setChildPos(TreeCellChildPosition.ver); - child.addChild(child2); + cell.addChild(child2); + TreeObjectLayout layout = new TreeObjectLayout(); + layout.addFields(fields); child2.setChildren(layout.cells); } else { - cell.setChildren(layout.cells); + boolean addSeqCol = shouldAddSeqCol(noSeqCol, fields); + if (addSeqCol) { + TreeCell col = new TreeCell(null, TreeCellChildPosition.ver); + col.setStyleId(STYLE_ID_COL); + TreeCell labelCell = new TreeCell(null); + labelCell.setStyleId(STYLE_ID_AUTO_SEQ); + TreeCell valueCell = new TreeCell(null); + valueCell.setStyleId(STYLE_ID_VALUE); + col.addChild(labelCell); + col.addChild(valueCell); + cell.addChild(col); + } + + for (ImportFieldModel field : fields) { + TreeCell col = new TreeCell(field, TreeCellChildPosition.ver); + col.setStyleId(STYLE_ID_COL); + TreeCell labelCell = new TreeCell(field); + labelCell.setStyleId(STYLE_ID_HEADER); + TreeCell valueCell = new TreeCell(field); + valueCell.setStyleId(STYLE_ID_VALUE); + col.addChild(labelCell); + col.addChild(valueCell); + cell.addChild(col); + } } - return layout; + return cell; + } + + private boolean shouldAddSeqCol(boolean noSeqCol, List fields) { + if (noSeqCol) + return false; + ImportFieldModel field = fields.get(0); + if (!field.isMandatory()) + return true; + ISchema schema = field.getSchema(); + if (schema == null) + return true; + return schema.getStdDataType() != StdDataType.INT; } } \ No newline at end of file diff --git a/nop-ooxml/nop-ooxml-xlsx/src/test/java/io/nop/ooxml/xlsx/imp/TestImportModelToExportModel.java b/nop-ooxml/nop-ooxml-xlsx/src/test/java/io/nop/ooxml/xlsx/imp/TestImportModelToExportModel.java new file mode 100644 index 000000000..2e94d8b16 --- /dev/null +++ b/nop-ooxml/nop-ooxml-xlsx/src/test/java/io/nop/ooxml/xlsx/imp/TestImportModelToExportModel.java @@ -0,0 +1,36 @@ +package io.nop.ooxml.xlsx.imp; + +import io.nop.core.initialize.CoreInitialization; +import io.nop.core.resource.IResource; +import io.nop.core.resource.VirtualFileSystem; +import io.nop.core.unittest.BaseTestCase; +import io.nop.excel.imp.model.ImportModel; +import io.nop.excel.model.ExcelWorkbook; +import io.nop.ooxml.xlsx.util.ExcelHelper; +import io.nop.xlang.xdsl.DslModelHelper; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class TestImportModelToExportModel extends BaseTestCase { + @BeforeAll + public static void init() { + CoreInitialization.initialize(); + } + + @AfterAll + public static void destroy() { + CoreInitialization.destroy(); + } + + @Test + public void testTransform() { + IResource resource = VirtualFileSystem.instance().getResource("/test/test-imp-to-excel.imp.xml"); + ImportModel model = (ImportModel) DslModelHelper.loadDslModel(resource); + ImportModelToExportModel transform = new ImportModelToExportModel(); + ExcelWorkbook wk = transform.build(model); + + IResource targetResource = getTargetResource("test-imp-to-excel.xlsx"); + ExcelHelper.saveExcel(targetResource, wk); + } +} diff --git a/nop-ooxml/nop-ooxml-xlsx/src/test/resources/_vfs/test/test-imp-to-excel.imp.xml b/nop-ooxml/nop-ooxml-xlsx/src/test/resources/_vfs/test/test-imp-to-excel.imp.xml new file mode 100644 index 000000000..2598102bd --- /dev/null +++ b/nop-ooxml/nop-ooxml-xlsx/src/test/resources/_vfs/test/test-imp-to-excel.imp.xml @@ -0,0 +1,587 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + if(col.tagSet?.contains('not-gen') || col.tagSet?.contains('del')) + col.notGenCode = true; + + if(col.name == 'id_' and col.primary and rootRecord['ext:allowIdAsColName']) + col.name = 'id' + + const domain = col.domain; + if(!domain) return; + if(domain == 'version'){ + record.versionProp = col.name; + }else if(domain == 'createTime'){ + record.createTimeProp = col.name; + }else if(domain == 'createdBy'){ + record.createrProp = col.name; + }else if(domain == 'updateTime'){ + record.updateTimeProp = col.name; + }else if(domain == 'updatedBy'){ + record.updaterProp = col.name; + }else if(domain == 'delFlag'){ + record.deleteFlagProp = col.name; + record.useLogicalDelete = true; + }else if(domain == 'delVersion'){ + record.deleteVersionProp = col.name; + }else if(domain == 'tenant'){ + record.tenantProp = col.name; + record.useTenant = true; + }else if(domain == 'shard'){ + record.shardProp = col.name; + record.useShard = true; + } + }); + ]]> + + + + + + + + + + + value?.$fullClassName(rootRecord['ext:entityPackageName']) + + + + + + + value?.$fullClassName(rootRecord['ext:entityPackageName']) + + + + + + + value?.$fullClassName(rootRecord['ext:entityPackageName']) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if(!record.name){ + // id被保留为系统自动生成的主键名,因此不会作为字段名 + record.name = record.code.$colCodeToPropName() + } + + + + + + + + + + + value == 'PK' || value == 'Y' || value == true + + + + + + + + + value == 'M' || value == 'Y' || value == true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + value.$fullClassName(rootRecord['ext:entityPackageName']) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + value || 'to-one' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +